This commit is contained in:
2026-01-02 15:14:58 +08:00
parent 2f51a0498f
commit d1f2f9089e
6 changed files with 227 additions and 79 deletions

View File

@@ -1,14 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import type { Response } from 'express';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { PrismaService } from './database/prisma.service';
describe('AppController', () => { describe('AppController', () => {
let appController: 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 () => { beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({ const app: TestingModule = await Test.createTestingModule({
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService, { provide: PrismaService, useValue: prismaMock }],
}).compile(); }).compile();
appController = app.get<AppController>(AppController); appController = app.get<AppController>(AppController);
@@ -19,4 +28,36 @@ describe('AppController', () => {
expect(appController.getHello()).toBe('Hello World!'); 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');
});
});
}); });

View File

@@ -1,6 +1,10 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Header, HttpStatus, Res } from '@nestjs/common';
import { AppService } from './app.service'; 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() @Controller()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
@@ -9,4 +13,20 @@ export class AppController {
getHello(): string { getHello(): string {
return this.appService.getHello(); 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;
}
} }

View 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;
}

View File

@@ -1,8 +1,43 @@
import { Injectable } from '@nestjs/common'; 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() @Injectable()
export class AppService { export class AppService {
constructor(private readonly prisma: PrismaService) {}
getHello(): string { getHello(): string {
return 'Hello World!'; 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,
};
}
} }

View File

