Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
636 changes: 559 additions & 77 deletions backend/package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@
"@nestjs/swagger": "^11.0.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@prisma/client": "^6.1.0",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"express-prom-bundle": "^8.0.0",
"helmet": "^8.0.0",
"nest-winston": "^1.9.7",
"pg": "^8.13.1",
"prisma": "^6.1.0",
"pg": "^8.16.3",
"prisma": "^7.1.0",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rimraf": "^6.0.1",
Expand Down
10 changes: 10 additions & 0 deletions backend/prisma/prisma.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "prisma/config";
import { getConnectionString } from "../src/database.config";

export default defineConfig({
datasources: {
db: {
url: getConnectionString(),
},
},
});
2 changes: 0 additions & 2 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["metrics"]
binaryTargets = ["native", "debian-openssl-3.0.x"]
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model users {
Expand Down
32 changes: 32 additions & 0 deletions backend/src/database.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const DB_HOST = process.env.POSTGRES_HOST || "localhost";
export const DB_USER = process.env.POSTGRES_USER || "postgres";
export const DB_PWD = encodeURIComponent(
process.env.POSTGRES_PASSWORD || "default",
); // this needs to be encoded, if the password contains special characters it will break connection string.
export const DB_PORT = process.env.POSTGRES_PORT || 5432;
export const DB_NAME = process.env.POSTGRES_DATABASE || "postgres";
export const DB_SCHEMA = process.env.POSTGRES_SCHEMA || "app";
export const DB_POOL_SIZE = parseInt(process.env.POSTGRES_POOL_SIZE || "5", 10);
export const DB_POOL_IDLE_TIMEOUT = parseInt(
process.env.POSTGRES_POOL_IDLE_TIMEOUT || "30000",
10,
);
export const DB_POOL_CONNECTION_TIMEOUT = parseInt(
process.env.POSTGRES_POOL_CONNECTION_TIMEOUT || "2000",
10,
);

// SSL settings for PostgreSQL 17+ which requires SSL by default
// Use 'prefer' for localhost or non-production environments, 'require' for production AWS deployments
const isLocalhost =
DB_HOST === "localhost" || DB_HOST === "127.0.0.1" || DB_HOST === "database";
const isProduction = process.env.NODE_ENV === "production";
const SSL_MODE = isLocalhost || !isProduction ? "prefer" : "require";

/**
* Constructs the PostgreSQL connection string with appropriate SSL mode and schema.
* Note: connection_limit is not included as pool size is managed by pg.Pool's max option.
*/
export function getConnectionString(): string {
return `postgresql://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&sslmode=${SSL_MODE}`;
}
56 changes: 56 additions & 0 deletions backend/src/metrics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Test, TestingModule } from "@nestjs/testing";
import { MetricsController } from "./metrics.controller";
import { Response } from "express";
import { register } from "./middleware/prom";

describe("MetricsController", () => {
let controller: MetricsController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MetricsController],
}).compile();

controller = module.get<MetricsController>(MetricsController);
});

it("should be defined", () => {
expect(controller).toBeDefined();
});

describe("getMetrics", () => {
it("should return application metrics", async () => {
// Arrange
const mockMetrics =
'# HELP nodejs_version_info Node.js version info\n# TYPE nodejs_version_info gauge\nnodejs_version_info{version="v24.11.1",major="24",minor="11",patch="1"} 1\n';
const mockResponse = {
end: vi.fn(),
} as unknown as Response;

vi.spyOn(register, "metrics").mockResolvedValue(mockMetrics);

// Act
await controller.getMetrics(mockResponse);

// Assert
expect(register.metrics).toHaveBeenCalled();
expect(mockResponse.end).toHaveBeenCalledWith(mockMetrics);
});

it("should handle empty metrics", async () => {
// Arrange
const mockResponse = {
end: vi.fn(),
} as unknown as Response;

vi.spyOn(register, "metrics").mockResolvedValue("");

// Act
await controller.getMetrics(mockResponse);

// Assert
expect(register.metrics).toHaveBeenCalled();
expect(mockResponse.end).toHaveBeenCalledWith("");
});
});
});
6 changes: 1 addition & 5 deletions backend/src/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { Controller, Get, Res } from "@nestjs/common";
import { Response } from "express";
import { register } from "src/middleware/prom";
import { PrismaService } from "src/prisma.service";
@Controller("metrics")
export class MetricsController {
constructor(private prisma: PrismaService) {}

@Get()
async getMetrics(@Res() res: Response) {
const prismaMetrics = await this.prisma.$metrics.prometheus();
const appMetrics = await register.metrics();
res.end(prismaMetrics + appMetrics);
res.end(appMetrics);
}
}
86 changes: 86 additions & 0 deletions backend/src/prisma.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaService } from "./prisma.service";

