health
This commit is contained in:
@@ -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>(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>(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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HealthResponse> {
|
||||
const health = await this.appService.getHealth();
|
||||
|
||||
if (health.status === 'DOWN') {
|
||||
res.status(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/app.health-response.dto.ts
Normal file
20
src/app.health-response.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<HealthResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UsersService, 'createFromToken' | 'findByKeycloakSub' | 'findOrCreate'>
|
||||
> = {
|
||||
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<any, any>(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',
|
||||
}),
|
||||
);
|
||||
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'],
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
await service.ensureUserExists(authUserMinimal);
|
||||
|
||||
expect(mockFindOrCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Unknown User',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<App>;
|
||||
|
||||
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');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user