@@ -1,34 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing'; 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 { AuthService } from './auth.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator'; import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity'; import { User } from '../users/users.entity';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { URLSearchParams } from 'url';
describe('AuthService', () => { describe('AuthService', () => {
let service: 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 = { const mockUser: User = {
id: 'uuid-123', id: 'uuid-123',
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
@@ -43,6 +24,23 @@ describe('AuthService', () => {
activeDollId: null, 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
@@ -82,9 +80,10 @@ describe('AuthService', () => {
it('should skip when config missing', async () => { it('should skip when config missing', async () => {
const missingConfigService = new ConfigService({}); const missingConfigService = new ConfigService({});
const localService = new AuthService( const localService = new AuthService(
mockUsersService as any, mockUsersService as unknown as UsersService,
missingConfigService, missingConfigService,
); );
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn'); const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
const result = await localService.revokeToken('rt'); const result = await localService.revokeToken('rt');
expect(result).toBe(false); expect(result).toBe(false);
@@ -127,12 +126,12 @@ describe('AuthService', () => {
describe('syncUserFromToken', () => { describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => { 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); const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
expect(mockCreateFromToken).toHaveBeenCalledWith({ expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'test@example.com', email: 'test@example.com',
name: 'Test User', name: 'Test User',
@@ -144,12 +143,12 @@ describe('AuthService', () => {
it('should handle existing user via upsert', async () => { it('should handle existing user via upsert', async () => {
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') }; const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
mockCreateFromToken.mockReturnValue(updatedUser); mockUsersService.createFromToken.mockResolvedValue(updatedUser);
const result = await service.syncUserFromToken(mockAuthUser); const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(updatedUser); expect(result).toEqual(updatedUser);
expect(mockCreateFromToken).toHaveBeenCalledWith({ expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'test@example.com', email: 'test@example.com',
name: 'Test User', name: 'Test User',
@@ -165,7 +164,7 @@ describe('AuthService', () => {
name: 'No Email User', name: 'No Email User',
}; };
mockCreateFromToken.mockReturnValue({ mockUsersService.createFromToken.mockResolvedValue({
...mockUser, ...mockUser,
email: '', email: '',
name: 'No Email User', name: 'No Email User',
@@ -173,7 +172,7 @@ describe('AuthService', () => {
await service.syncUserFromToken(authUserNoEmail); await service.syncUserFromToken(authUserNoEmail);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
email: '', email: '',
name: 'No Email User', name: 'No Email User',
@@ -187,14 +186,14 @@ describe('AuthService', () => {
username: 'fallbackuser', username: 'fallbackuser',
}; };
mockCreateFromToken.mockReturnValue({ mockUsersService.createFromToken.mockResolvedValue({
...mockUser, ...mockUser,
name: 'fallbackuser', name: 'fallbackuser',
}); });
await service.syncUserFromToken(authUserNoName); await service.syncUserFromToken(authUserNoName);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: 'fallbackuser', name: 'fallbackuser',
}), }),
@@ -206,14 +205,14 @@ describe('AuthService', () => {
keycloakSub: 'f:realm:minimal', keycloakSub: 'f:realm:minimal',
}; };
mockCreateFromToken.mockReturnValue({ mockUsersService.createFromToken.mockResolvedValue({
...mockUser, ...mockUser,
name: 'Unknown User', name: 'Unknown User',
}); });
await service.syncUserFromToken(authUserMinimal); await service.syncUserFromToken(authUserMinimal);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: 'Unknown User', name: 'Unknown User',
}), }),
@@ -227,7 +226,7 @@ describe('AuthService', () => {
name: 'Empty Sub User', name: 'Empty Sub User',
}; };
mockCreateFromToken.mockReturnValue({ mockUsersService.createFromToken.mockResolvedValue({
...mockUser, ...mockUser,
keycloakSub: '', keycloakSub: '',
email: 'empty@example.com', email: 'empty@example.com',
@@ -236,7 +235,7 @@ describe('AuthService', () => {
await service.syncUserFromToken(authUserEmptySub); await service.syncUserFromToken(authUserEmptySub);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
keycloakSub: '', keycloakSub: '',
email: 'empty@example.com', email: 'empty@example.com',
@@ -252,7 +251,7 @@ describe('AuthService', () => {
name: 'Malformed User', name: 'Malformed User',
}; };
mockCreateFromToken.mockReturnValue({ mockUsersService.createFromToken.mockResolvedValue({
...mockUser, ...mockUser,
keycloakSub: 'invalid-format', keycloakSub: 'invalid-format',
email: 'malformed@example.com', email: 'malformed@example.com',
@@ -261,24 +260,25 @@ describe('AuthService', () => {
const result = await service.syncUserFromToken(authUserMalformed); const result = await service.syncUserFromToken(authUserMalformed);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
keycloakSub: 'invalid-format', keycloakSub: 'invalid-format',
email: 'malformed@example.com', email: 'malformed@example.com',
name: 'Malformed User', name: 'Malformed User',
}), }),
); );
expect(result.keycloakSub).toBe('invalid-format');
}); });
}); });
describe('ensureUserExists', () => { describe('ensureUserExists', () => {
it('should call findOrCreate with correct params', async () => { it('should call findOrCreate with correct params', async () => {
mockFindOrCreate.mockResolvedValue(mockUser); mockUsersService.findOrCreate.mockResolvedValue(mockUser);
const result = await service.ensureUserExists(mockAuthUser); const result = await service.ensureUserExists(mockAuthUser);
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
expect(mockFindOrCreate).toHaveBeenCalledWith({ expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'test@example.com', email: 'test@example.com',
name: 'Test User', name: 'Test User',
@@ -288,45 +288,23 @@ describe('AuthService', () => {
}); });
}); });
it('should handle user with no email', async () => { it('should handle missing username gracefully', async () => {
const authUserNoEmail: AuthenticatedUser = { mockUsersService.findOrCreate.mockResolvedValue(mockUser);
keycloakSub: 'f:realm:user456',
name: 'No Email User',
};
mockFindOrCreate.mockResolvedValue({ const result = await service.ensureUserExists({
...mockUser, ...mockAuthUser,
email: '', username: undefined,
name: 'No Email User',
}); });
await service.ensureUserExists(authUserNoEmail); expect(result).toEqual(mockUser);
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
expect(mockFindOrCreate).toHaveBeenCalledWith( keycloakSub: 'f:realm:user123',
expect.objectContaining({ email: 'test@example.com',
email: '', name: 'Test User',
name: 'No Email 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',
}),
);
}); });
}); });

View File

@@ -1,16 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { PrismaService } from '../src/database/prisma.service';
import { AppModule } from './../src/app.module'; import { AppModule } from './../src/app.module';
const prismaMock = {
$queryRaw: jest.fn().mockResolvedValue([1]),
};
describe('AppController (e2e)', () => { describe('AppController (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
beforeEach(async () => { beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); })
.overrideProvider(PrismaService)
.useValue(prismaMock)
.compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
await app.init(); await app.init();
@@ -22,4 +30,50 @@ describe('AppController (e2e)', () => {
.expect(200) .expect(200)
.expect('Hello World!'); .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');
},
);
});
}); });