// Mock the database config module
vi.mock("src/database.config", () => ({
getConnectionString: vi.fn(
() => "postgresql://test:test@localhost:5432/test",
),
DB_POOL_SIZE: 5,
DB_POOL_IDLE_TIMEOUT: 30000,
DB_POOL_CONNECTION_TIMEOUT: 2000,
}));

// Mock the pg module
vi.mock("pg", () => {
const mockPool = {
end: vi.fn().mockResolvedValue(undefined),
};
return {
Pool: vi.fn(function () {
return mockPool;
}),
};
});

// Mock @prisma/adapter-pg
vi.mock("@prisma/adapter-pg", () => ({
PrismaPg: vi.fn(function () {
return {
provider: "postgres",
adapterName: "pg",
};
}),
}));

describe("PrismaService", () => {
let service: PrismaService;

beforeEach(async () => {
// Clear all mocks before each test
vi.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [PrismaService],
}).compile();

service = module.get<PrismaService>(PrismaService);
});

it("should be defined", () => {
expect(service).toBeDefined();
});

describe("onModuleInit", () => {
it("should connect to the database", async () => {
// Arrange
const connectSpy = vi
.spyOn(service, "$connect")
.mockResolvedValue(undefined);
const onSpy = vi.spyOn(service, "$on").mockImplementation(() => {});

// Act
await service.onModuleInit();

// Assert
expect(connectSpy).toHaveBeenCalled();
expect(onSpy).toHaveBeenCalledWith("query", expect.any(Function));
});
});

describe("onModuleDestroy", () => {
it("should disconnect from the database and close the pool", async () => {
// Arrange
const disconnectSpy = vi
.spyOn(service, "$disconnect")
.mockResolvedValue(undefined);

// Act
await service.onModuleDestroy();

// Assert
expect(disconnectSpy).toHaveBeenCalled();
expect(service["pool"].end).toHaveBeenCalled();
});
});
});
52 changes: 25 additions & 27 deletions backend/src/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,44 @@ import {
Scope,
} from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";

const DB_HOST = process.env.POSTGRES_HOST || "localhost";
const DB_USER = process.env.POSTGRES_USER || "postgres";
const DB_PWD = encodeURIComponent(process.env.POSTGRES_PASSWORD || "default"); // this needs to be encoded, if the password contains special characters it will break connection string.
const DB_PORT = process.env.POSTGRES_PORT || 5432;
const DB_NAME = process.env.POSTGRES_DATABASE || "postgres";
const DB_SCHEMA = process.env.POSTGRES_SCHEMA || "app";
const DB_POOL_SIZE = parseInt(process.env.POSTGRES_POOL_SIZE || "5", 10);
// SSL settings for PostgreSQL 17+ which requires SSL by default
// Use 'prefer' for localhost or non-production environments, 'require' for production AWS deployments
const isLocalhost =
DB_HOST === "localhost" || DB_HOST === "127.0.0.1" || DB_HOST === "database";
const isProduction = process.env.NODE_ENV === "production";
const SSL_MODE = isLocalhost || !isProduction ? "prefer" : "require";
const dataSourceURL = `postgresql://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connection_limit=${DB_POOL_SIZE}&sslmode=${SSL_MODE}`;
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import {
getConnectionString,
DB_POOL_SIZE,
DB_POOL_IDLE_TIMEOUT,
DB_POOL_CONNECTION_TIMEOUT,
} from "src/database.config";

@Injectable({ scope: Scope.DEFAULT })
class PrismaService
extends PrismaClient<Prisma.PrismaClientOptions, "query">
implements OnModuleInit, OnModuleDestroy
{
private static instance: PrismaService;
private logger = new Logger("PRISMA");
private pool: Pool;

constructor() {
if (PrismaService.instance) {
console.log("Returning existing PrismaService instance");
return PrismaService.instance;
}
// Create pg connection pool with configuration
const pool = new Pool({
connectionString: getConnectionString(),
max: DB_POOL_SIZE,
idleTimeoutMillis: DB_POOL_IDLE_TIMEOUT,
connectionTimeoutMillis: DB_POOL_CONNECTION_TIMEOUT,
});
const adapter = new PrismaPg(pool);

super({
errorFormat: "pretty",
datasources: {
db: {
url: dataSourceURL,
},
},
adapter,
log: [
{ emit: "event", level: "query" },
{ emit: "stdout", level: "info" },
{ emit: "stdout", level: "warn" },
{ emit: "stdout", level: "error" },
],
});
PrismaService.instance = this;
this.pool = pool;
}

async onModuleInit() {
Expand All @@ -64,7 +58,11 @@ class PrismaService
}

async onModuleDestroy() {
await this.$disconnect();
try {
await this.$disconnect();
} finally {
await this.pool.end();
}
}
}

Expand Down
Loading