diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..566f508 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -1,14 +1,23 @@ import { Test, TestingModule } from '@nestjs/testing'; +import type { Response } from 'express'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { PrismaService } from './database/prisma.service'; describe('AppController', () => { let appController: AppController; + const prismaMock = { + $queryRaw: jest.fn().mockResolvedValue([1]), + } as unknown as PrismaService; + + const prismaDownMock = { + $queryRaw: jest.fn().mockRejectedValue(new Error('db down')), + } as unknown as PrismaService; beforeEach(async () => { const app: TestingModule = await Test.createTestingModule({ controllers: [AppController], - providers: [AppService], + providers: [AppService, { provide: PrismaService, useValue: prismaMock }], }).compile(); appController = app.get(AppController); @@ -19,4 +28,36 @@ describe('AppController', () => { expect(appController.getHello()).toBe('Hello World!'); }); }); + + describe('health', () => { + it('should return health payload', async () => { + const res = { status: jest.fn() } as unknown as Response; + const response = await appController.getHealth(res); + + expect(res.status).not.toHaveBeenCalled(); + expect(['OK', 'DOWN']).toContain(response.status); + expect(response.version).toBeDefined(); + expect(response.uptimeSecs).toBeGreaterThanOrEqual(0); + expect(['OK', 'DOWN']).toContain(response.db); + }); + + it('should mark down and set 503 when db fails', async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [ + AppService, + { provide: PrismaService, useValue: prismaDownMock }, + ], + }).compile(); + + const controller = app.get(AppController); + const res = { status: jest.fn() } as unknown as Response; + + const response = await controller.getHealth(res); + + expect(res.status).toHaveBeenCalledWith(503 as const); + expect(response.status).toBe('DOWN'); + expect(response.db).toBe('DOWN'); + }); + }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..251baaa 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,6 +1,10 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller, Get, Header, HttpStatus, Res } from '@nestjs/common'; +import type { Response } from 'express'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AppService, HealthResponse } from './app.service'; +import { HealthResponseDto } from './app.health-response.dto'; +@ApiTags('system') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @@ -9,4 +13,20 @@ export class AppController { getHello(): string { return this.appService.getHello(); } + + @Get('/health') + @Header('Cache-Control', 'no-store') + @ApiOperation({ summary: 'Health check' }) + @ApiOkResponse({ description: 'Service health', type: HealthResponseDto }) + async getHealth( + @Res({ passthrough: true }) res: Response, + ): Promise { + const health = await this.appService.getHealth(); + + if (health.status === 'DOWN') { + res.status(HttpStatus.SERVICE_UNAVAILABLE); + } + + return health; + } } diff --git a/src/app.health-response.dto.ts b/src/app.health-response.dto.ts new file mode 100644 index 0000000..a774d39 --- /dev/null +++ b/src/app.health-response.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import type { DatabaseHealth } from './app.service'; + +export class HealthResponseDto { + @ApiProperty({ + enum: ['OK', 'DOWN'], + example: 'OK', + description: 'Overall service status', + }) + status!: DatabaseHealth; + + @ApiProperty({ description: 'Server build version', example: '0.0.1' }) + version!: string; + + @ApiProperty({ description: 'Process uptime in seconds', example: 123 }) + uptimeSecs!: number; + + @ApiProperty({ enum: ['OK', 'DOWN'], example: 'OK' }) + db!: DatabaseHealth; +} diff --git a/src/app.service.ts b/src/app.service.ts index 927d7cc..f748c34 100644 --- a/src/app.service.ts +++ b/src/app.service.ts @@ -1,8 +1,43 @@ import { Injectable } from '@nestjs/common'; +import { PrismaService } from './database/prisma.service'; + +const appVersion = + process.env.APP_VERSION ?? process.env.npm_package_version ?? 'unknown'; + +export type DatabaseHealth = 'OK' | 'DOWN'; + +export interface HealthResponse { + status: DatabaseHealth; + version: string; + uptimeSecs: number; + db: DatabaseHealth; +} @Injectable() export class AppService { + constructor(private readonly prisma: PrismaService) {} + getHello(): string { return 'Hello World!'; } + + async getHealth(): Promise { + const uptimeSecs = Math.floor(process.uptime()); + let db: DatabaseHealth = 'OK'; + + try { + await this.prisma.$queryRaw`SELECT 1`; + } catch { + db = 'DOWN'; + } + + const status: DatabaseHealth = db === 'OK' ? 'OK' : 'DOWN'; + + return { + status, + version: appVersion, + uptimeSecs, + db, + }; + } } diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 5d1b9a4..247da96 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,34 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { URLSearchParams } from 'url'; +import axios from 'axios'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import type { AuthenticatedUser } from './decorators/current-user.decorator'; import { User } from '../users/users.entity'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; -import { URLSearchParams } from 'url'; describe('AuthService', () => { let service: AuthService; - const mockCreateFromToken = jest.fn(); - const mockFindByKeycloakSub = jest.fn(); - const mockFindOrCreate = jest.fn(); - - const mockUsersService = { - createFromToken: mockCreateFromToken, - findByKeycloakSub: mockFindByKeycloakSub, - findOrCreate: mockFindOrCreate, - }; - - const mockAuthUser: AuthenticatedUser = { - keycloakSub: 'f:realm:user123', - email: 'test@example.com', - name: 'Test User', - username: 'testuser', - picture: 'https://example.com/avatar.jpg', - roles: ['user', 'premium'], - }; - const mockUser: User = { id: 'uuid-123', keycloakSub: 'f:realm:user123', @@ -43,6 +24,23 @@ describe('AuthService', () => { activeDollId: null, }; + const mockUsersService: jest.Mocked< + Pick + > = { + createFromToken: jest.fn().mockResolvedValue(mockUser), + findByKeycloakSub: jest.fn().mockResolvedValue(null), + findOrCreate: jest.fn().mockResolvedValue(mockUser), + }; + + const mockAuthUser: AuthenticatedUser = { + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -82,9 +80,10 @@ describe('AuthService', () => { it('should skip when config missing', async () => { const missingConfigService = new ConfigService({}); const localService = new AuthService( - mockUsersService as any, + mockUsersService as unknown as UsersService, missingConfigService, ); + const warnSpy = jest.spyOn(localService['logger'], 'warn'); const result = await localService.revokeToken('rt'); expect(result).toBe(false); @@ -127,12 +126,12 @@ describe('AuthService', () => { describe('syncUserFromToken', () => { it('should create a new user if user does not exist', async () => { - mockCreateFromToken.mockReturnValue(mockUser); + mockUsersService.createFromToken.mockResolvedValue(mockUser); const result = await service.syncUserFromToken(mockAuthUser); expect(result).toEqual(mockUser); - expect(mockCreateFromToken).toHaveBeenCalledWith({ + expect(mockUsersService.createFromToken).toHaveBeenCalledWith({ keycloakSub: 'f:realm:user123', email: 'test@example.com', name: 'Test User', @@ -144,12 +143,12 @@ describe('AuthService', () => { it('should handle existing user via upsert', async () => { const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') }; - mockCreateFromToken.mockReturnValue(updatedUser); + mockUsersService.createFromToken.mockResolvedValue(updatedUser); const result = await service.syncUserFromToken(mockAuthUser); expect(result).toEqual(updatedUser); - expect(mockCreateFromToken).toHaveBeenCalledWith({ + expect(mockUsersService.createFromToken).toHaveBeenCalledWith({ keycloakSub: 'f:realm:user123', email: 'test@example.com', name: 'Test User', @@ -165,7 +164,7 @@ describe('AuthService', () => { name: 'No Email User', }; - mockCreateFromToken.mockReturnValue({ + mockUsersService.createFromToken.mockResolvedValue({ ...mockUser, email: '', name: 'No Email User', @@ -173,7 +172,7 @@ describe('AuthService', () => { await service.syncUserFromToken(authUserNoEmail); - expect(mockCreateFromToken).toHaveBeenCalledWith( + expect(mockUsersService.createFromToken).toHaveBeenCalledWith( expect.objectContaining({ email: '', name: 'No Email User', @@ -187,14 +186,14 @@ describe('AuthService', () => { username: 'fallbackuser', }; - mockCreateFromToken.mockReturnValue({ + mockUsersService.createFromToken.mockResolvedValue({ ...mockUser, name: 'fallbackuser', }); await service.syncUserFromToken(authUserNoName); - expect(mockCreateFromToken).toHaveBeenCalledWith( + expect(mockUsersService.createFromToken).toHaveBeenCalledWith( expect.objectContaining({ name: 'fallbackuser', }), @@ -206,14 +205,14 @@ describe('AuthService', () => { keycloakSub: 'f:realm:minimal', }; - mockCreateFromToken.mockReturnValue({ + mockUsersService.createFromToken.mockResolvedValue({ ...mockUser, name: 'Unknown User', }); await service.syncUserFromToken(authUserMinimal); - expect(mockCreateFromToken).toHaveBeenCalledWith( + expect(mockUsersService.createFromToken).toHaveBeenCalledWith( expect.objectContaining({ name: 'Unknown User', }), @@ -227,7 +226,7 @@ describe('AuthService', () => { name: 'Empty Sub User', }; - mockCreateFromToken.mockReturnValue({ + mockUsersService.createFromToken.mockResolvedValue({ ...mockUser, keycloakSub: '', email: 'empty@example.com', @@ -236,7 +235,7 @@ describe('AuthService', () => { await service.syncUserFromToken(authUserEmptySub); - expect(mockCreateFromToken).toHaveBeenCalledWith( + expect(mockUsersService.createFromToken).toHaveBeenCalledWith( expect.objectContaining({ keycloakSub: '', email: 'empty@example.com', @@ -252,7 +251,7 @@ describe('AuthService', () => { name: 'Malformed User', }; - mockCreateFromToken.mockReturnValue({ + mockUsersService.createFromToken.mockResolvedValue({ ...mockUser, keycloakSub: 'invalid-format', email: 'malformed@example.com', @@ -261,24 +260,25 @@ describe('AuthService', () => { const result = await service.syncUserFromToken(authUserMalformed); - expect(mockCreateFromToken).toHaveBeenCalledWith( + expect(mockUsersService.createFromToken).toHaveBeenCalledWith( expect.objectContaining({ keycloakSub: 'invalid-format', email: 'malformed@example.com', name: 'Malformed User', }), ); + expect(result.keycloakSub).toBe('invalid-format'); }); }); describe('ensureUserExists', () => { it('should call findOrCreate with correct params', async () => { - mockFindOrCreate.mockResolvedValue(mockUser); + mockUsersService.findOrCreate.mockResolvedValue(mockUser); const result = await service.ensureUserExists(mockAuthUser); expect(result).toEqual(mockUser); - expect(mockFindOrCreate).toHaveBeenCalledWith({ + expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({ keycloakSub: 'f:realm:user123', email: 'test@example.com', name: 'Test User', @@ -288,45 +288,23 @@ describe('AuthService', () => { }); }); - it('should handle user with no email', async () => { - const authUserNoEmail: AuthenticatedUser = { - keycloakSub: 'f:realm:user456', - name: 'No Email User', - }; + it('should handle missing username gracefully', async () => { + mockUsersService.findOrCreate.mockResolvedValue(mockUser); - mockFindOrCreate.mockResolvedValue({ - ...mockUser, - email: '', - name: 'No Email User', + const result = await service.ensureUserExists({ + ...mockAuthUser, + username: undefined, }); - await service.ensureUserExists(authUserNoEmail); - - expect(mockFindOrCreate).toHaveBeenCalledWith( - expect.objectContaining({ - email: '', - name: 'No Email User', - }), - ); - }); - - it('should use "Unknown User" when creating user with no name or username', async () => { - const authUserMinimal: AuthenticatedUser = { - keycloakSub: 'f:realm:minimal', - }; - - mockFindOrCreate.mockResolvedValue({ - ...mockUser, - name: 'Unknown User', + expect(result).toEqual(mockUser); + expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: undefined, + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], }); - - await service.ensureUserExists(authUserMinimal); - - expect(mockFindOrCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Unknown User', - }), - ); }); }); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..a315e64 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,16 +1,24 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { App } from 'supertest/types'; +import { PrismaService } from '../src/database/prisma.service'; import { AppModule } from './../src/app.module'; +const prismaMock = { + $queryRaw: jest.fn().mockResolvedValue([1]), +}; + describe('AppController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(PrismaService) + .useValue(prismaMock) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); @@ -22,4 +30,50 @@ describe('AppController (e2e)', () => { .expect(200) .expect('Hello World!'); }); + + it('/health (GET) returns ok with db up', () => { + return request(app.getHttpServer()) + .get('/health') + .expect('Cache-Control', 'no-store') + .expect(200) + .expect( + ({ + body, + }: { + body: { + status: string; + version: string; + uptimeSecs: number; + db: string; + }; + }) => { + expect(body.status).toBe('ok'); + expect(body.db).toBe('ok'); + expect(body.version).toBeDefined(); + expect(body.uptimeSecs).toBeGreaterThanOrEqual(0); + }, + ); + }); + + it('/health (GET) returns 503 with db down', async () => { + prismaMock.$queryRaw.mockRejectedValueOnce(new Error('db down')); + + await request(app.getHttpServer()) + .get('/health') + .expect('Cache-Control', 'no-store') + .expect(503) + .expect( + ({ + body, + }: { + body: { + status: string; + db: string; + }; + }) => { + expect(body.status).toBe('DOWN'); + expect(body.db).toBe('DOWN'); + }, + ); + }); });