Set up PostgreSQL database configuration and enhance application structure

- Added .env.example with database connection details and JWT configuration.
- Introduced docker-compose.yml for PostgreSQL service setup with health checks.
- Created drizzle.config.ts for database schema and migration management.
- Updated nest-cli.json to include Swagger plugin configuration for API documentation.
- Enhanced package.json with new database-related scripts and dependencies.
- Implemented initial database migrations for user and token management.
- Configured application bootstrap process to load environment variables and set up Swagger.
- Added shared application configuration in configure-app.ts for consistent setup.
- Included unit tests for application configuration and Swagger setup.
This commit is contained in:
shancheas
2026-08-21 15:00:19 +07:00
parent 7f37985d22
commit d01fd6e2ef
54 changed files with 4378 additions and 87 deletions
+12
View File
@@ -0,0 +1,12 @@
export type AuthUser = {
readonly id: string;
readonly username: string;
readonly jti: string;
};
export type JwtAccessPayload = {
readonly sub: string;
readonly username: string;
readonly jti: string;
readonly typ: 'access';
};
+42
View File
@@ -0,0 +1,42 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from '../app.controller';
import { AppService } from '../app.service';
import { configureApp } from './configure-app';
import * as setupSwaggerModule from './swagger/setup-swagger';
describe('configureApp', () => {
let app: INestApplication;
let useGlobalPipesSpy: jest.SpyInstance;
let setupSwaggerSpy: jest.SpyInstance;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
app = moduleRef.createNestApplication();
useGlobalPipesSpy = jest.spyOn(app, 'useGlobalPipes');
setupSwaggerSpy = jest
.spyOn(setupSwaggerModule, 'setupSwagger')
.mockImplementation(() => undefined);
});
afterAll(async () => {
await app.close();
});
afterEach(() => {
useGlobalPipesSpy.mockClear();
setupSwaggerSpy.mockClear();
});
it('registers ValidationPipe and setupSwagger', () => {
const env = { NODE_ENV: 'test' } as NodeJS.ProcessEnv;
configureApp(app, env);
expect(useGlobalPipesSpy).toHaveBeenCalledWith(expect.any(ValidationPipe));
expect(setupSwaggerSpy).toHaveBeenCalledWith(app, env);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { setupSwagger } from './swagger/setup-swagger';
/** Shared Nest app configuration for bootstrap and E2E. */
export function configureApp(
app: INestApplication,
env: NodeJS.ProcessEnv = process.env,
): void {
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
setupSwagger(app, env);
}
@@ -0,0 +1,47 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import type { AuthUser } from '../auth/auth-user';
/**
* Mirrors CurrentUser createParamDecorator factory for unit testing.
*/
function extractCurrentUser(
data: keyof AuthUser | undefined,
ctx: ExecutionContext,
): AuthUser | AuthUser[keyof AuthUser] {
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException();
}
return data ? user[data] : user;
}
describe('CurrentUser decorator', () => {
const user: AuthUser = {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
};
const createCtx = (u?: AuthUser): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({ user: u }),
}),
}) as unknown as ExecutionContext;
it('returns the full AuthUser when no property is specified', () => {
expect(extractCurrentUser(undefined, createCtx(user))).toEqual(user);
});
it('returns a single property when a key is specified', () => {
expect(extractCurrentUser('username', createCtx(user))).toBe('alice');
expect(extractCurrentUser('id', createCtx(user))).toBe('user-1');
});
it('throws UnauthorizedException when user is missing', () => {
expect(() => extractCurrentUser(undefined, createCtx())).toThrow(
UnauthorizedException,
);
});
});
@@ -0,0 +1,24 @@
import {
createParamDecorator,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import type { AuthUser } from '../auth/auth-user';
/**
* Injects the authenticated user from the request (set by JwtAuthGuard).
* Pass a property name to pick a single field, e.g. `@CurrentUser('id')`.
*/
export const CurrentUser = createParamDecorator(
(
data: keyof AuthUser | undefined,
ctx: ExecutionContext,
): AuthUser | AuthUser[keyof AuthUser] => {
const request = ctx.switchToHttp().getRequest<{ user?: AuthUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException();
}
return data ? user[data] : user;
},
);
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** Marks a controller class or route handler as publicly accessible (no JWT). */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+52
View File
@@ -0,0 +1,52 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
describe('JwtAuthGuard', () => {
const createContext = (
handlerMeta?: boolean,
classMeta?: boolean,
): ExecutionContext => {
const handler = () => undefined;
const controller = class TestController {};
if (handlerMeta !== undefined) {
Reflect.defineMetadata(IS_PUBLIC_KEY, handlerMeta, handler);
}
if (classMeta !== undefined) {
Reflect.defineMetadata(IS_PUBLIC_KEY, classMeta, controller);
}
return {
getHandler: () => handler,
getClass: () => controller,
switchToHttp: () => ({
getRequest: () => ({}),
}),
} as unknown as ExecutionContext;
};
it('allows public methods without JWT', () => {
const guard = new JwtAuthGuard(new Reflector());
const result = guard.canActivate(createContext(true));
expect(result).toBe(true);
});
it('allows public controller classes without JWT', () => {
const guard = new JwtAuthGuard(new Reflector());
const result = guard.canActivate(createContext(undefined, true));
expect(result).toBe(true);
});
it('delegates to AuthGuard when route is protected', () => {
const guard = new JwtAuthGuard(new Reflector());
const spy = jest
.spyOn(Object.getPrototypeOf(JwtAuthGuard.prototype), 'canActivate')
.mockReturnValue(true);
const result = guard.canActivate(createContext());
expect(spy).toHaveBeenCalled();
expect(result).toBe(true);
spy.mockRestore();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { SwaggerModule } from '@nestjs/swagger';
import { AppController } from '../../app.controller';
import { AppService } from '../../app.service';
import { AuthController } from '../../modules/auth/auth.controller';
import { AuthService } from '../../modules/auth/auth.service';
import {
createOpenApiDocument,
isSwaggerEnabled,
setupSwagger,
} from './setup-swagger';
describe('isSwaggerEnabled', () => {
it('is disabled in production by default', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'production' })).toBe(false);
});
it('is enabled in production when SWAGGER_ENABLED=true', () => {
expect(
isSwaggerEnabled({
NODE_ENV: 'production',
SWAGGER_ENABLED: 'true',
}),
).toBe(true);
});
it('is enabled when not production', () => {
expect(isSwaggerEnabled({ NODE_ENV: 'development' })).toBe(true);
});
it('is disabled when SWAGGER_ENABLED=false', () => {
expect(
isSwaggerEnabled({
NODE_ENV: 'development',
SWAGGER_ENABLED: 'false',
}),
).toBe(false);
});
});
describe('createOpenApiDocument', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController, AuthController],
providers: [
AppService,
{
provide: AuthService,
useValue: {
register: jest.fn(),
login: jest.fn(),
refresh: jest.fn(),
revoke: jest.fn(),
},
},
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('includes auth and app paths with bearer scheme', () => {
const document = createOpenApiDocument(app);
expect(document.info.title).toBe('Tracking API');
expect(document.paths['/auth/login']).toBeDefined();
expect(document.paths['/auth/me']).toBeDefined();
expect(document.paths['/']).toBeDefined();
expect(document.components?.securitySchemes?.['access-token']).toEqual(
expect.objectContaining({ type: 'http', scheme: 'bearer' }),
);
});
it('requires bearer on /auth/me but not on public auth posts', () => {
const document = createOpenApiDocument(app);
expect(document.paths['/auth/me']?.get?.security).toEqual([
{ 'access-token': [] },
]);
expect(document.paths['/auth/login']?.post?.security).toBeUndefined();
expect(document.paths['/auth/register']?.post?.security).toBeUndefined();
});
});
describe('setupSwagger', () => {
let app: INestApplication;
let setupSpy: jest.SpyInstance;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
setupSpy = jest
.spyOn(SwaggerModule, 'setup')
.mockImplementation(() => undefined);
});
afterEach(() => {
setupSpy.mockRestore();
});
it('skips mounting when swagger is disabled', () => {
setupSwagger(app, {
NODE_ENV: 'production',
});
expect(setupSpy).not.toHaveBeenCalled();
});
it('mounts at /docs when swagger is enabled', () => {
setupSwagger(app, {
NODE_ENV: 'test',
});
expect(setupSpy).toHaveBeenCalledWith(
'docs',
app,
expect.any(Object),
expect.objectContaining({ jsonDocumentUrl: 'docs-json' }),
);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { OpenAPIObject } from '@nestjs/swagger';
export const SWAGGER_PATH = 'docs';
export const SWAGGER_JSON_PATH = 'docs-json';
export const BEARER_AUTH_NAME = 'access-token';
export function isSwaggerEnabled(
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (env.SWAGGER_ENABLED === 'true') {
return true;
}
if (env.SWAGGER_ENABLED === 'false') {
return false;
}
return env.NODE_ENV !== 'production';
}
export function createOpenApiDocument(app: INestApplication): OpenAPIObject {
const config = new DocumentBuilder()
.setTitle('Tracking API')
.setDescription('HTTP API for the Tracking service')
.setVersion('0.0.1')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Access token from login or register',
},
BEARER_AUTH_NAME,
)
.build();
return SwaggerModule.createDocument(app, config);
}
export function setupSwagger(
app: INestApplication,
env: NodeJS.ProcessEnv = process.env,
): void {
if (!isSwaggerEnabled(env)) {
return;
}
const document = createOpenApiDocument(app);
SwaggerModule.setup(SWAGGER_PATH, app, document, {
jsonDocumentUrl: SWAGGER_JSON_PATH,
});
}