diff --git a/api/.env.dev.local b/api/.env.dev.local index 4e3ce72..2612624 100644 --- a/api/.env.dev.local +++ b/api/.env.dev.local @@ -9,3 +9,5 @@ # one found in a remote Prisma Postgres URL, does not contain any sensitive information. DATABASE_URL="postgresql://root:123@localhost:5432/nestjs?schema=public" +JWT_SECRET="local-jwt-test-secret" +JWT_REFRESH_SECRET="local-jwt-refresh-test-secret" diff --git a/api/oauth-mock-server/index.ts b/api/oauth-mock-server/index.ts index ff065d8..ee9358e 100644 --- a/api/oauth-mock-server/index.ts +++ b/api/oauth-mock-server/index.ts @@ -48,7 +48,6 @@ app.post('/token', (req: any, res: any) => { exp: now + 3600, }, privateKey, - { algorithm: 'RS256' }, ); res.json({ diff --git a/api/package-lock.json b/api/package-lock.json index d6ffb25..1afaeb8 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.2.2", "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^6.12.0", "class-transformer": "^0.5.1", @@ -23,6 +24,7 @@ "graphql": "^16.11.0", "jsonwebtoken": "^9.0.2", "passport-google-oauth": "^2.0.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, @@ -2092,6 +2094,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.19", "license": "MIT", @@ -7161,6 +7173,25 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-google-oauth": { "version": "2.0.0", "license": "MIT", @@ -7189,6 +7220,16 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, "node_modules/passport-oauth1": { "version": "1.3.0", "license": "MIT", @@ -7294,6 +7335,12 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==", + "peer": true + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, diff --git a/api/package.json b/api/package.json index 35e4fbf..222b093 100644 --- a/api/package.json +++ b/api/package.json @@ -28,6 +28,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.2.2", "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@prisma/client": "^6.12.0", "class-transformer": "^0.5.1", @@ -35,6 +36,7 @@ "graphql": "^16.11.0", "jsonwebtoken": "^9.0.2", "passport-google-oauth": "^2.0.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1" }, diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 4e99ef6..a185b0b 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -5,18 +5,26 @@ import { AuthResolver } from './auth.resolver'; import { PrismaService } from '../prisma/prisma.service'; import { UserModule } from '../user/user.module'; import { SessionModule } from '../session/session.module'; +import { AccessTokenStrategy } from './strategy/access-token.strategy'; +import { RefreshTokenStrategy } from './strategy/refresh-token.strategy'; @Module({ imports: [ forwardRef(() => UserModule), forwardRef(() => SessionModule), JwtModule.register({ - secret: process.env.JWT_SECRET || 'defaultSecretKey', + secret: process.env.JWT_SECRET || 'local-jwt-test-secret', signOptions: { expiresIn: '1h' }, global: true, }), ], - providers: [AuthResolver, AuthService, PrismaService], + providers: [ + AuthResolver, + AuthService, + PrismaService, + AccessTokenStrategy, + RefreshTokenStrategy, + ], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/api/src/auth/auth.resolver.spec.ts b/api/src/auth/auth.resolver.spec.ts index 637593d..1b202e6 100644 --- a/api/src/auth/auth.resolver.spec.ts +++ b/api/src/auth/auth.resolver.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthResolver } from './auth.resolver'; import { AuthService } from './auth.service'; +import { JwtService } from '@nestjs/jwt'; describe('AuthResolver', () => { let resolver: AuthResolver; @@ -12,12 +13,15 @@ describe('AuthResolver', () => { findOneByEmail: jest.fn(), refreshToken: jest.fn(), remove: jest.fn(), + login: jest.fn(), + logout: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ AuthResolver, { provide: AuthService, useValue: authServiceMock }, + JwtService, ], }).compile(); @@ -32,8 +36,9 @@ describe('AuthResolver', () => { it('should call authService.create and return AuthPayload', async () => { const input = { email: 'test@example.com', name: 'Test' }; const payload = { - user: { id: 1, email: 'test@example.com' }, - refreshToken: 'token', + user: { id: '1', email: 'test@example.com', name: 'Test' }, + accessToken: 'access', + refreshToken: 'refresh', }; authServiceMock.create.mockResolvedValue(payload); @@ -45,7 +50,7 @@ describe('AuthResolver', () => { describe('findOneByEmail', () => { it('should call authService.findOneByEmail and return User', async () => { - const user = { id: 1, email: 'test@example.com' }; + const user = { id: '1', email: 'test@example.com', name: 'Test' }; authServiceMock.findOneByEmail.mockResolvedValue(user); const result = await resolver.findOneByEmail('test@example.com'); @@ -57,26 +62,61 @@ describe('AuthResolver', () => { }); describe('refreshToken', () => { - it('should call authService.refreshToken and return AuthPayload', async () => { - const payload = { - user: { id: 1, email: 'test@example.com' }, + it('should call authService.refreshToken with userId and refreshToken', async () => { + const payload = { userId: '1', email: 'test@example.com', name: 'Test' }; + const refreshToken = 'oldtoken'; + const returnedPayload = { + user: { id: '1', email: 'test@example.com', name: 'Test' }, refreshToken: 'newtoken', + accessToken: 'newAccess', }; - authServiceMock.refreshToken.mockResolvedValue(payload); + authServiceMock.refreshToken.mockResolvedValue(returnedPayload); - const result = await resolver.refreshToken('oldtoken'); - expect(authServiceMock.refreshToken).toHaveBeenCalledWith('oldtoken'); - expect(result).toBe(payload); + const result = await resolver.refreshToken(payload as any, refreshToken); + expect(authServiceMock.refreshToken).toHaveBeenCalledWith( + payload.userId, + refreshToken, + ); + expect(result).toBe(returnedPayload); }); }); describe('removeAuth', () => { - it('should call authService.remove and return true', async () => { - authServiceMock.remove.mockResolvedValue({ id: 'abc' }); + it('should call authService.remove with userId from payload', async () => { + const payload = { userId: '1', email: 'test@example.com', name: 'Test' }; + authServiceMock.remove.mockResolvedValue(true); + + const result = await resolver.removeAuth(payload as any); + expect(authServiceMock.remove).toHaveBeenCalledWith(payload.userId); + expect(result).toBe(true); + }); + }); + + describe('login', () => { + it('should call authService.login with email', async () => { + const email = 'test@example.com'; + const returnedPayload = { + user: { id: '1', email, name: 'Test' }, + accessToken: 'access', + refreshToken: 'refresh', + }; + authServiceMock.login.mockResolvedValue(returnedPayload); + + const result = await resolver.login(email); + expect(authServiceMock.login).toHaveBeenCalledWith(email); + expect(result).toBe(returnedPayload); + }); + }); + + describe('logout', () => { + it('should call authService.logout with refreshToken', async () => { + const refreshToken = 'refresh'; + authServiceMock.logout.mockResolvedValue(true); + + const result = await resolver.logout(refreshToken); - const result = await resolver.removeAuth('abc'); - expect(authServiceMock.remove).toHaveBeenCalledWith('abc'); - expect(result).toBeTruthy(); + expect(authServiceMock.logout).toHaveBeenCalledWith(refreshToken); + expect(result).toBe(true); }); }); }); diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts index 5f53e78..eb296e2 100644 --- a/api/src/auth/auth.resolver.ts +++ b/api/src/auth/auth.resolver.ts @@ -4,6 +4,11 @@ import { Auth } from './entities/auth.entity'; import { User } from '../user/entities/user.entity'; import { CreateAuthInput } from './dto/create-auth.input'; import { AuthPayload } from './entities/authPayload.entity'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from './guards/auth.guard'; +import { RefreshTokenGuard } from './guards/refresh-token.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from './types/jwt-payload.type'; @Resolver(() => Auth) export class AuthResolver { @@ -20,14 +25,19 @@ export class AuthResolver { } // refresh token - @Mutation(() => AuthPayload, { name: 'refreshToken' }) - refreshToken(@Args('refreshToken') refreshToken: string) { - return this.authService.refreshToken(refreshToken); + @Mutation(() => AuthPayload, { name: 'payload' }) + @UseGuards(RefreshTokenGuard) + refreshToken( + @CurrentUser('payload') payload: JwtPayload, + @Args('refreshToken', { type: () => String }) refreshToken: string, + ) { + return this.authService.refreshToken(payload.userId, refreshToken); } @Mutation(() => Boolean, { name: 'removeAuth' }) - removeAuth(@Args('id', { type: () => String }) id: string) { - return this.authService.remove(id); + @UseGuards(GqlAuthGuard) + removeAuth(@CurrentUser() payload: JwtPayload) { + return this.authService.remove(payload.userId); } @Mutation(() => AuthPayload, { name: 'login' }) @@ -36,7 +46,8 @@ export class AuthResolver { } @Mutation(() => Boolean, { name: 'logout' }) - async logout(@Args('refreshToken') refreshToken: string) { - return this.authService.logout(refreshToken); + @UseGuards(GqlAuthGuard) + async logout(@CurrentUser() payload: JwtPayload) { + return this.authService.logoutByUserId(payload.userId); } } diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 0e7553f..92af3dd 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -38,7 +38,11 @@ export class AuthService { }, }); - const payload = { sub: newUser.id, name: newUser.name }; + const payload = { + userId: newUser.id, + name: newUser.name, + email: newUser.email, + }; const accessToken = this.jwtService.sign(payload); // Generate your own refresh token for the session (recommended) @@ -76,30 +80,35 @@ export class AuthService { } async remove(id: string) { - const oauthAccount = await this.prisma.oAuthAccount.findUnique({ + const user = await this.prisma.user.findUnique({ where: { id }, }); - if (!oauthAccount) { - throw new NotFoundException(`OAuthAccount with ID ${id} not found`); + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); } - await this.prisma.oAuthAccount.delete({ where: { id } }); + await this.prisma.user.delete({ where: { id } }); return true; } - async refreshToken(refreshToken: string) { + async refreshToken(userId: string, refreshToken: string) { + const user = await this.userService.findOneById(userId); const session = await this.sessionService.findUnique(refreshToken); if (!session) { throw new NotFoundException('Session not found'); } + if (session.userId !== user.id) { + throw new NotFoundException('Session not found for this user'); + } + const newSession = await this.sessionService.update(refreshToken); return { - user: session.user, + user: newSession.user, refreshToken: newSession.refreshToken, }; } @@ -109,12 +118,15 @@ export class AuthService { if (!user) { throw new NotFoundException(`User with email ${email} not found`); } - // console.log('Login User Auth Service: ', user); // verify password if using local auth // const valid = await bcrypt.compare(input.password, user.passwordHash); - const payload = { sub: user.id, name: user.name }; + const payload = { + userId: user.id, + name: user.name, + email: user.email, + }; const accessToken = this.jwtService.sign(payload); const refreshToken = this.sessionService.generateSecureToken(); @@ -130,14 +142,9 @@ export class AuthService { return { user, accessToken, refreshToken }; } - async logout(refreshToken: string): Promise { - const session = await this.sessionService.findUnique(refreshToken); - - if (!session) { - throw new NotFoundException('Session not found'); - } - - await this.sessionService.removeByRefeshToken(refreshToken); + async logoutByUserId(userId: string): Promise { + // Remove all sessions for this user + await this.sessionService.removeByUserId(userId); return true; } } diff --git a/api/src/auth/auth.guard.ts b/api/src/auth/guards/auth.guard.ts similarity index 72% rename from api/src/auth/auth.guard.ts rename to api/src/auth/guards/auth.guard.ts index ab9cd2d..f0e09fd 100644 --- a/api/src/auth/auth.guard.ts +++ b/api/src/auth/guards/auth.guard.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { JwtService } from '@nestjs/jwt'; +import { JwtPayload } from '../types/jwt-payload.type'; @Injectable() export class GqlAuthGuard implements CanActivate { @@ -18,11 +19,13 @@ export class GqlAuthGuard implements CanActivate { const authHeader = req.headers.authorization; if (!authHeader) throw new UnauthorizedException('No authorization header'); - const token = authHeader.replace('Bearer ', ''); + const token = authHeader.split(' ')[1]?.trim(); + if (!token) throw new UnauthorizedException('No token provided'); try { - const decoded = await this.jwtService.verifyAsync(token, { - secret: process.env.JWT_SECRET || 'defaultSecretKey', + const decoded = await this.jwtService.verifyAsync(token, { + secret: process.env.JWT_SECRET, + // ignoreExpiration: true, }); req.user = decoded; // store user in request diff --git a/api/src/auth/guards/refresh-token.guard.ts b/api/src/auth/guards/refresh-token.guard.ts new file mode 100644 index 0000000..c8e27e7 --- /dev/null +++ b/api/src/auth/guards/refresh-token.guard.ts @@ -0,0 +1,6 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +// refresh-token.guard.ts +@Injectable() +export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {} diff --git a/api/src/auth/strategy/access-token.strategy.ts b/api/src/auth/strategy/access-token.strategy.ts new file mode 100644 index 0000000..dd2e817 --- /dev/null +++ b/api/src/auth/strategy/access-token.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthPayload } from '../entities/authPayload.entity'; + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, + ignoreExpiration: false, + }); + } + + async validate(payload: AuthPayload) { + // This becomes @CurrentUser() in resolvers + return { + userId: payload.user.id, + email: payload.user.email, + name: payload.user.name, + }; + } +} diff --git a/api/src/auth/strategy/refresh-token.strategy.ts b/api/src/auth/strategy/refresh-token.strategy.ts new file mode 100644 index 0000000..2b0cb54 --- /dev/null +++ b/api/src/auth/strategy/refresh-token.strategy.ts @@ -0,0 +1,30 @@ +// refresh-token.strategy.ts +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Request } from 'express'; +import { JwtPayload } from '../types/jwt-payload.type'; + +@Injectable() +export class RefreshTokenStrategy extends PassportStrategy( + Strategy, + 'jwt-refresh', // strategy name +) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // or from cookie + secretOrKey: process.env.JWT_REFRESH_SECRET, // different secret than access token + passReqToCallback: true, + }); + } + + async validate(req: Request, payload: JwtPayload) { + // Optionally also validate that the refreshToken still exists in DB + const refreshToken = req + ?.get('authorization') + ?.replace('Bearer', '') + .trim(); + + return { ...payload, refreshToken }; // will be injected into @CurrentUser() + } +} diff --git a/api/src/auth/types/jwt-payload.type.ts b/api/src/auth/types/jwt-payload.type.ts new file mode 100644 index 0000000..f64e572 --- /dev/null +++ b/api/src/auth/types/jwt-payload.type.ts @@ -0,0 +1,8 @@ +// auth/types/jwt-payload.type.ts +export interface JwtPayload { + userId: string; + email: string; + name: string; + iat?: number; + exp?: number; +} diff --git a/api/src/challenge/challenge.resolver.spec.ts b/api/src/challenge/challenge.resolver.spec.ts index b9f9785..2542ee1 100644 --- a/api/src/challenge/challenge.resolver.spec.ts +++ b/api/src/challenge/challenge.resolver.spec.ts @@ -1,20 +1,28 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ChallengeResolver } from './challenge.resolver'; import { ChallengeService } from './challenge.service'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { InternalServerErrorException } from '@nestjs/common'; import { CreateChallengeInput } from './dto/create-challenge.input'; import { UpdateChallengeInput } from './dto/update-challenge.input'; +import { JwtPayload } from 'jsonwebtoken'; const mockChallengeService = { create: jest.fn(), findOneById: jest.fn(), + findOneByIdUser: jest.fn(), update: jest.fn(), remove: jest.fn(), findAll: jest.fn(), }; -const mockUser = { sub: 'user123' }; +const mockPayload: JwtPayload = { + id: '123', + email: 'test@email.com', + name: 'Test User', + iat: 0, + exp: 0, +}; describe('ChallengeResolver', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -29,7 +37,7 @@ describe('ChallengeResolver', () => { ], }) .overrideGuard(GqlAuthGuard) - .useValue({ canActivate: () => true }) // Mock guard + .useValue({ canActivate: () => true }) // mock auth guard .compile(); resolver = module.get(ChallengeResolver); @@ -44,17 +52,17 @@ describe('ChallengeResolver', () => { }); describe('createChallenge', () => { - it('should call challengeService.create with input and user.sub', async () => { + it('should call challengeService.create with input and payload.id', async () => { const input: CreateChallengeInput = { name: 'New Challenge' }; const expected = { id: 'ch1', ...input }; mockChallengeService.create.mockResolvedValue(expected); - const result = await resolver.createChallenge(input, mockUser); + const result = await resolver.createChallenge(input, mockPayload); expect(result).toEqual(expected); expect(mockChallengeService.create).toHaveBeenCalledWith( input, - mockUser.sub, + mockPayload.id, ); }); @@ -62,14 +70,39 @@ describe('ChallengeResolver', () => { const input: CreateChallengeInput = { name: 'Test' }; mockChallengeService.create.mockRejectedValue(new Error('DB error')); - await expect(resolver.createChallenge(input, mockUser)).rejects.toThrow( + await expect( + resolver.createChallenge(input, mockPayload), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('challenge', () => { + it('should call challengeService.findOneByIdUser with id and payload.id', async () => { + const id = 'uuid-123'; + const expected = { id, name: 'Challenge Name' }; + mockChallengeService.findOneByIdUser.mockResolvedValue(expected); + + const result = await resolver.challenge(id, mockPayload); + + expect(result).toEqual(expected); + expect(mockChallengeService.findOneByIdUser).toHaveBeenCalledWith( + id, + mockPayload.id, + ); + }); + + it('should throw InternalServerErrorException on service error', async () => { + mockChallengeService.findOneByIdUser.mockRejectedValue( + new Error('DB error'), + ); + await expect(resolver.challenge('uuid-123', mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); }); describe('findOneById', () => { - it('should return a challenge by ID', async () => { + it('should call challengeService.findOneById with id', async () => { const id = 'uuid-123'; const expected = { id, name: 'Challenge Name' }; mockChallengeService.findOneById.mockResolvedValue(expected); @@ -88,22 +121,22 @@ describe('ChallengeResolver', () => { }); describe('updateChallenge', () => { - it('should call challengeService.update with id, input and user.sub', async () => { + it('should call challengeService.update with id, input, and payload.id', async () => { const input: UpdateChallengeInput = { id: 'uuid-123', name: 'Updated Challenge', }; - const expected = { id: 'uuid-123', name: 'Updated Challenge' }; + const expected = { ...input }; mockChallengeService.update.mockResolvedValue(expected); - const result = await resolver.updateChallenge(input, mockUser); + const result = await resolver.updateChallenge(input, mockPayload); expect(result).toEqual(expected); expect(mockChallengeService.update).toHaveBeenCalledWith( input.id, input, - mockUser.sub, + mockPayload.id, ); }); @@ -111,25 +144,25 @@ describe('ChallengeResolver', () => { const input: UpdateChallengeInput = { id: 'uuid-123', name: 'Fail' }; mockChallengeService.update.mockRejectedValue(new Error('DB error')); - await expect(resolver.updateChallenge(input, mockUser)).rejects.toThrow( - InternalServerErrorException, - ); + await expect( + resolver.updateChallenge(input, mockPayload), + ).rejects.toThrow(InternalServerErrorException); }); }); describe('removeChallenge', () => { - it('should call challengeService.remove with id and user.sub', async () => { + it('should call challengeService.remove with id and payload.id', async () => { const id = 'uuid-123'; const expected = { id }; mockChallengeService.remove.mockResolvedValue(expected); - const result = await resolver.removeChallenge(id, mockUser); + const result = await resolver.removeChallenge(id, mockPayload); expect(result).toEqual(expected); expect(mockChallengeService.remove).toHaveBeenCalledWith( id, - mockUser.sub, + mockPayload.id, ); }); @@ -137,7 +170,7 @@ describe('ChallengeResolver', () => { mockChallengeService.remove.mockRejectedValue(new Error('DB error')); await expect( - resolver.removeChallenge('uuid-123', mockUser), + resolver.removeChallenge('uuid-123', mockPayload), ).rejects.toThrow(InternalServerErrorException); }); }); diff --git a/api/src/challenge/challenge.resolver.ts b/api/src/challenge/challenge.resolver.ts index 5baea5e..da8f98f 100644 --- a/api/src/challenge/challenge.resolver.ts +++ b/api/src/challenge/challenge.resolver.ts @@ -4,8 +4,10 @@ import { Challenge } from './entities/challenge.entity'; import { CreateChallengeInput } from './dto/create-challenge.input'; import { UpdateChallengeInput } from './dto/update-challenge.input'; import { UseGuards, InternalServerErrorException } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +// import { User } from '../user/entities/user.entity'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Challenge) export class ChallengeResolver { @@ -15,10 +17,13 @@ export class ChallengeResolver { @UseGuards(GqlAuthGuard) async createChallenge( @Args('createChallengeInput') createChallengeInput: CreateChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.challengeService.create(createChallengeInput, user.sub); + return await this.challengeService.create( + createChallengeInput, + payload.userId, + ); } catch (error) { console.error('Error creating challenge:', error); throw new InternalServerErrorException('Failed to create challenge'); @@ -29,10 +34,10 @@ export class ChallengeResolver { @UseGuards(GqlAuthGuard) async challenge( @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.challengeService.findOneByIdUser(id, user.sub); + return await this.challengeService.findOneByIdUser(id, payload.userId); } catch (error) { console.error('Error fetching challenge:', error); throw new InternalServerErrorException('Failed to fetch challenge'); @@ -61,13 +66,13 @@ export class ChallengeResolver { @UseGuards(GqlAuthGuard) async updateChallenge( @Args('updateChallengeInput') updateChallengeInput: UpdateChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return await this.challengeService.update( updateChallengeInput.id, updateChallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error updating challenge:', error); @@ -79,10 +84,10 @@ export class ChallengeResolver { @UseGuards(GqlAuthGuard) async removeChallenge( @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.challengeService.remove(id, user.sub); + return await this.challengeService.remove(id, payload.userId); } catch (error) { console.error('Error removing challenge:', error); throw new InternalServerErrorException('Failed to remove challenge'); diff --git a/api/src/challenge/dto/create-challenge.input.ts b/api/src/challenge/dto/create-challenge.input.ts index 95aa34a..47fc49f 100644 --- a/api/src/challenge/dto/create-challenge.input.ts +++ b/api/src/challenge/dto/create-challenge.input.ts @@ -11,10 +11,6 @@ export class CreateChallengeInput { nullable: true, description: 'Optional description of the challenge', }) - @IsOptional() - @IsString() - description?: string; - @Field(() => Date, { nullable: true, description: 'Optional end date for the challenge', diff --git a/api/src/comment/comment.resolver.spec.ts b/api/src/comment/comment.resolver.spec.ts index b0257ef..2af6307 100644 --- a/api/src/comment/comment.resolver.spec.ts +++ b/api/src/comment/comment.resolver.spec.ts @@ -1,8 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CommentResolver } from './comment.resolver'; import { CommentService } from './comment.service'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { InternalServerErrorException } from '@nestjs/common'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; const mockCommentService = { create: jest.fn(), @@ -11,7 +12,11 @@ const mockCommentService = { remove: jest.fn(), }; -const mockUser = { sub: 'user123' }; +const mockPayload: JwtPayload = { + userId: 'user123', + email: 'test@email.com', + name: 'Test User', +}; describe('CommentResolver', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -41,15 +46,16 @@ describe('CommentResolver', () => { }); describe('createComment', () => { - it('should call commentService.create with user.sub and input', async () => { + it('should call commentService.create with payload.userId and input', async () => { const input = { content: 'test comment', postId: 'post1' }; const expected = { id: 'c1', ...input }; mockCommentService.create.mockResolvedValue(expected); - const result = await resolver.createComment(input, mockUser); + const result = await resolver.createComment(input, mockPayload); + expect(result).toEqual(expected); expect(mockCommentService.create).toHaveBeenCalledWith( - mockUser.sub, + mockPayload.userId, input, ); }); @@ -58,7 +64,7 @@ describe('CommentResolver', () => { const input = { content: 'test comment', postId: 'post1' }; mockCommentService.create.mockRejectedValue(new Error('DB error')); - await expect(resolver.createComment(input, mockUser)).rejects.toThrow( + await expect(resolver.createComment(input, mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); @@ -71,6 +77,7 @@ describe('CommentResolver', () => { mockCommentService.findOne.mockResolvedValue(expected); const result = await resolver.findOne(commentId); + expect(result).toEqual(expected); expect(mockCommentService.findOne).toHaveBeenCalledWith(commentId); }); @@ -85,15 +92,16 @@ describe('CommentResolver', () => { }); describe('updateComment', () => { - it('should call commentService.update with user.sub and input', async () => { + it('should call commentService.update with payload.userId and input', async () => { const input = { id: 'c1', content: 'updated text' }; - const expected = { id: 'c1', content: 'updated text' }; + const expected = { ...input }; mockCommentService.update.mockResolvedValue(expected); - const result = await resolver.updateComment(input, mockUser); + const result = await resolver.updateComment(input, mockPayload); + expect(result).toEqual(expected); expect(mockCommentService.update).toHaveBeenCalledWith( - mockUser.sub, + mockPayload.userId, input, ); }); @@ -102,22 +110,23 @@ describe('CommentResolver', () => { const input = { id: 'c1', content: 'updated text' }; mockCommentService.update.mockRejectedValue(new Error('DB error')); - await expect(resolver.updateComment(input, mockUser)).rejects.toThrow( + await expect(resolver.updateComment(input, mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); }); describe('removeComment', () => { - it('should call commentService.remove with user.sub and id', async () => { + it('should call commentService.remove with payload.userId and id', async () => { const commentId = 'c1'; const expected = { id: commentId }; mockCommentService.remove.mockResolvedValue(expected); - const result = await resolver.removeComment(commentId, mockUser); + const result = await resolver.removeComment(commentId, mockPayload); + expect(result).toEqual(expected); expect(mockCommentService.remove).toHaveBeenCalledWith( - mockUser.sub, + mockPayload.userId, commentId, ); }); @@ -125,7 +134,7 @@ describe('CommentResolver', () => { it('should throw InternalServerErrorException on service error', async () => { mockCommentService.remove.mockRejectedValue(new Error('DB error')); - await expect(resolver.removeComment('c1', mockUser)).rejects.toThrow( + await expect(resolver.removeComment('c1', mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); diff --git a/api/src/comment/comment.resolver.ts b/api/src/comment/comment.resolver.ts index 7fc81e1..6d8bfc5 100644 --- a/api/src/comment/comment.resolver.ts +++ b/api/src/comment/comment.resolver.ts @@ -5,7 +5,8 @@ import { CreateCommentInput } from './dto/create-comment.input'; import { UpdateCommentInput } from './dto/update-comment.input'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { UseGuards, InternalServerErrorException } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Comment) export class CommentResolver { @@ -15,10 +16,13 @@ export class CommentResolver { @UseGuards(GqlAuthGuard) async createComment( @Args('createCommentInput') createCommentInput: CreateCommentInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.commentService.create(user.sub, createCommentInput); + return await this.commentService.create( + payload.userId, + createCommentInput, + ); } catch (error) { console.error('Error creating comment:', error); throw new InternalServerErrorException('Failed to create comment'); @@ -40,10 +44,13 @@ export class CommentResolver { @UseGuards(GqlAuthGuard) async updateComment( @Args('updateCommentInput') updateCommentInput: UpdateCommentInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.commentService.update(user.sub, updateCommentInput); + return await this.commentService.update( + payload.userId, + updateCommentInput, + ); } catch (error) { console.error('Error updating comment:', error); throw new InternalServerErrorException('Failed to update comment'); @@ -54,10 +61,10 @@ export class CommentResolver { @UseGuards(GqlAuthGuard) async removeComment( @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.commentService.remove(user.sub, id); + return await this.commentService.remove(payload.userId, id); } catch (error) { console.error('Error removing comment:', error); throw new InternalServerErrorException('Failed to remove comment'); diff --git a/api/src/league/league.resolver.spec.ts b/api/src/league/league.resolver.spec.ts index 09e77a1..eddca37 100644 --- a/api/src/league/league.resolver.spec.ts +++ b/api/src/league/league.resolver.spec.ts @@ -5,8 +5,8 @@ import { CreateLeagueInput } from './dto/create-league.input'; import { UpdateLeagueInput } from './dto/update-league.input'; import { InternalServerErrorException } from '@nestjs/common'; import { ExecutionContext } from '@nestjs/common'; - -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; describe('LeagueResolver', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -14,7 +14,12 @@ describe('LeagueResolver', () => { let resolver: LeagueResolver; let leagueService: jest.Mocked; - const mockUser = { sub: 'user-1' }; + const mockPayload: JwtPayload = { + userId: 'user-1', + email: 'test@email.com', + name: 'Test User', + }; + const mockLeague = { id: 'league-1', name: 'Test League', @@ -41,7 +46,7 @@ describe('LeagueResolver', () => { .useValue({ canActivate: (context: ExecutionContext) => { const ctx = context.getArgByIndex(2); // GraphQL context - ctx.req = { user: mockUser }; // Inject mock user + ctx.req = { user: mockPayload }; // Inject mock payload return true; }, }) @@ -57,18 +62,20 @@ describe('LeagueResolver', () => { const input: CreateLeagueInput = { name: 'Test League' }; leagueService.create.mockResolvedValue(mockLeague); - const result = await resolver.createLeague(input, mockUser); - expect(leagueService.create).toHaveBeenCalledWith(input, mockUser.sub); // adjust if order is different + const result = await resolver.createLeague(input, mockPayload); + + expect(leagueService.create).toHaveBeenCalledWith( + input, + mockPayload.userId, + ); expect(result).toEqual(mockLeague); }); it('should throw InternalServerErrorException on service failure', async () => { const input: CreateLeagueInput = { name: 'Test League' }; - leagueService.create.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + leagueService.create.mockRejectedValue(new Error('DB error')); - await expect(resolver.createLeague(input, mockUser)).rejects.toThrow( + await expect(resolver.createLeague(input, mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); @@ -76,42 +83,61 @@ describe('LeagueResolver', () => { describe('findOneByIdUser', () => { it('should call leagueService.findOneByIdUser and return the result', async () => { - leagueService.findOneByIdUser.mockResolvedValue(mockLeague); // <- correct method + leagueService.findOneByIdUser.mockResolvedValue(mockLeague); - const result = await resolver.findOneByIdUser('league-1', mockUser); + const result = await resolver.findOneByIdUser('league-1', mockPayload); expect(leagueService.findOneByIdUser).toHaveBeenCalledWith( 'league-1', - mockUser.sub, + mockPayload.userId, ); expect(result).toEqual(mockLeague); }); it('should throw InternalServerErrorException on service failure', async () => { - leagueService.findOneByIdUser.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + leagueService.findOneByIdUser.mockRejectedValue(new Error('DB error')); await expect( - resolver.findOneByIdUser('league-1', mockUser), + resolver.findOneByIdUser('league-1', mockPayload), ).rejects.toThrow(InternalServerErrorException); }); }); + describe('findLeagueById', () => { + it('should call leagueService.findOneById and return the result', async () => { + leagueService.findOneById.mockResolvedValue(mockLeague); + + const result = await resolver.findLeagueById('league-1'); + + expect(leagueService.findOneById).toHaveBeenCalledWith('league-1'); + expect(result).toEqual(mockLeague); + }); + + it('should throw InternalServerErrorException on service failure', async () => { + leagueService.findOneById.mockRejectedValue(new Error('DB error')); + + await expect(resolver.findLeagueById('league-1')).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + describe('updateLeague', () => { it('should call leagueService.update and return the result', async () => { const input: UpdateLeagueInput = { id: 'league-1', name: 'Updated League', }; - leagueService.update.mockResolvedValue({ - ...mockLeague, - name: 'Updated League', - }); + const updated = { ...mockLeague, name: 'Updated League' }; + leagueService.update.mockResolvedValue(updated); + + const result = await resolver.updateLeague(input, mockPayload); - const result = await resolver.updateLeague(input, mockUser); - expect(leagueService.update).toHaveBeenCalledWith(input, mockUser.sub); - expect(result).toEqual({ ...mockLeague, name: 'Updated League' }); + expect(leagueService.update).toHaveBeenCalledWith( + input, + mockPayload.userId, + ); + expect(result).toEqual(updated); }); it('should throw InternalServerErrorException on service failure', async () => { @@ -119,11 +145,9 @@ describe('LeagueResolver', () => { id: 'league-1', name: 'Updated League', }; - leagueService.update.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + leagueService.update.mockRejectedValue(new Error('DB error')); - await expect(resolver.updateLeague(input, mockUser)).rejects.toThrow( + await expect(resolver.updateLeague(input, mockPayload)).rejects.toThrow( InternalServerErrorException, ); }); @@ -133,22 +157,21 @@ describe('LeagueResolver', () => { it('should call leagueService.remove and return the result', async () => { leagueService.remove.mockResolvedValue(mockLeague); - const result = await resolver.removeLeague('league-1', mockUser); + const result = await resolver.removeLeague('league-1', mockPayload); + expect(leagueService.remove).toHaveBeenCalledWith( 'league-1', - mockUser.sub, + mockPayload.userId, ); expect(result).toEqual(mockLeague); }); it('should throw InternalServerErrorException on service failure', async () => { - leagueService.remove.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + leagueService.remove.mockRejectedValue(new Error('DB error')); - await expect(resolver.removeLeague('league-1', mockUser)).rejects.toThrow( - InternalServerErrorException, - ); + await expect( + resolver.removeLeague('league-1', mockPayload), + ).rejects.toThrow(InternalServerErrorException); }); }); }); diff --git a/api/src/league/league.resolver.ts b/api/src/league/league.resolver.ts index d549a8b..d66a5f0 100644 --- a/api/src/league/league.resolver.ts +++ b/api/src/league/league.resolver.ts @@ -4,8 +4,9 @@ import { League } from './entities/league.entity'; import { CreateLeagueInput } from './dto/create-league.input'; import { UpdateLeagueInput } from './dto/update-league.input'; import { UseGuards, InternalServerErrorException } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => League) export class LeagueResolver { @@ -15,10 +16,10 @@ export class LeagueResolver { @UseGuards(GqlAuthGuard) async createLeague( @Args('createLeagueInput') createLeagueInput: CreateLeagueInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.leagueService.create(createLeagueInput, user.sub); + return await this.leagueService.create(createLeagueInput, payload.userId); } catch (error) { console.error('Error creating league:', error); throw new InternalServerErrorException('Failed to create league'); @@ -33,11 +34,11 @@ export class LeagueResolver { @Query(() => League, { name: 'league' }) @UseGuards(GqlAuthGuard) async findOneByIdUser( - @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @Args('id', { type: () => String }) id: string, // league id + @CurrentUser() payload: JwtPayload, ) { try { - return await this.leagueService.findOneByIdUser(id, user.sub); + return await this.leagueService.findOneByIdUser(id, payload.userId); } catch (error) { console.error('Error finding league:', error); throw new InternalServerErrorException('Failed to find league'); @@ -59,10 +60,10 @@ export class LeagueResolver { @UseGuards(GqlAuthGuard) async updateLeague( @Args('updateLeagueInput') updateLeagueInput: UpdateLeagueInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.leagueService.update(updateLeagueInput, user.sub); + return await this.leagueService.update(updateLeagueInput, payload.userId); } catch (error) { console.error('Error updating league:', error); throw new InternalServerErrorException('Failed to update league'); @@ -73,10 +74,10 @@ export class LeagueResolver { @UseGuards(GqlAuthGuard) async removeLeague( @Args('id', { type: () => ID }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.leagueService.remove(id, user.sub); + return await this.leagueService.remove(id, payload.userId); } catch (error) { console.error('Error deleting league:', error); throw new InternalServerErrorException('Failed to delete league'); diff --git a/api/src/leaguechallenge/leaguechallenge.resolver.spec.ts b/api/src/leaguechallenge/leaguechallenge.resolver.spec.ts index 0bf7d0d..a4cc182 100644 --- a/api/src/leaguechallenge/leaguechallenge.resolver.spec.ts +++ b/api/src/leaguechallenge/leaguechallenge.resolver.spec.ts @@ -3,14 +3,18 @@ import { LeaguechallengeResolver } from './leaguechallenge.resolver'; import { LeaguechallengeService } from './leaguechallenge.service'; import { CreateLeaguechallengeInput } from './dto/create-leaguechallenge.input'; import { UpdateLeaguechallengeInput } from './dto/update-leaguechallenge.input'; -import { InternalServerErrorException, ExecutionContext } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { ExecutionContext } from '@nestjs/common'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; describe('LeaguechallengeResolver', () => { let resolver: LeaguechallengeResolver; let service: jest.Mocked; - const mockUser = { sub: 'user-1' }; + const mockUser = { + userId: 'user-1', + email: 'test@email.com', + name: 'Test User', + }; // match JwtPayload const mockResult = { leagueId: 'league-1', challengeId: 'challenge-1', @@ -35,7 +39,7 @@ describe('LeaguechallengeResolver', () => { .useValue({ canActivate: (context: ExecutionContext) => { const ctx = context.getArgByIndex(2); // GraphQL context - ctx.req = { user: mockUser }; // Inject mock user + ctx.req = { user: mockUser }; // inject mock user return true; }, }) @@ -58,14 +62,13 @@ describe('LeaguechallengeResolver', () => { }; const result = await resolver.addLeaguechallenge(input, mockUser); - expect(service.create).toHaveBeenCalledWith(input, mockUser.sub); + + expect(service.create).toHaveBeenCalledWith(input, mockUser.userId); expect(result).toEqual(mockResult); }); - it('should throw error on service failure', async () => { - service.create.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + it('should bubble up errors from the service', async () => { + service.create.mockRejectedValue(new Error('DB error')); const input: CreateLeaguechallengeInput = { leagueId: 'league-1', @@ -74,7 +77,7 @@ describe('LeaguechallengeResolver', () => { await expect( resolver.addLeaguechallenge(input, mockUser), - ).rejects.toThrow(InternalServerErrorException); + ).rejects.toThrow('DB error'); }); }); @@ -88,14 +91,13 @@ describe('LeaguechallengeResolver', () => { }; const result = await resolver.removeLeaguechallenge(input, mockUser); - expect(service.remove).toHaveBeenCalledWith(input, mockUser.sub); + + expect(service.remove).toHaveBeenCalledWith(input, mockUser.userId); expect(result).toEqual(mockResult); }); - it('should throw error on service failure', async () => { - service.remove.mockRejectedValue( - new InternalServerErrorException('DB error'), - ); + it('should bubble up errors from the service', async () => { + service.remove.mockRejectedValue(new Error('DB error')); const input: UpdateLeaguechallengeInput = { leagueId: 'league-1', @@ -104,7 +106,7 @@ describe('LeaguechallengeResolver', () => { await expect( resolver.removeLeaguechallenge(input, mockUser), - ).rejects.toThrow(InternalServerErrorException); + ).rejects.toThrow('DB error'); }); }); }); diff --git a/api/src/leaguechallenge/leaguechallenge.resolver.ts b/api/src/leaguechallenge/leaguechallenge.resolver.ts index 4a25df1..be61547 100644 --- a/api/src/leaguechallenge/leaguechallenge.resolver.ts +++ b/api/src/leaguechallenge/leaguechallenge.resolver.ts @@ -3,9 +3,10 @@ import { LeaguechallengeService } from './leaguechallenge.service'; import { Leaguechallenge } from './entities/leaguechallenge.entity'; import { CreateLeaguechallengeInput } from './dto/create-leaguechallenge.input'; import { UpdateLeaguechallengeInput } from './dto/update-leaguechallenge.input'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { UseGuards } from '@nestjs/common'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Leaguechallenge) export class LeaguechallengeResolver { @@ -19,12 +20,12 @@ export class LeaguechallengeResolver { addLeaguechallenge( @Args('createLeaguechallengeInput') createLeaguechallengeInput: CreateLeaguechallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return this.leaguechallengeService.create( createLeaguechallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error adding challenge to league:', error); @@ -37,12 +38,12 @@ export class LeaguechallengeResolver { removeLeaguechallenge( @Args('updateLeaguechallengeInput') createLeaguechallengeInput: UpdateLeaguechallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return this.leaguechallengeService.remove( createLeaguechallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error removing challenge from league:', error); diff --git a/api/src/leagueuser/leagueuser.resolver.spec.ts b/api/src/leagueuser/leagueuser.resolver.spec.ts index e1ca291..d41968a 100644 --- a/api/src/leagueuser/leagueuser.resolver.spec.ts +++ b/api/src/leagueuser/leagueuser.resolver.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LeagueuserResolver } from './leagueuser.resolver'; import { LeagueuserService } from './leagueuser.service'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { ExecutionContext } from '@nestjs/common'; import { CreateLeagueuserInput } from './dto/create-leagueuser.input'; import { LeagueRole } from '@prisma/client'; @@ -12,7 +12,11 @@ describe('LeagueuserResolver', () => { let resolver: LeagueuserResolver; let leagueuserService: jest.Mocked; - const mockUser = { sub: 'user-1' }; + const mockUser = { + userId: 'user-1', + email: 'test@email.com', + name: 'Test User', + }; const mockLeagueUser = { leagueId: 'league-1', userId: 'user-1', @@ -60,7 +64,7 @@ describe('LeagueuserResolver', () => { expect(leagueuserService.create).toHaveBeenCalledWith( input, - mockUser.sub, + mockUser.userId, ); expect(result).toEqual(mockLeagueUser); }); @@ -102,7 +106,7 @@ describe('LeagueuserResolver', () => { expect(leagueuserService.findAll).toHaveBeenCalledWith( input, - mockUser.sub, + mockUser.userId, ); expect(result).toEqual(mockResult); }); @@ -139,22 +143,22 @@ describe('LeagueuserResolver', () => { describe('removeLeagueuser', () => { it('should call service.remove and return result', async () => { const mockResponse = { - message: `League user with userId=${mockUser.sub} removed from leagueId=league-1`, + message: `League user with userId=${mockUser.userId} removed from leagueId=league-1`, leagueId: 'league-1', - userId: mockUser.sub, + userId: mockUser.userId, }; leagueuserService.remove.mockResolvedValue(mockResponse); const result = await resolver.removeLeagueuser( 'league-1', - mockUser.sub, + mockUser.userId, mockUser, ); expect(leagueuserService.remove).toHaveBeenCalledWith( 'league-1', - mockUser.sub, - mockUser.sub, + mockUser.userId, + mockUser.userId, ); expect(result).toEqual(mockResponse); }); diff --git a/api/src/leagueuser/leagueuser.resolver.ts b/api/src/leagueuser/leagueuser.resolver.ts index 7b00370..087bd34 100644 --- a/api/src/leagueuser/leagueuser.resolver.ts +++ b/api/src/leagueuser/leagueuser.resolver.ts @@ -3,9 +3,10 @@ import { LeagueuserService } from './leagueuser.service'; import { Leagueuser } from './entities/leagueuser.entity'; import { CreateLeagueuserInput } from './dto/create-leagueuser.input'; import { RemoveLeagueuserResponse } from './entities/remove-leagueuser.response'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { UseGuards } from '@nestjs/common'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Leagueuser) export class LeagueuserResolver { @@ -16,10 +17,13 @@ export class LeagueuserResolver { @UseGuards(GqlAuthGuard) createLeagueuser( @Args('createLeagueuserInput') createLeagueuserInput: CreateLeagueuserInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return this.leagueuserService.create(createLeagueuserInput, user.sub); + return this.leagueuserService.create( + createLeagueuserInput, + payload.userId, + ); } catch (error) { console.error('Error joining league:', error); throw new Error('Failed to join league user'); @@ -30,10 +34,13 @@ export class LeagueuserResolver { @UseGuards(GqlAuthGuard) findAll( @Args('createLeagueuserInput') createLeagueuserInput: CreateLeagueuserInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return this.leagueuserService.findAll(createLeagueuserInput, user.sub); + return this.leagueuserService.findAll( + createLeagueuserInput, + payload.userId, + ); } catch (error) { console.error('Error finding users in league:', error); throw new Error('Failed to find users in league'); @@ -58,10 +65,10 @@ export class LeagueuserResolver { removeLeagueuser( @Args('leagueId', { type: () => String }) leagueId: string, @Args('userId', { type: () => String }) userId: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return this.leagueuserService.remove(leagueId, user.sub, userId); + return this.leagueuserService.remove(leagueId, payload.userId, userId); } catch (error) { console.error('Error removing user from league:', error); throw new Error('Failed to remove user from league'); diff --git a/api/src/like/like.resolver.spec.ts b/api/src/like/like.resolver.spec.ts index f29913f..7ba7db4 100644 --- a/api/src/like/like.resolver.spec.ts +++ b/api/src/like/like.resolver.spec.ts @@ -4,13 +4,17 @@ import { LikeService } from './like.service'; import { CreateLikeInput } from './dto/create-like.input'; import { InternalServerErrorException } from '@nestjs/common'; import { Like } from './entities/like.entity'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; describe('LikeResolver', () => { let resolver: LikeResolver; let service: LikeService; - const mockUser = { sub: 'user-123' }; + const mockUser = { + userId: 'user-1', + email: 'test@email.com', + name: 'Test User', + }; const mockLike: Like = { postId: 'post-1', @@ -51,7 +55,7 @@ describe('LikeResolver', () => { const result = await resolver.createLike(input, mockUser); - expect(service.create).toHaveBeenCalledWith(mockUser.sub, input); + expect(service.create).toHaveBeenCalledWith(mockUser.userId, input); expect(result).toEqual(mockLike); }); @@ -70,7 +74,7 @@ describe('LikeResolver', () => { const result = await resolver.findOne('like-1', mockUser); - expect(service.findOne).toHaveBeenCalledWith(mockUser.sub, 'like-1'); + expect(service.findOne).toHaveBeenCalledWith(mockUser.userId, 'like-1'); expect(result).toEqual(mockLike); }); @@ -89,7 +93,7 @@ describe('LikeResolver', () => { const result = await resolver.removeLike('like-1', mockUser); - expect(service.remove).toHaveBeenCalledWith(mockUser.sub, 'like-1'); + expect(service.remove).toHaveBeenCalledWith(mockUser.userId, 'like-1'); expect(result).toEqual(mockLike); }); diff --git a/api/src/like/like.resolver.ts b/api/src/like/like.resolver.ts index 063bb85..416bca2 100644 --- a/api/src/like/like.resolver.ts +++ b/api/src/like/like.resolver.ts @@ -3,8 +3,9 @@ import { LikeService } from './like.service'; import { Like } from './entities/like.entity'; import { CreateLikeInput } from './dto/create-like.input'; import { UseGuards, InternalServerErrorException } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Like) export class LikeResolver { @@ -14,10 +15,10 @@ export class LikeResolver { @UseGuards(GqlAuthGuard) async createLike( @Args('createLikeInput') createLikeInput: CreateLikeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.likeService.create(user.sub, createLikeInput); + return await this.likeService.create(payload.userId, createLikeInput); } catch (error) { console.error('Error creating like:', error); throw new InternalServerErrorException('Failed to create like'); @@ -28,10 +29,10 @@ export class LikeResolver { @UseGuards(GqlAuthGuard) async findOne( @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.likeService.findOne(user.sub, id); + return await this.likeService.findOne(payload.userId, id); } catch (error) { console.error('Error finding like:', error); throw new InternalServerErrorException('Failed to find like'); @@ -42,10 +43,10 @@ export class LikeResolver { @UseGuards(GqlAuthGuard) async removeLike( @Args('id', { type: () => String }) id: string, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { - return await this.likeService.remove(user.sub, id); + return await this.likeService.remove(payload.userId, id); } catch (error) { console.error('Error removing like:', error); throw new InternalServerErrorException('Failed to remove like'); diff --git a/api/src/post/dto/create-post.input.ts b/api/src/post/dto/create-post.input.ts index 317ff01..cd89355 100644 --- a/api/src/post/dto/create-post.input.ts +++ b/api/src/post/dto/create-post.input.ts @@ -10,7 +10,4 @@ export class CreatePostInput { @Field({ nullable: true }) content?: string; - - @Field(() => String) - authorId: string; } diff --git a/api/src/post/dto/update-post.input.ts b/api/src/post/dto/update-post.input.ts index e4cf6da..e5121a0 100644 --- a/api/src/post/dto/update-post.input.ts +++ b/api/src/post/dto/update-post.input.ts @@ -3,6 +3,6 @@ import { InputType, PartialType, Field } from '@nestjs/graphql'; @InputType() export class UpdatePostInput extends PartialType(CreatePostInput) { - @Field({ nullable: true }) + @Field(() => String) id: string; } diff --git a/api/src/post/post.module.ts b/api/src/post/post.module.ts index d11ce4c..a36ecf1 100644 --- a/api/src/post/post.module.ts +++ b/api/src/post/post.module.ts @@ -1,9 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PostService } from './post.service'; import { PostResolver } from './post.resolver'; import { PrismaService } from '../prisma/prisma.service'; +import { AuthModule } from '../auth/auth.module'; +import { UserModule } from '../user/user.module'; @Module({ + imports: [forwardRef(() => AuthModule), forwardRef(() => UserModule)], providers: [PostResolver, PostService, PrismaService], exports: [PostService], }) diff --git a/api/src/post/post.resolver.spec.ts b/api/src/post/post.resolver.spec.ts index 730c011..65aade2 100644 --- a/api/src/post/post.resolver.spec.ts +++ b/api/src/post/post.resolver.spec.ts @@ -1,23 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PostResolver } from './post.resolver'; -import { PrismaService } from '../prisma/prisma.service'; import { PostService } from './post.service'; import { NotFoundException, InternalServerErrorException, } from '@nestjs/common'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; describe('PostResolver', () => { let resolver: PostResolver; const mockPostService = { create: jest.fn(), - findAll: jest.fn(), findOne: jest.fn(), + findUserPosts: jest.fn(), update: jest.fn(), remove: jest.fn(), }; + const mockUser: JwtPayload = { + userId: 'user-123', + email: 'test@example.com', + name: 'Test User', + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -26,12 +33,15 @@ describe('PostResolver', () => { provide: PostService, useValue: mockPostService, }, - { - provide: PrismaService, - useValue: {}, // Not used directly - }, ], - }).compile(); + }) + .overrideGuard(GqlAuthGuard) + .useValue({ + canActivate: () => { + return true; + }, + }) + .compile(); resolver = module.get(PostResolver); }); @@ -46,59 +56,86 @@ describe('PostResolver', () => { describe('createPost', () => { it('should return created post', async () => { - const input = { title: 'Test', content: 'Hello', authorId: '1' }; - const result = { id: '1', ...input }; + const input = { title: 'Test', content: 'Hello' }; + const result = { id: '1', ...input, authorId: mockUser.userId }; + mockPostService.create.mockResolvedValue(result); - await expect(resolver.createPost(input)).resolves.toEqual(result); + await expect(resolver.createPost(input, mockUser)).resolves.toEqual( + result, + ); + expect(mockPostService.create).toHaveBeenCalledWith( + input, + mockUser.userId, + ); }); it('should throw InternalServerErrorException on failure', async () => { - // Silence console.error for this test - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); // silence error logs - const input = { title: 'Test', content: 'Hello', authorId: '1' }; + const input = { title: 'Test', content: 'Hello' }; mockPostService.create.mockRejectedValue( new InternalServerErrorException(), ); - await expect(resolver.createPost(input)).rejects.toThrow( + await expect(resolver.createPost(input, mockUser)).rejects.toThrow( InternalServerErrorException, ); }); }); - describe('findOne', () => { + describe('findOneById', () => { it('should return single post', async () => { const result = { id: '1', title: 'Hello', content: 'World' }; mockPostService.findOne.mockResolvedValue(result); - await expect(resolver.findOne('1')).resolves.toEqual(result); + await expect(resolver.findOneById('1')).resolves.toEqual(result); }); it('should throw NotFoundException if post not found', async () => { - mockPostService.findOne.mockRejectedValue( - new NotFoundException('Not found'), + mockPostService.findOne.mockRejectedValue(new NotFoundException()); + + await expect(resolver.findOneById('99')).rejects.toThrow( + NotFoundException, ); + }); + }); - await expect(resolver.findOne('99')).rejects.toThrow(NotFoundException); + describe('findUserPosts', () => { + it('should return all posts for a user', async () => { + const posts = [{ id: '1', title: 'Post 1', authorId: mockUser.userId }]; + mockPostService.findUserPosts.mockResolvedValue(posts); + + await expect(resolver.findUserPosts(mockUser.userId)).resolves.toEqual( + posts, + ); + expect(mockPostService.findUserPosts).toHaveBeenCalledWith( + mockUser.userId, + ); }); }); describe('updatePost', () => { it('should update and return post', async () => { const input = { id: '1', title: 'Updated', content: 'Changed' }; - const result = { ...input }; + const result = { ...input, authorId: mockUser.userId }; + mockPostService.update.mockResolvedValue(result); - await expect(resolver.updatePost(input)).resolves.toEqual(result); + await expect(resolver.updatePost(input, mockUser)).resolves.toEqual( + result, + ); + expect(mockPostService.update).toHaveBeenCalledWith( + input, + mockUser.userId, + ); }); it('should throw NotFoundException if post does not exist', async () => { const input = { id: '99', title: 'Updated', content: 'Changed' }; mockPostService.update.mockRejectedValue(new NotFoundException()); - await expect(resolver.updatePost(input)).rejects.toThrow( + await expect(resolver.updatePost(input, mockUser)).rejects.toThrow( NotFoundException, ); }); @@ -109,13 +146,14 @@ describe('PostResolver', () => { const result = { id: '1', title: 'Removed', content: 'Bye' }; mockPostService.remove.mockResolvedValue(result); - await expect(resolver.removePost('1')).resolves.toEqual(result); + await expect(resolver.removePost('1', mockUser)).resolves.toEqual(result); + expect(mockPostService.remove).toHaveBeenCalledWith('1', mockUser.userId); }); it('should throw NotFoundException if post not found', async () => { mockPostService.remove.mockRejectedValue(new NotFoundException()); - await expect(resolver.removePost('404')).rejects.toThrow( + await expect(resolver.removePost('404', mockUser)).rejects.toThrow( NotFoundException, ); }); @@ -125,7 +163,7 @@ describe('PostResolver', () => { new InternalServerErrorException(), ); - await expect(resolver.removePost('1')).rejects.toThrow( + await expect(resolver.removePost('1', mockUser)).rejects.toThrow( InternalServerErrorException, ); }); diff --git a/api/src/post/post.resolver.ts b/api/src/post/post.resolver.ts index a24a5ec..fa6b781 100644 --- a/api/src/post/post.resolver.ts +++ b/api/src/post/post.resolver.ts @@ -7,17 +7,23 @@ import { NotFoundException, InternalServerErrorException, } from '@nestjs/common'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; +import { UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Post) export class PostResolver { constructor(private readonly postService: PostService) {} @Mutation(() => Post) + @UseGuards(GqlAuthGuard) async createPost( @Args('createPostInput') createPostInput: CreatePostInput, + @CurrentUser() payload: JwtPayload, ): Promise { try { - return await this.postService.create(createPostInput); + return await this.postService.create(createPostInput, payload.userId); } catch (error) { console.error('Error creating post:', error); throw new InternalServerErrorException('Failed to create post'); @@ -25,6 +31,7 @@ export class PostResolver { } @Query(() => Post, { name: 'post' }) + @UseGuards(GqlAuthGuard) async findOneById(@Args('id', { type: () => String }) id: string) { try { return await this.postService.findOne(id); @@ -36,14 +43,19 @@ export class PostResolver { // Gets all posts that belong to a specific user @Query(() => [Post], { name: 'userPosts' }) + @UseGuards(GqlAuthGuard) async findUserPosts(@Args('userId', { type: () => String }) userId: string) { return this.postService.findUserPosts(userId); } @Mutation(() => Post, { name: 'updatePost' }) - async updatePost(@Args('updatePostInput') updatePostInput: UpdatePostInput) { + @UseGuards(GqlAuthGuard) + async updatePost( + @Args('updatePostInput') updatePostInput: UpdatePostInput, + @CurrentUser() payload: JwtPayload, + ) { try { - return await this.postService.update(updatePostInput.id, updatePostInput); + return await this.postService.update(updatePostInput, payload.userId); } catch (error) { if (error instanceof NotFoundException) throw error; throw new InternalServerErrorException('Failed to update post'); @@ -51,9 +63,13 @@ export class PostResolver { } @Mutation(() => Post) - async removePost(@Args('id', { type: () => String }) id: string) { + @UseGuards(GqlAuthGuard) + async removePost( + @Args('id', { type: () => String }) id: string, + @CurrentUser() payload: JwtPayload, + ) { try { - return await this.postService.remove(id); + return await this.postService.remove(id, payload.userId); } catch (error) { if (error instanceof NotFoundException) throw error; throw new InternalServerErrorException('Failed to delete post'); diff --git a/api/src/post/post.service.spec.ts b/api/src/post/post.service.spec.ts index 3615d38..e27f6b8 100644 --- a/api/src/post/post.service.spec.ts +++ b/api/src/post/post.service.spec.ts @@ -1,5 +1,5 @@ +import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; import { PostService } from './post.service'; import { PrismaService } from '../prisma/prisma.service'; import { CreatePostInput } from './dto/create-post.input'; @@ -7,6 +7,7 @@ import { UpdatePostInput } from './dto/update-post.input'; describe('PostService', () => { let service: PostService; + let prisma: jest.Mocked; const mockPrismaService = { post: { @@ -32,6 +33,7 @@ describe('PostService', () => { }).compile(); service = module.get(PostService); + prisma = module.get(PrismaService); }); it('should be defined', () => { @@ -39,31 +41,31 @@ describe('PostService', () => { }); describe('create', () => { - it('should create a new post WITHOUT Media', async () => { + it('should create a new post', async () => { const input: CreatePostInput = { title: 'Test Title', content: 'Test Content', - authorId: 'author123', }; + const userId = 'user-123'; const createdPost = { id: 'post1', title: input.title, content: input.content, - authorId: input.authorId, + authorId: userId, }; mockPrismaService.post.create.mockResolvedValue(createdPost); - const result = await service.create(input); + const result = await service.create(input, userId); expect(result).toEqual(createdPost); - expect(mockPrismaService.post.create).toHaveBeenCalledWith({ + expect(prisma.post.create).toHaveBeenCalledWith({ data: { title: input.title, content: input.content, author: { - connect: { id: input.authorId }, + connect: { id: userId }, }, }, }); @@ -81,7 +83,7 @@ describe('PostService', () => { const result = await service.findAll(); expect(result).toEqual(posts); - expect(mockPrismaService.post.findMany).toHaveBeenCalledWith({ + expect(prisma.post.findMany).toHaveBeenCalledWith({ include: { author: true, comments: true, @@ -92,7 +94,7 @@ describe('PostService', () => { }); describe('findOne', () => { - it('should return a single post by ID with relations', async () => { + it('should return a single post by ID', async () => { const post = { id: '123', title: 'Sample', @@ -106,7 +108,7 @@ describe('PostService', () => { const result = await service.findOne('123'); expect(result).toEqual(post); - expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({ + expect(prisma.post.findUnique).toHaveBeenCalledWith({ where: { id: '123' }, include: { author: true, @@ -115,113 +117,116 @@ describe('PostService', () => { }, }); }); - }); - describe('update', () => { - it('should update a post by ID', async () => { - const id = 'abc123'; - const updateData: UpdatePostInput = { - id: id, - title: 'Updated Title', - content: 'Updated Content', - authorId: 'author456', - }; + it('should throw NotFoundException if post not found', async () => { + mockPrismaService.post.findUnique.mockResolvedValue(null); - const updatedPost = { - id, - ...updateData, - }; + await expect(service.findOne('missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); - mockPrismaService.post.update.mockResolvedValue(updatedPost); + describe('findUserPosts', () => { + it('should return all posts for a user', async () => { + const posts = [ + { + id: 'p1', + title: 'Post 1', + authorId: 'u1', + author: {}, + comments: [], + likes: [], + }, + ]; + mockPrismaService.post.findMany.mockResolvedValue(posts); - const result = await service.update(id, updateData); + const result = await service.findUserPosts('u1'); - expect(result).toEqual(updatedPost); - expect(mockPrismaService.post.update).toHaveBeenCalledWith({ - where: { id }, - data: updateData, + expect(result).toEqual(posts); + expect(prisma.post.findMany).toHaveBeenCalledWith({ + where: { authorId: 'u1' }, + include: { author: true, comments: true, likes: true }, }); }); }); - describe('remove', () => { - it('should delete a post by ID', async () => { - const id = 'xyz789'; - const deletedPost = { - id, - title: 'Deleted Post', - content: '...', + describe('update', () => { + it('should update a post if user is the owner', async () => { + const input: UpdatePostInput = { + id: 'p1', + title: 'Updated', + content: 'Updated Content', }; + const userId = 'u1'; + const existing = { id: 'p1', authorId: 'u1' }; + const updated = { ...existing, ...input }; - mockPrismaService.post.delete.mockResolvedValue(deletedPost); + mockPrismaService.post.findUnique.mockResolvedValue(existing); + mockPrismaService.post.update.mockResolvedValue(updated); - const result = await service.remove(id); + const result = await service.update(input, userId); - expect(result).toEqual(deletedPost); - expect(mockPrismaService.post.delete).toHaveBeenCalledWith({ - where: { id }, + expect(result).toEqual(updated); + expect(prisma.post.update).toHaveBeenCalledWith({ + where: { id: input.id }, + data: input, }); }); - }); - describe('findOne - Error Handling', () => { it('should throw NotFoundException if post not found', async () => { mockPrismaService.post.findUnique.mockResolvedValue(null); - await expect(service.findOne('nonexistent-id')).rejects.toThrow( - NotFoundException, - ); - expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({ - where: { id: 'nonexistent-id' }, - include: { - author: true, - comments: true, - likes: true, - }, - }); + await expect( + service.update({ id: 'missing' } as UpdatePostInput, 'u1'), + ).rejects.toThrow(NotFoundException); }); - }); - describe('update - Error Handling', () => { - it('should throw NotFoundException if update fails (post not found)', async () => { - const id = 'invalid-id'; - const updateData: UpdatePostInput = { - id: 'invalid-id', - title: 'Does not matter', - content: '...', - authorId: 'authorX', - }; + it('should throw ForbiddenException if user is not the owner', async () => { + const input: UpdatePostInput = { id: 'p1', title: 'New', content: 'New' }; + const existing = { id: 'p1', authorId: 'other' }; - // Mock findUnique to return null → simulate not found - mockPrismaService.post.findUnique.mockResolvedValue(null); + mockPrismaService.post.findUnique.mockResolvedValue(existing); - await expect(service.update(id, updateData)).rejects.toThrow( - NotFoundException, + await expect(service.update(input, 'u1')).rejects.toThrow( + ForbiddenException, ); - - expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({ - where: { id }, - }); - - // Optional: ensure update was never called - expect(mockPrismaService.post.update).not.toHaveBeenCalled(); }); }); - describe('remove - Error Handling', () => { - it('should throw NotFoundException if delete fails (post not found)', async () => { - const id = 'missing-id'; + describe('remove', () => { + it('should delete a post if user is the owner', async () => { + const id = 'p1'; + const userId = 'u1'; + const existing = { id, authorId: userId }; + const deleted = { id, title: 'Deleted', content: '...' }; + + mockPrismaService.post.findUnique.mockResolvedValue(existing); + mockPrismaService.post.delete.mockResolvedValue(deleted); + + const result = await service.remove(id, userId); + + expect(result).toEqual(deleted); + expect(prisma.post.delete).toHaveBeenCalledWith({ where: { id } }); + }); - // Mock findUnique to return null → simulate not found + it('should throw NotFoundException if post not found', async () => { mockPrismaService.post.findUnique.mockResolvedValue(null); - await expect(service.remove(id)).rejects.toThrow(NotFoundException); + await expect(service.remove('missing', 'u1')).rejects.toThrow( + NotFoundException, + ); + }); - expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({ - where: { id }, + it('should throw ForbiddenException if user is not the owner', async () => { + mockPrismaService.post.findUnique.mockResolvedValue({ + id: 'p1', + authorId: 'other', }); - expect(mockPrismaService.post.delete).not.toHaveBeenCalled(); + await expect(service.remove('p1', 'u1')).rejects.toThrow( + ForbiddenException, + ); }); }); }); diff --git a/api/src/post/post.service.ts b/api/src/post/post.service.ts index f33b3b5..b09d28f 100644 --- a/api/src/post/post.service.ts +++ b/api/src/post/post.service.ts @@ -2,6 +2,7 @@ import { NotFoundException, Injectable, InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { CreatePostInput } from './dto/create-post.input'; import { UpdatePostInput } from './dto/update-post.input'; @@ -11,14 +12,14 @@ import { PrismaService } from '../prisma/prisma.service'; export class PostService { constructor(private prisma: PrismaService) {} - async create(createPostInput: CreatePostInput) { + async create(createPostInput: CreatePostInput, userId: string) { try { return await this.prisma.post.create({ data: { title: createPostInput.title, content: createPostInput.content, author: { - connect: { id: createPostInput.authorId }, + connect: { id: userId }, }, }, }); @@ -87,37 +88,65 @@ export class PostService { } } - async update(id: string, updatePostInput: UpdatePostInput) { + async update(updatePostInput: UpdatePostInput, userId: string) { try { // Ensure post exists - const existing = await this.prisma.post.findUnique({ where: { id } }); + const existing = await this.prisma.post.findUnique({ + where: { id: updatePostInput.id }, + }); + if (!existing) { - throw new NotFoundException(`Post with ID ${id} not found`); + throw new NotFoundException( + `Post with ID ${updatePostInput.id} not found`, + ); + } + + // Check ownership + if (existing.authorId !== userId) { + throw new ForbiddenException( + `You do not have permission to update this post`, + ); } return await this.prisma.post.update({ - where: { id }, + where: { id: updatePostInput.id }, data: updatePostInput, }); } catch (error) { - if (error instanceof NotFoundException) throw error; + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; + } throw new InternalServerErrorException('Failed to update post'); } } - async remove(id: string) { + async remove(id: string, userId: string) { try { - // Ensure post exists const existing = await this.prisma.post.findUnique({ where: { id } }); + if (!existing) { throw new NotFoundException(`Post with ID ${id} not found`); } + if (existing.authorId !== userId) { + throw new ForbiddenException( + `You do not have permission to update this post`, + ); + } + return await this.prisma.post.delete({ where: { id }, }); } catch (error) { - if (error instanceof NotFoundException) throw error; + if ( + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + throw error; // let these bubble up + } throw new InternalServerErrorException('Failed to delete post'); } } diff --git a/api/src/schema.gql b/api/src/schema.gql index 82c8a7f..1ff76f9 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -44,9 +44,6 @@ input CreateAuthInput { } input CreateChallengeInput { - """Optional description of the challenge""" - description: String - """Optional end date for the challenge""" endDate: DateTime @@ -85,7 +82,6 @@ input CreateLikeInput { } input CreatePostInput { - authorId: String! content: String title: String! } @@ -147,9 +143,9 @@ type Mutation { followUser(targetUserId: String!): Boolean! joinUserChallenge(joinUserChallengeInput: JoinUserChallengeInput!): Userchallenge! login(email: String!): AuthPayload! - logout(refreshToken: String!): Boolean! - refreshToken(refreshToken: String!): AuthPayload! - removeAuth(id: String!): Boolean! + logout: Boolean! + payload(refreshToken: String!): AuthPayload! + removeAuth: Boolean! removeChallenge(id: String!): Challenge! removeComment(id: String!): Comment! removeLeague(id: ID!): League! @@ -157,7 +153,7 @@ type Mutation { removeLeagueuser(leagueId: String!, userId: String!): RemoveLeagueuserResponse! removeLike(id: String!): Like! removePost(id: String!): Post! - removeUser(id: String!): User! + removeUser: User! removeUserChallenge(joinUserChallengeInput: JoinUserChallengeInput!): Userchallenge! unfollowUser(targetUserId: String!): Boolean! updateChallenge(updateChallengeInput: UpdateChallengeInput!): Challenge! @@ -205,9 +201,6 @@ type RemoveLeagueuserResponse { } input UpdateChallengeInput { - """Optional description of the challenge""" - description: String - """Optional end date for the challenge""" endDate: DateTime @@ -239,9 +232,8 @@ input UpdateLeaguechallengeInput { } input UpdatePostInput { - authorId: String content: String - id: String + id: String! title: String } @@ -255,7 +247,6 @@ input UpdateUserInput { avatarUrl: String bio: String email: String - id: String! name: String } diff --git a/api/src/session/session.resolver.spec.ts b/api/src/session/session.resolver.spec.ts index ffe1ddb..3c9c033 100644 --- a/api/src/session/session.resolver.spec.ts +++ b/api/src/session/session.resolver.spec.ts @@ -1,40 +1,40 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SessionResolver } from './session.resolver'; -import { SessionService } from './session.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { UserService } from '../user/user.service'; +// import { Test, TestingModule } from '@nestjs/testing'; +// import { SessionResolver } from './session.resolver'; +// import { SessionService } from './session.service'; +// import { PrismaService } from '../prisma/prisma.service'; +// import { UserService } from '../user/user.service'; -describe('SessionResolver', () => { - let resolver: SessionResolver; - let prismaMock: any; - let userServiceMock: any; +// describe('SessionResolver', () => { +// let resolver: SessionResolver; +// let prismaMock: any; +// let userServiceMock: any; - beforeEach(async () => { - prismaMock = { - session: { - create: jest.fn(), - findUnique: jest.fn(), - delete: jest.fn(), - }, - }; +// beforeEach(async () => { +// prismaMock = { +// session: { +// create: jest.fn(), +// findUnique: jest.fn(), +// delete: jest.fn(), +// }, +// }; - userServiceMock = { - findOneByEmail: jest.fn(), - }; +// userServiceMock = { +// findOneByEmail: jest.fn(), +// }; - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SessionResolver, - SessionService, - { provide: PrismaService, useValue: prismaMock }, - { provide: UserService, useValue: userServiceMock }, - ], - }).compile(); +// const module: TestingModule = await Test.createTestingModule({ +// providers: [ +// SessionResolver, +// SessionService, +// { provide: PrismaService, useValue: prismaMock }, +// { provide: UserService, useValue: userServiceMock }, +// ], +// }).compile(); - resolver = module.get(SessionResolver); - }); +// resolver = module.get(SessionResolver); +// }); - it('should be defined', () => { - expect(resolver).toBeDefined(); - }); -}); +// it('should be defined', () => { +// expect(resolver).toBeDefined(); +// }); +// }); diff --git a/api/src/session/session.resolver.ts b/api/src/session/session.resolver.ts index da27932..6a5e494 100644 --- a/api/src/session/session.resolver.ts +++ b/api/src/session/session.resolver.ts @@ -1,37 +1,37 @@ -import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql'; -import { SessionService } from './session.service'; -import { Session } from './entities/session.entity'; -import { CreateSessionInput } from './dto/create-session.input'; -// import { UpdateSessionInput } from './dto/update-session.input'; +// import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql'; +// import { SessionService } from './session.service'; +// import { Session } from './entities/session.entity'; +// import { CreateSessionInput } from './dto/create-session.input'; +// // import { UpdateSessionInput } from './dto/update-session.input'; -@Resolver(() => Session) -export class SessionResolver { - constructor(private readonly sessionService: SessionService) {} +// @Resolver(() => Session) +// export class SessionResolver { +// constructor(private readonly sessionService: SessionService) {} - @Mutation(() => Session) - createSession( - @Args('createSessionInput') createSessionInput: CreateSessionInput, - ) { - return this.sessionService.create(createSessionInput); - } +// @Mutation(() => Session) +// createSession( +// @Args('createSessionInput') createSessionInput: CreateSessionInput, +// ) { +// return this.sessionService.create(createSessionInput); +// } - // @Query(() => [Session], { name: 'session' }) - // findAll() { - // return this.sessionService.findAll(); - // } +// // @Query(() => [Session], { name: 'session' }) +// // findAll() { +// // return this.sessionService.findAll(); +// // } - // @Query(() => Session, { name: 'session' }) - // findOne(@Args('id', { type: () => Int }) id: number) { - // return this.sessionService.findOne(id); - // } +// // @Query(() => Session, { name: 'session' }) +// // findOne(@Args('id', { type: () => Int }) id: number) { +// // return this.sessionService.findOne(id); +// // } - @Mutation(() => Session) - updateSession(@Args('refreshToken') refreshToken: string) { - return this.sessionService.update(refreshToken); - } +// @Mutation(() => Session) +// updateSession(@Args('refreshToken') refreshToken: string) { +// return this.sessionService.update(refreshToken); +// } - @Mutation(() => Session) - removeSession(@Args('id', { type: () => String }) id: string) { - return this.sessionService.remove(id); - } -} +// @Mutation(() => Session) +// removeSession(@Args('id', { type: () => String }) id: string) { +// return this.sessionService.remove(id); +// } +// } diff --git a/api/src/session/session.service.ts b/api/src/session/session.service.ts index 28e07e6..dbaa83b 100644 --- a/api/src/session/session.service.ts +++ b/api/src/session/session.service.ts @@ -102,6 +102,11 @@ export class SessionService { // Optionally, you can return the deleted session or a success message return session; } + + async removeByUserId(userId: string): Promise { + await this.prisma.session.deleteMany({ where: { userId } }); + } + generateSecureToken(): string { return randomBytes(32).toString('hex'); } diff --git a/api/src/user/dto/update-user.input.ts b/api/src/user/dto/update-user.input.ts index e8a04dc..fd44eb7 100644 --- a/api/src/user/dto/update-user.input.ts +++ b/api/src/user/dto/update-user.input.ts @@ -3,9 +3,6 @@ import { InputType, Field, PartialType } from '@nestjs/graphql'; @InputType() export class UpdateUserInput extends PartialType(CreateUserInput) { - @Field(() => String) - id: string; - @Field(() => String, { nullable: true }) name?: string; diff --git a/api/src/user/user.resolver.spec.ts b/api/src/user/user.resolver.spec.ts index deb9a51..8a2162b 100644 --- a/api/src/user/user.resolver.spec.ts +++ b/api/src/user/user.resolver.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserResolver } from './user.resolver'; import { UserService } from './user.service'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; describe('UserResolver', () => { let resolver: UserResolver; @@ -19,7 +21,14 @@ describe('UserResolver', () => { UserResolver, { provide: UserService, useValue: userServiceMock }, ], - }).compile(); + }) + .overrideGuard(GqlAuthGuard) + .useValue({ + canActivate: () => { + return true; + }, + }) + .compile(); resolver = module.get(UserResolver); }); @@ -34,7 +43,7 @@ describe('UserResolver', () => { const user = { id: '1', ...input }; userServiceMock.create.mockResolvedValue(user); - const result = await resolver.createUser(input as any); + const result = await resolver.createUser(input); expect(userServiceMock.create).toHaveBeenCalledWith(input); expect(result).toBe(user); }); @@ -53,12 +62,21 @@ describe('UserResolver', () => { describe('updateUser', () => { it('should call userService.update and return User', async () => { - const input = { id: '1', email: 'updated@example.com', name: 'Updated' }; - const updatedUser = { ...input }; + const input = { name: 'Updated', email: 'updated@example.com' }; + const payload: JwtPayload = { + userId: '1', + name: input.name, + email: input.email, + }; + const updatedUser = { id: '1', ...input }; + userServiceMock.update.mockResolvedValue(updatedUser); - const result = await resolver.updateUser(input as any); - expect(userServiceMock.update).toHaveBeenCalledWith(input.id, input); + const result = await resolver.updateUser(input as any, payload); + expect(userServiceMock.update).toHaveBeenCalledWith( + payload.userId, + input, + ); expect(result).toBe(updatedUser); }); }); @@ -66,10 +84,33 @@ describe('UserResolver', () => { describe('removeUser', () => { it('should call userService.remove and return User', async () => { const user = { id: '1', email: 'test@example.com', name: 'Test' }; + const payload: JwtPayload = { + userId: '1', + email: user.email, + name: user.name, + }; + userServiceMock.remove.mockResolvedValue(user); - const result = await resolver.removeUser('1'); - expect(userServiceMock.remove).toHaveBeenCalledWith('1'); + const result = await resolver.removeUser(payload); + expect(userServiceMock.remove).toHaveBeenCalledWith(payload.userId); + expect(result).toBe(user); + }); + }); + + describe('me', () => { + it('should return the current user', async () => { + const user = { id: '1', email: 'test@example.com', name: 'Test' }; + const payload: JwtPayload = { + userId: '1', + email: user.email, + name: user.name, + }; + + userServiceMock.findOneById.mockResolvedValue(user); + + const result = await resolver.me(payload); + expect(userServiceMock.findOneById).toHaveBeenCalledWith(payload.userId); expect(result).toBe(user); }); }); diff --git a/api/src/user/user.resolver.ts b/api/src/user/user.resolver.ts index 4f6327d..6105e69 100644 --- a/api/src/user/user.resolver.ts +++ b/api/src/user/user.resolver.ts @@ -4,8 +4,9 @@ import { User } from './entities/user.entity'; import { CreateUserInput } from './dto/create-user.input'; import { UpdateUserInput } from './dto/update-user.input'; import { UseGuards } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => User) @UseGuards(GqlAuthGuard) @@ -13,7 +14,6 @@ export class UserResolver { constructor(private readonly userService: UserService) {} @Mutation(() => User) - // @UseGuards(GqlAuthGuard) createUser(@Args('createUserInput') createUserInput: CreateUserInput) { return this.userService.create(createUserInput); } @@ -26,19 +26,22 @@ export class UserResolver { @Mutation(() => User) @UseGuards(GqlAuthGuard) - updateUser(@Args('updateUserInput') updateUserInput: UpdateUserInput) { - return this.userService.update(updateUserInput.id, updateUserInput); + updateUser( + @Args('updateUserInput') updateUserInput: UpdateUserInput, + @CurrentUser() payload: JwtPayload, + ) { + return this.userService.update(payload.userId, updateUserInput); } @Mutation(() => User) @UseGuards(GqlAuthGuard) - removeUser(@Args('id', { type: () => String }) id: string) { - return this.userService.remove(id); + removeUser(@CurrentUser() payload: JwtPayload) { + return this.userService.remove(payload.userId); } @UseGuards(GqlAuthGuard) @Query(() => User) - async me(@CurrentUser() user: User) { - return user; + async me(@CurrentUser() payload: JwtPayload) { + return this.userService.findOneById(payload.userId); } } diff --git a/api/src/user/user.service.spec.ts b/api/src/user/user.service.spec.ts index 50c3be5..d41ae3b 100644 --- a/api/src/user/user.service.spec.ts +++ b/api/src/user/user.service.spec.ts @@ -5,13 +5,20 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; describe('UserService', () => { let service: UserService; - let prismaMock: any; + let prismaMock: { + user: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + }; beforeEach(async () => { prismaMock = { user: { - create: jest.fn(), findUnique: jest.fn(), + create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, @@ -37,12 +44,13 @@ describe('UserService', () => { id: '1', email: 'test@example.com', }); + await expect( service.create({ name: 'Test', email: 'test@example.com' }), ).rejects.toThrow(ConflictException); }); - it('should create and return new user if not exists', async () => { + it('should create and return new user if email does not exist', async () => { prismaMock.user.findUnique.mockResolvedValue(null); prismaMock.user.create.mockResolvedValue({ id: '1', @@ -50,106 +58,99 @@ describe('UserService', () => { email: 'test@example.com', }); - const result = await service.create({ - name: 'Test', - email: 'test@example.com', - }); - expect(prismaMock.user.create).toHaveBeenCalledWith({ - data: { name: 'Test', email: 'test@example.com' }, - }); - expect(result).toEqual({ - id: '1', - name: 'Test', - email: 'test@example.com', - }); + const input = { name: 'Test', email: 'test@example.com' }; + const result = await service.create(input); + + expect(prismaMock.user.create).toHaveBeenCalledWith({ data: input }); + expect(result).toEqual({ id: '1', ...input }); }); }); describe('findOneById', () => { - it('should return user by id', async () => { - prismaMock.user.findUnique.mockResolvedValue({ - id: '1', - email: 'test@example.com', - }); + it('should return user by ID', async () => { + const user = { id: '1', name: 'Test', email: 'test@example.com' }; + prismaMock.user.findUnique.mockResolvedValue(user); + const result = await service.findOneById('1'); expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' }, }); - expect(result).toEqual({ id: '1', email: 'test@example.com' }); + expect(result).toEqual(user); + }); + + it('should return null if user not found', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + const result = await service.findOneById('99'); + expect(result).toBeNull(); }); }); describe('findOneByEmail', () => { it('should return user by email', async () => { - prismaMock.user.findUnique.mockResolvedValue({ - id: '1', - email: 'test@example.com', - }); + const user = { id: '1', name: 'Test', email: 'test@example.com' }; + prismaMock.user.findUnique.mockResolvedValue(user); + const result = await service.findOneByEmail('test@example.com'); expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { email: 'test@example.com' }, }); - expect(result).toEqual({ id: '1', email: 'test@example.com' }); + expect(result).toEqual(user); + }); + + it('should return null if user not found', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + const result = await service.findOneByEmail('missing@example.com'); + expect(result).toBeNull(); }); }); describe('update', () => { it('should throw NotFoundException if user does not exist', async () => { prismaMock.user.findUnique.mockResolvedValue(null); + await expect( - service.update('1', { id: '1', name: 'New', email: 'new@example.com' }), + service.update('1', { name: 'New', email: 'new@example.com' }), ).rejects.toThrow(NotFoundException); }); it('should update and return user if exists', async () => { - prismaMock.user.findUnique.mockResolvedValue({ - id: '1', - email: 'test@example.com', - }); - prismaMock.user.update.mockResolvedValue({ - id: '1', - name: 'New', - email: 'new@example.com', - }); + const existing = { id: '1', name: 'Old', email: 'old@example.com' }; + const updated = { id: '1', name: 'New', email: 'new@example.com' }; + + prismaMock.user.findUnique.mockResolvedValue(existing); + prismaMock.user.update.mockResolvedValue(updated); const result = await service.update('1', { - id: '1', name: 'New', email: 'new@example.com', }); expect(prismaMock.user.update).toHaveBeenCalledWith({ where: { id: '1' }, - data: { id: '1', name: 'New', email: 'new@example.com' }, - }); - expect(result).toEqual({ - id: '1', - name: 'New', - email: 'new@example.com', + data: { name: 'New', email: 'new@example.com' }, }); + expect(result).toEqual(updated); }); }); describe('remove', () => { it('should throw NotFoundException if user does not exist', async () => { prismaMock.user.findUnique.mockResolvedValue(null); + await expect(service.remove('1')).rejects.toThrow(NotFoundException); }); it('should delete and return user if exists', async () => { - prismaMock.user.findUnique.mockResolvedValue({ - id: '1', - email: 'test@example.com', - }); - prismaMock.user.delete.mockResolvedValue({ - id: '1', - email: 'test@example.com', - }); + const user = { id: '1', name: 'Test', email: 'test@example.com' }; + prismaMock.user.findUnique.mockResolvedValue(user); + prismaMock.user.delete.mockResolvedValue(user); const result = await service.remove('1'); expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: '1' }, }); - expect(result).toEqual({ id: '1', email: 'test@example.com' }); + expect(result).toEqual(user); }); }); }); diff --git a/api/src/userchallenge/userchallenge.resolver.spec.ts b/api/src/userchallenge/userchallenge.resolver.spec.ts index 78180b1..2f65037 100644 --- a/api/src/userchallenge/userchallenge.resolver.spec.ts +++ b/api/src/userchallenge/userchallenge.resolver.spec.ts @@ -3,7 +3,7 @@ import { UserchallengeResolver } from './userchallenge.resolver'; import { UserchallengeService } from './userchallenge.service'; import { JoinUserChallengeInput } from './dto/join-userchallenge.input'; import { UpdateUserChallengeInput } from './dto/update-userchallenge.input'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { ExecutionContext } from '@nestjs/common'; import { ChallengeStatus } from '@prisma/client'; @@ -13,7 +13,9 @@ describe('UserchallengeResolver', () => { let resolver: UserchallengeResolver; let service: jest.Mocked; - const mockUser = { sub: 'user123' }; + // const input = { email: 'test@example.com', name: 'Test' }; + // const user = { id: '1', ...input }; + const mockUser = { userId: '1', email: 'test@example.com', name: 'Test' }; beforeEach(async () => { const mockService: jest.Mocked = { @@ -54,7 +56,7 @@ describe('UserchallengeResolver', () => { }; const expected = { challengeId: input.challengeId, - userId: mockUser.sub, + userId: mockUser.userId, status: ChallengeStatus.NOT_IN_PROGRESS, startedAt: new Date(), completedAt: new Date(), @@ -64,7 +66,7 @@ describe('UserchallengeResolver', () => { const result = await resolver.joinUserChallenge(input, mockUser); - expect(service.create).toHaveBeenCalledWith(input, mockUser.sub); + expect(service.create).toHaveBeenCalledWith(input, mockUser.userId); expect(result).toEqual(expected); }); @@ -101,7 +103,7 @@ describe('UserchallengeResolver', () => { const result = await resolver.updateUserChallenge(input, mockUser); - expect(service.update).toHaveBeenCalledWith(input, mockUser.sub); + expect(service.update).toHaveBeenCalledWith(input, mockUser.userId); expect(result).toEqual(expected); }); @@ -136,7 +138,7 @@ describe('UserchallengeResolver', () => { const result = await resolver.findOne(input, mockUser); expect(service.findOne).toHaveBeenCalledWith( - mockUser.sub, + mockUser.userId, input.challengeId, ); expect(result).toEqual(expected); @@ -172,7 +174,7 @@ describe('UserchallengeResolver', () => { const result = await resolver.removeUserChallenge(input, mockUser); - expect(service.remove).toHaveBeenCalledWith(input, mockUser.sub); + expect(service.remove).toHaveBeenCalledWith(input, mockUser.userId); expect(result).toEqual(expected); }); diff --git a/api/src/userchallenge/userchallenge.resolver.ts b/api/src/userchallenge/userchallenge.resolver.ts index 8add156..5cba797 100644 --- a/api/src/userchallenge/userchallenge.resolver.ts +++ b/api/src/userchallenge/userchallenge.resolver.ts @@ -3,9 +3,10 @@ import { UserchallengeService } from './userchallenge.service'; import { Userchallenge } from './entities/userchallenge.entity'; import { JoinUserChallengeInput } from './dto/join-userchallenge.input'; import { UpdateUserChallengeInput } from './dto/update-userchallenge.input'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { UseGuards } from '@nestjs/common'; import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Userchallenge) export class UserchallengeResolver { @@ -16,12 +17,12 @@ export class UserchallengeResolver { async joinUserChallenge( @Args('joinUserChallengeInput') joinUserChallengeInput: JoinUserChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return await this.userchallengeService.create( joinUserChallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error joining user challenge:', error); @@ -40,12 +41,12 @@ export class UserchallengeResolver { async updateUserChallenge( @Args('updateUserChallengeInput') updateUserChallengeInput: UpdateUserChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return await this.userchallengeService.update( updateUserChallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error updating user challenge:', error); @@ -58,11 +59,11 @@ export class UserchallengeResolver { async findOne( @Args('joinUserChallengeInput') joinUserChallengeInput: JoinUserChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return await this.userchallengeService.findOne( - user.sub, + payload.userId, joinUserChallengeInput.challengeId, ); } catch (error) { @@ -76,12 +77,12 @@ export class UserchallengeResolver { async removeUserChallenge( @Args('joinUserChallengeInput') joinUserChallengeInput: JoinUserChallengeInput, - @CurrentUser() user: any, + @CurrentUser() payload: JwtPayload, ) { try { return await this.userchallengeService.remove( joinUserChallengeInput, - user.sub, + payload.userId, ); } catch (error) { console.error('Error removing user challenge:', error); diff --git a/api/src/userfollow/userfollow.resolver.spec.ts b/api/src/userfollow/userfollow.resolver.spec.ts index 8598f71..fff66df 100644 --- a/api/src/userfollow/userfollow.resolver.spec.ts +++ b/api/src/userfollow/userfollow.resolver.spec.ts @@ -1,14 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserfollowResolver } from './userfollow.resolver'; import { UserfollowService } from './userfollow.service'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { ExecutionContext } from '@nestjs/common'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; describe('UserfollowResolver', () => { let resolver: UserfollowResolver; let service: UserfollowService; - const mockUser = { id: 'user-1', name: 'Test User', email: 'test@email.com' }; + const mockUser: JwtPayload = { + userId: 'user-123', + email: 'test@example.com', + name: 'Test User', + }; const targetUserId = 'user-2'; const mockService = { @@ -49,7 +54,10 @@ describe('UserfollowResolver', () => { await expect(resolver.followUser(targetUserId, mockUser)).resolves.toBe( true, ); - expect(service.followUser).toHaveBeenCalledWith(mockUser.id, targetUserId); + expect(service.followUser).toHaveBeenCalledWith( + mockUser.userId, + targetUserId, + ); }); it('should unfollow a user', async () => { @@ -57,39 +65,42 @@ describe('UserfollowResolver', () => { true, ); expect(service.unfollowUser).toHaveBeenCalledWith( - mockUser.id, + mockUser.userId, targetUserId, ); }); it('should return followers list', async () => { - await expect(resolver.getFollowers(mockUser.id)).resolves.toEqual([ + await expect(resolver.getFollowers(mockUser.userId)).resolves.toEqual([ { id: 'user-2' }, ]); - expect(service.getFollowers).toHaveBeenCalledWith(mockUser.id); + expect(service.getFollowers).toHaveBeenCalledWith(mockUser.userId); }); it('should return following list', async () => { - await expect(resolver.getFollowing(mockUser.id)).resolves.toEqual([ + await expect(resolver.getFollowing(mockUser.userId)).resolves.toEqual([ { id: 'user-3' }, ]); - expect(service.getFollowing).toHaveBeenCalledWith(mockUser.id); + expect(service.getFollowing).toHaveBeenCalledWith(mockUser.userId); }); it('should return follower count', async () => { - await expect(resolver.getFollowerCount(mockUser.id)).resolves.toBe(5); - expect(service.getFollowerCount).toHaveBeenCalledWith(mockUser.id); + await expect(resolver.getFollowerCount(mockUser.userId)).resolves.toBe(5); + expect(service.getFollowerCount).toHaveBeenCalledWith(mockUser.userId); }); it('should return following count', async () => { - await expect(resolver.getFollowingCount(mockUser.id)).resolves.toBe(7); - expect(service.getFollowingCount).toHaveBeenCalledWith(mockUser.id); + await expect(resolver.getFollowingCount(mockUser.userId)).resolves.toBe(7); + expect(service.getFollowingCount).toHaveBeenCalledWith(mockUser.userId); }); it('should return true for isFollowing', async () => { await expect(resolver.isFollowing(targetUserId, mockUser)).resolves.toBe( true, ); - expect(service.isFollowing).toHaveBeenCalledWith(mockUser.id, targetUserId); + expect(service.isFollowing).toHaveBeenCalledWith( + mockUser.userId, + targetUserId, + ); }); }); diff --git a/api/src/userfollow/userfollow.resolver.ts b/api/src/userfollow/userfollow.resolver.ts index 124572c..2ada653 100644 --- a/api/src/userfollow/userfollow.resolver.ts +++ b/api/src/userfollow/userfollow.resolver.ts @@ -2,9 +2,10 @@ import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql'; import { UserfollowService } from './userfollow.service'; import { Userfollow } from './entities/userfollow.entity'; import { UseGuards, InternalServerErrorException } from '@nestjs/common'; -import { GqlAuthGuard } from '../auth/auth.guard'; +import { GqlAuthGuard } from '../auth/guards/auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { User } from '../user/entities/user.entity'; +import { JwtPayload } from '../auth/types/jwt-payload.type'; @Resolver(() => Userfollow) export class UserfollowResolver { @@ -14,11 +15,11 @@ export class UserfollowResolver { @UseGuards(GqlAuthGuard) async followUser( @Args('targetUserId') targetUserId: string, - @CurrentUser() currentUser: any, + @CurrentUser() payload: JwtPayload, ): Promise { try { return await this.userfollowService.followUser( - currentUser.sub, + payload.userId, targetUserId, ); } catch (error) { @@ -31,11 +32,11 @@ export class UserfollowResolver { @UseGuards(GqlAuthGuard) async unfollowUser( @Args('targetUserId') targetUserId: string, - @CurrentUser() currentUser: any, + @CurrentUser() payload: JwtPayload, ): Promise { try { return await this.userfollowService.unfollowUser( - currentUser.sub, + payload.userId, targetUserId, ); } catch (error) { @@ -88,11 +89,11 @@ export class UserfollowResolver { @UseGuards(GqlAuthGuard) async isFollowing( @Args('targetUserId') targetUserId: string, - @CurrentUser() currentUser: any, + @CurrentUser() payload: JwtPayload, ): Promise { try { return await this.userfollowService.isFollowing( - currentUser.sub, + payload.userId, targetUserId, ); } catch (error) { diff --git a/api/test/e2e/app.e2e-spec.ts b/api/test/e2e/app.e2e-spec.ts index 993b288..85fd56e 100644 --- a/api/test/e2e/app.e2e-spec.ts +++ b/api/test/e2e/app.e2e-spec.ts @@ -5,6 +5,11 @@ import { AppModule } from '../../src/app.module'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../../src/prisma/prisma.service'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('E2E Tests (GraphQL)', () => { let app: INestApplication; let httpServer: any; @@ -42,7 +47,7 @@ describe('E2E Tests (GraphQL)', () => { .send({ query, variables }); if (authToken) { - req.set('Authorization', `Bearer ${authToken}`); + req.set('authorization', `Bearer ${authToken}`); } return req; @@ -104,15 +109,11 @@ describe('E2E Tests (GraphQL)', () => { const res = await graphql(mutation); - // const res = await graphql(mutation, variables); - expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); expect(res.body.data.login.accessToken).toBeDefined(); expect(res.body.data.login.user.email).toBe(EMAIL); - // console.log(res.body); - // save token for future authenticated requests token = res.body.data.login.accessToken; refreshToken = res.body.data.login.refreshToken; @@ -122,13 +123,15 @@ describe('E2E Tests (GraphQL)', () => { it('should logout user', async () => { const res = await graphql( ` - mutation ($refreshToken: String!) { - logout(refreshToken: $refreshToken) + mutation { + logout } `, - { refreshToken: refreshToken }, + {}, token, ); + + expect(res.body.errors).toBeUndefined(); expect(res.body.data.logout).toBe(true); }); @@ -157,11 +160,7 @@ describe('E2E Tests (GraphQL)', () => { ` mutation { createPost( - createPostInput: { - title: "Test Title" - content: "Hello world!" - authorId: "${userId}" - } + createPostInput: { title: "Test Title", content: "Hello world!" } ) { id content @@ -270,7 +269,7 @@ describe('E2E Tests (GraphQL)', () => { const resFollow = await graphql( ` mutation { - followUser(targetUserId: "${secondUserId}") + followUser(targetUserId: "${secondUserId}") } `, {}, diff --git a/api/test/int/auth.int-spec.ts b/api/test/int/auth.int-spec.ts index f6bab79..f78a1d0 100644 --- a/api/test/int/auth.int-spec.ts +++ b/api/test/int/auth.int-spec.ts @@ -1,56 +1,100 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; import { PrismaService } from '../../src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; -import { Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { RefreshTokenGuard } from '../../src/auth/guards/refresh-token.guard'; +import { ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; -describe('AuthService Integration (OAuth)', () => { +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + +describe('AuthResolver (Integration)', () => { let app: INestApplication; let prisma: PrismaService; + let token: string; + + const gql = (query: string, variables?: Record) => + request(app.getHttpServer()) + .post('/api/graphql') + .send({ query, variables }); + + const cleanDb = async () => { + await prisma.oAuthAccount.deleteMany({}); + await prisma.user.deleteMany({}); + }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideGuard(RefreshTokenGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + const req = ctx.getContext().req; + + // Set a fake payload on the request + req.user = { + payload: { + userId: user.id, // set this to the test user's ID + email: user.email, // optional, whatever you need + }, + }; + + return true; + }, + }) + + .compile(); app = moduleFixture.createNestApplication(); await app.init(); prisma = app.get(PrismaService); - await prisma.oAuthAccount.deleteMany({}); - await prisma.user.deleteMany({}); + await cleanDb(); + + // Create a user directly in DB + const email = `auth+${uuidv4()}@test.com`; + const providerUserId = uuidv4(); + + const user = await prisma.user.create({ + data: { + name: 'Auth Test', + email, + oauthAccounts: { + create: { + provider: 'mock', + providerUserId, + expiresAt: new Date(), + }, + }, + }, + }); + + // Generate JWT manually for GqlAuthGuard + const jwtService = new JwtService({ + secret: process.env.JWT_SECRET || 'test-secret', + }); + token = await jwtService.signAsync({ + userId: user.id, + email: user.email, + name: user.name, + }); }); afterAll(async () => { - await prisma.oAuthAccount.deleteMany({}); - await prisma.user.deleteMany({}); + await cleanDb(); await app.close(); }); - it('should return 200 OK for health check', () => { - return request('http://localhost:8080') - .get('/health') - .expect(200) - .expect({ status: 'ok' }); - }); - - it('should create a user and oauth account via AuthService', async () => { - const email = `integration+${uuidv4()}@test.com`; - const providerUserId = uuidv4(); - - // Simulate token - await request('http://localhost:8080') - .post('/token') - .send({ - sub: providerUserId, - email, - name: 'Integration Test', - }) - .expect(200); - - const mutation = ` + describe('createAuth', () => { + const CREATE_AUTH = ` mutation CreateAuth($input: CreateAuthInput!) { createAuth(createAuthInput: $input) { user { @@ -62,173 +106,322 @@ describe('AuthService Integration (OAuth)', () => { } `; - const variables = { - input: { - name: 'Integration Test', - email, - provider: 'mock', - providerUserId, - }, - }; + it('should create a user and oauth account', async () => { + const email = `integration+${uuidv4()}@test.com`; + const providerUserId = uuidv4(); - const res = await request(app.getHttpServer()) - .post('/api/graphql') - .send({ query: mutation, variables }); + const variables = { + input: { + name: 'Integration Test', + email, + provider: 'mock', + providerUserId, + }, + }; + + const res = await gql(CREATE_AUTH, variables); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); + const { data } = res.body; + expect(data.createAuth.user.email).toBe(email); + expect(data.createAuth.refreshToken).toBeDefined(); - const { data } = res.body; - expect(data.createAuth.user.email).toBe(email); - expect(data.createAuth.user.name).toBe('Integration Test'); - expect(data.createAuth.refreshToken).toBeDefined(); + const user = await prisma.user.findUnique({ + where: { email }, + include: { oauthAccounts: true }, + }); - const user = await prisma.user.findUnique({ - where: { email }, - include: { oauthAccounts: true }, + expect(user).toBeTruthy(); + expect(user?.oauthAccounts[0]?.provider).toBe('mock'); + expect(user?.oauthAccounts[0]?.providerUserId).toBe(providerUserId); }); - expect(user).toBeTruthy(); - expect(user?.oauthAccounts[0]?.provider).toBe('mock'); - expect(user?.oauthAccounts[0]?.providerUserId).toBe(providerUserId); + it('should return error when user already exists', async () => { + Logger.overrideLogger(false); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + const email = `duplicate+${uuidv4()}@test.com`; + const providerUserId = uuidv4(); + + await prisma.user.create({ + data: { + name: 'Duplicate Test', + email, + oauthAccounts: { + create: { + provider: 'mock', + providerUserId, + expiresAt: new Date(Date.now() + 3600 * 1000), + }, + }, + }, + }); + + const res = await gql(CREATE_AUTH, { + input: { + name: 'Duplicate Test', + email, + provider: 'mock', + providerUserId, + }, + }); + + expect(res.status).toBe(200); + expect(res.body.data).toBeNull(); + expect(res.body.errors?.[0]?.message).toContain( + `User with email ${email} already exists`, + ); + }); }); - it('should return 400 error when creating a user that already exists', async () => { - // Silence console.error for this test - Logger.overrideLogger(false); - jest.spyOn(console, 'error').mockImplementation(() => {}); + describe('removeAuth', () => { + it('should return "Invalid or expired token" for non-existent OAuth account', async () => { + const res = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${token}+fake`) + .send({ + query: ` + mutation { + removeAuth + } + `, + }); + + expect(res.status).toBe(200); + expect(res.body.data).toBeNull(); + expect(res.body.errors[0].message).toMatch('Invalid or expired token'); + }); - const email = 'integration@test.com'; - const providerUserId = uuidv4(); + it('should remove an existing OAuth account successfully', async () => { + const email = `remove+${uuidv4()}@test.com`; + const providerUserId = uuidv4(); + + const user = await prisma.user.create({ + data: { + name: 'Remove Test', + email, + oauthAccounts: { + create: { + provider: 'mock', + providerUserId, + expiresAt: new Date(), + }, + }, + }, + include: { oauthAccounts: true }, + }); - // Create user manually - await prisma.user.create({ - data: { - name: 'Duplicate Test', - email, - oauthAccounts: { - create: { - provider: 'mock', - providerUserId, - expiresAt: new Date(Date.now() + 3600 * 1000), + // Generate a fresh token for this user + const jwtService = new JwtService({ + secret: process.env.JWT_SECRET, + }); + const userToken = await jwtService.signAsync({ + userId: user.id, + email: user.email, + name: user.name, + }); + + const res = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${userToken}`) + .send({ + query: ` + mutation { + removeAuth + } + `, + }); + + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.removeAuth).toBe(true); + }); + }); + + describe('login and logout', () => { + let userEmail: string; + let providerUserId: string; + let refreshToken: string; + let token: string; + + beforeAll(async () => { + // Create a user in the DB to test login + userEmail = `login+${uuidv4()}@test.com`; + providerUserId = uuidv4(); + + await prisma.user.create({ + data: { + name: 'Login Test', + email: userEmail, + oauthAccounts: { + create: { + provider: 'mock', + providerUserId, + expiresAt: new Date(), + }, }, }, - }, + }); }); - const mutation = ` - mutation CreateAuth($input: CreateAuthInput!) { - createAuth(createAuthInput: $input) { + it('should login successfully', async () => { + const LOGIN = ` + mutation Login($email: String!) { + login(email: $email) { + accessToken + refreshToken user { email name } - refreshToken } } `; - const res = await request(app.getHttpServer()) - .post('/api/graphql') - .send({ - query: mutation, - variables: { - input: { - name: 'Duplicate Test', - email, - provider: 'mock', - providerUserId, - }, - }, - }); + const res = await gql(LOGIN, { email: userEmail }); - expect(res.status).toBe(200); - expect(res.body.data).toBeNull(); - expect(res.body.errors?.[0]?.message).toContain( - `User with email ${email} already exists`, - ); - }); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); - // Remove non-existent OAuth account - it('should return 404 when removing a non-existent OAuth account', async () => { - const mutation = ` - mutation RemoveOAuthAccount($id: String!) { - removeAuth(id: $id) - } - `; + const data = res.body.data.login; + expect(data.user.email).toBe(userEmail); + expect(data.accessToken).toBeDefined(); + expect(data.refreshToken).toBeDefined(); - const fakeId = '11111111-1111-1111-1111-111111111111'; // valid UUID format + // Save refreshToken for logout test + refreshToken = data.refreshToken; + token = data.accessToken; + }); - const res = await request(app.getHttpServer()) - .post('/api/graphql') - .send({ query: mutation, variables: { id: fakeId } }); - - expect(res.status).toBe(200); - expect(res.body.data).toBeNull(); - expect(res.body.errors?.[0]?.message).toContain( - `OAuthAccount with ID ${fakeId} not found`, - ); - expect(res.body.errors?.[0]?.extensions?.status).toBe(404); - }); + it('should logout successfully', async () => { + const res = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) + .send({ + query: ` + mutation { + logout + } + `, + }); - // Remove existing OAuth account successfully - it('should remove an existing OAuth account successfully', async () => { - const email = `integration+${uuidv4()}@test.com`; - const providerUserId = uuidv4(); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + expect(res.body.data.logout).toBe(true); - const user = await prisma.user.create({ - data: { - name: 'Remove Test', - email, - oauthAccounts: { - create: { - provider: 'mock', - providerUserId, - expiresAt: new Date(), - }, - }, - }, - include: { oauthAccounts: true }, + // Verify session is deleted + const session = await prisma.session.findUnique({ + where: { refreshToken }, + }); + expect(session).toBeNull(); }); - const oauthAccountId = user.oauthAccounts[0].id; + it('should return error when logging out with invalid token', async () => { + const res = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${token}-invalid`) + .send({ + query: ` + mutation { + logout + } + `, + }); + + expect(res.status).toBe(200); + expect(res.body.data).toBeNull(); + expect(res.body.errors?.[0]?.message).toBeDefined(); + }); + }); - const mutation = ` - mutation RemoveOAuthAccount($id: String!) { - removeAuth(id: $id) + describe('refreshToken', () => { + let app: INestApplication; + let prisma: PrismaService; + let userEmail: string; + let userId: string; + let refreshToken: string; + + const REFRESH_TOKEN_MUTATION = ` + mutation RefreshToken($token: String!) { + payload(refreshToken: $token) { + refreshToken + user { email } + } } `; - const res = await request(app.getHttpServer()) - .post('/api/graphql') - .send({ query: mutation, variables: { id: oauthAccountId } }); + beforeAll(async () => { + prisma = new PrismaService(); + + // 1️⃣ Create test user first + userEmail = `refreshtoken+${uuidv4()}@test.com`; + const user = await prisma.user.create({ + data: { + name: 'Refresh Token Test', + email: userEmail, + oauthAccounts: { + create: { + provider: 'mock', + providerUserId: uuidv4(), + expiresAt: new Date(), + }, + }, + }, + }); + userId = user.id; + + // 2️⃣ Create a session with a refresh token + refreshToken = `refresh-${uuidv4()}`; + await prisma.session.create({ + data: { + userId, + refreshToken, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + ipAddress: null, + userAgent: null, + }, + }); - // console.error(res.body); + // 3️⃣ Compile the app AFTER creating user/session + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideGuard(RefreshTokenGuard) + .useValue({ + canActivate: (context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + ctx.getContext().req.user = { userId, email: userEmail }; + return true; + }, + }) + .compile(); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.removeAuth).toBe(true); - }); + app = moduleFixture.createNestApplication(); + await app.init(); + }); - it('should return 404 error when refreshing with invalid token', async () => { - const mutation = ` - mutation RefreshToken($token: String!) { - refreshToken(refreshToken: $token) { - refreshToken - user { - email - } - } - } - `; + afterAll(async () => { + await prisma.session.deleteMany({}); + await prisma.user.deleteMany({}); + await app.close(); + }); - const res = await request(app.getHttpServer()) - .post('/api/graphql') - .send({ query: mutation, variables: { token: 'invalid-token' } }); + it('should refresh token successfully with a valid refresh token', async () => { + const res = await request(app.getHttpServer()) + .post('/api/graphql') + .send({ + query: REFRESH_TOKEN_MUTATION, + variables: { token: refreshToken }, + }); - expect(res.status).toBe(200); - expect(res.body.data).toBeNull(); - expect(res.body.errors?.[0]?.message).toContain('Session not found'); - expect(res.body.errors?.[0]?.extensions?.status).toBe(404); + expect(res.status).toBe(200); + expect(res.body.errors).toBeUndefined(); + + const data = res.body.data.payload; + expect(data.user.email).toBe(userEmail); + expect(data.refreshToken).toBeDefined(); + }); }); }); diff --git a/api/test/int/challenge.int-spec.ts b/api/test/int/challenge.int-spec.ts index 1760a22..2b1118c 100644 --- a/api/test/int/challenge.int-spec.ts +++ b/api/test/int/challenge.int-spec.ts @@ -6,6 +6,11 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; import { JwtService } from '@nestjs/jwt'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('ChallengeModule (integration)', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -43,7 +48,7 @@ describe('ChallengeModule (integration)', () => { // Create JWT token = jwtService.sign( - { sub: userId }, + { userId: userId, name: user.name, email: user.email }, { secret: process.env.JWT_SECRET }, ); }); @@ -154,6 +159,24 @@ describe('ChallengeModule (integration)', () => { expect(check).toBeNull(); }); + it('throw error when non-creator attempts to delete challenge', async () => { + const mutation = ` + mutation RemoveChallenge($id: String!) { + removeChallenge(id: $id) { + id + name + } + } + `; + + const response = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${token}-fake`) + .send({ query: mutation, variables: { id: challengeId } }); + expect(response.status).toBe(200); + expect(response.body.errors).toBeDefined(); + }); + describe('Unauthorized requests', () => { it('should not allow creating a challenge without auth', async () => { const mutation = ` diff --git a/api/test/int/comment.int-spec.ts b/api/test/int/comment.int-spec.ts index ec72147..70e0906 100644 --- a/api/test/int/comment.int-spec.ts +++ b/api/test/int/comment.int-spec.ts @@ -4,6 +4,12 @@ import * as request from 'supertest'; import { AppModule } from '../../src/app.module'; import { PrismaService } from '../../src/prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; +import { v4 as uuidv4 } from 'uuid'; + +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); describe('CommentModule (integration)', () => { let app: INestApplication; @@ -28,13 +34,21 @@ describe('CommentModule (integration)', () => { prisma = app.get(PrismaService); jwtService = app.get(JwtService); - // Seed user and post + // Create user const user = await prisma.user.create({ - data: { email: 'test@example.com', name: 'Test User' }, + data: { + id: uuidv4(), + email: 'comment-test@example.com', + name: 'Comment Tester', + }, }); userId = user.id; - // console.log('User ID:', userId); + // Create JWT + token = jwtService.sign( + { userId: userId, name: user.name, email: user.email }, + { secret: process.env.JWT_SECRET }, + ); const post = await prisma.post.create({ data: { @@ -45,7 +59,11 @@ describe('CommentModule (integration)', () => { }); postId = post.id; - token = jwtService.sign({ sub: userId }); + // Create JWT + token = jwtService.sign( + { userId: userId, name: user.name, email: user.email }, + { secret: process.env.JWT_SECRET }, + ); }); it('creates a comment', async () => { diff --git a/api/test/int/league.int-spec.ts b/api/test/int/league.int-spec.ts index f09bf22..b611695 100644 --- a/api/test/int/league.int-spec.ts +++ b/api/test/int/league.int-spec.ts @@ -6,6 +6,11 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; import { v4 as uuidv4 } from 'uuid'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('LeagueModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; @@ -31,12 +36,17 @@ describe('LeagueModule (integration)', () => { // Create user const user = await prisma.user.create({ - data: { id: uuidv4(), email: 'league@test.com', name: 'League User' }, + data: { + id: uuidv4(), + email: 'challenge-test@example.com', + name: 'Challenge Tester', + }, }); userId = user.id; + // Create JWT token = jwtService.sign( - { sub: userId }, + { userId: userId, name: user.name, email: user.email }, { secret: process.env.JWT_SECRET }, ); }); diff --git a/api/test/int/leagueuser.int-spec.ts b/api/test/int/leagueuser.int-spec.ts index ca894c7..fb3d2ef 100644 --- a/api/test/int/leagueuser.int-spec.ts +++ b/api/test/int/leagueuser.int-spec.ts @@ -6,6 +6,11 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; import { JwtService } from '@nestjs/jwt'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('LeagueuserModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; @@ -52,8 +57,9 @@ describe('LeagueuserModule (integration)', () => { }); leagueId = league.id; + // Create JWT token = jwtService.sign( - { sub: userId }, + { userId: userId, name: user.name, email: user.email }, { secret: process.env.JWT_SECRET }, ); }); diff --git a/api/test/int/like.int-spec.ts b/api/test/int/like.int-spec.ts index d72d16a..6b93227 100644 --- a/api/test/int/like.int-spec.ts +++ b/api/test/int/like.int-spec.ts @@ -6,6 +6,11 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; import { JwtService } from '@nestjs/jwt'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('LikeModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; @@ -47,9 +52,9 @@ describe('LikeModule (integration)', () => { }); postId = post.id; - // Create JWT token + // Create JWT token = jwtService.sign( - { sub: userId }, + { userId: userId, name: user.name, email: user.email }, { secret: process.env.JWT_SECRET }, ); }); diff --git a/api/test/int/post.int-spec.ts b/api/test/int/post.int-spec.ts index de9f1f5..7275338 100644 --- a/api/test/int/post.int-spec.ts +++ b/api/test/int/post.int-spec.ts @@ -4,11 +4,19 @@ import { AppModule } from '../../src/app.module'; import * as request from 'supertest'; import { PrismaService } from '../../src/prisma/prisma.service'; import { v4 as uuidv4 } from 'uuid'; +import { JwtService } from '@nestjs/jwt'; + +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); describe('PostModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; let userId: string; + let token: string; + let jwtService: JwtService; let postId: string; const nonExistentId = uuidv4(); // Generate a random ID that doesn't exist @@ -21,21 +29,28 @@ describe('PostModule (integration)', () => { await app.init(); prisma = app.get(PrismaService); + jwtService = app.get(JwtService); // Clear and seed the test DB await prisma.post.deleteMany(); await prisma.user.deleteMany(); + // Create user const user = await prisma.user.create({ data: { id: uuidv4(), - email: 'test@example.com', - name: 'Test User', + email: 'challenge-test@example.com', + name: 'Challenge Tester', }, }); - userId = user.id; + // Create JWT + token = jwtService.sign( + { userId: userId, name: user.name, email: user.email }, + { secret: process.env.JWT_SECRET }, + ); + const post = await prisma.post.create({ data: { id: uuidv4(), @@ -68,6 +83,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query }); expect(response.status).toBe(200); @@ -93,6 +109,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query, variables: { userId }, @@ -130,6 +147,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query: mutation, variables }); // console.error(response.body.errors); @@ -154,6 +172,7 @@ describe('PostModule (integration)', () => { // Remove the post const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query: mutation, variables: { id: postId } }); expect(response.status).toBe(200); @@ -170,6 +189,7 @@ describe('PostModule (integration)', () => { const confirmResponse = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query: confirmQuery }); expect(confirmResponse.status).toBe(200); @@ -193,6 +213,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query }); expect(response.status).toBe(200); @@ -225,6 +246,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query: mutation, variables }); expect(response.status).toBe(200); @@ -247,6 +269,7 @@ describe('PostModule (integration)', () => { const response = await request(app.getHttpServer()) .post('/api/graphql') + .set('Authorization', `Bearer ${token}`) .send({ query: mutation, variables: { id: nonExistentId } }); expect(response.status).toBe(200); diff --git a/api/test/int/userchallenge.int-spec.ts b/api/test/int/userchallenge.int-spec.ts index 02dfe0c..ba02b1f 100644 --- a/api/test/int/userchallenge.int-spec.ts +++ b/api/test/int/userchallenge.int-spec.ts @@ -7,6 +7,11 @@ import { v4 as uuidv4 } from 'uuid'; import { ChallengeStatus } from '@prisma/client'; import { JwtService } from '@nestjs/jwt'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('UserchallengeModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; @@ -32,12 +37,22 @@ describe('UserchallengeModule (integration)', () => { await prisma.challenge.deleteMany(); await prisma.user.deleteMany(); - // Create user and challenge + // Create user const user = await prisma.user.create({ - data: { id: uuidv4(), email: 'test@example.com', name: 'Test User' }, + data: { + id: uuidv4(), + email: 'challenge-test@example.com', + name: 'Challenge Tester', + }, }); userId = user.id; + // Create JWT + token = jwtService.sign( + { userId: userId, name: user.name, email: user.email }, + { secret: process.env.JWT_SECRET }, + ); + const challenge = await prisma.challenge.create({ data: { id: uuidv4(), @@ -46,11 +61,6 @@ describe('UserchallengeModule (integration)', () => { }, }); challengeId = challenge.id; - - token = jwtService.sign( - { sub: userId }, - { secret: process.env.JWT_SECRET }, - ); }); afterAll(async () => { diff --git a/api/test/int/userfollow.int-spec.ts b/api/test/int/userfollow.int-spec.ts index b50013e..e9fb9e0 100644 --- a/api/test/int/userfollow.int-spec.ts +++ b/api/test/int/userfollow.int-spec.ts @@ -6,6 +6,11 @@ import { v4 as uuidv4 } from 'uuid'; import * as request from 'supertest'; import { JwtService } from '@nestjs/jwt'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env.dev.local') }); + describe('UserfollowModule (integration)', () => { let app: INestApplication; let prisma: PrismaService; @@ -51,7 +56,7 @@ describe('UserfollowModule (integration)', () => { // Generate JWT for userA tokenA = jwtService.sign( - { sub: userAId }, + { userId: userAId, name: userA.name, email: userA.email }, { secret: process.env.JWT_SECRET }, ); });