refactoring, added s3Service.ts, add more depenedncy injection, fix tests as always i geuss

This commit is contained in:
2025-09-24 23:55:05 +02:00
parent b590c9485b
commit d3cbaea742
18 changed files with 2593 additions and 196 deletions
+2232
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,6 +16,8 @@
"license": "MIT",
"description": "",
"dependencies": {
"@aws-sdk/client-s3": "^3.895.0",
"@aws-sdk/s3-request-presigner": "^3.895.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
+1 -1
View File
@@ -4,7 +4,7 @@ import {appEventBus, USER_UPDATED_EVENT} from "../../utils/eventBus";
export function watchUserChanges() {
const changeStream = UserModel.watch([], { fullDocument: 'updateLookup' });
changeStream.on('change', (change) => {
changeStream.on('change', (change: any) => {
if (change.operationType === 'update' && change.fullDocument) {
const updatedUser = change.fullDocument;
+73 -6
View File
@@ -1,9 +1,27 @@
import { Server } from "./server";
import { config as baseConfig} from "./config";
import {Server} from "./server";
import {config as baseConfig} from "./config/config";
import {S3Service} from "./services/s3Service";
import {UserService} from "./services/db/UserService";
import {SpotifyTokenService} from "./services/spotifyTokenService";
import {connectToDatabase} from "./services/db/database.service";
import {SpotifyApiService} from "./services/spotifyApiService";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
async function bootstrap() {
const { SECRET_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } = process.env;
const {
SECRET_KEY,
SPOTIFY_CLIENT_ID,
SPOTIFY_CLIENT_SECRET,
MINIO_ENDPOINT,
MINIO_PORT,
MINIO_BUCKET_NAME,
MINIO_ROOT_USER,
MINIO_ROOT_PASSWORD,
DB_NAME,
DB_CONN_STRING
} = process.env;
if (!SECRET_KEY || SECRET_KEY.length < 32) {
throw new Error("CRITICAL ERROR: SECRET_KEY environment variable is not set or too short.");
@@ -15,12 +33,61 @@ async function bootstrap() {
throw new Error("CRITICAL ERROR: SPOTIFY_CLIENT_SECRET environment variable is not set.");
}
if (!MINIO_ENDPOINT || !MINIO_PORT) {
throw new Error("MINIO_ENDPOINT and/or MINIO_PORT environment variable is not set.");
}
if (!MINIO_ROOT_USER || !MINIO_ROOT_PASSWORD) {
throw new Error("MINIO_ROOT_USER and/or MINIO_ROOT_PASSWORD environment variable is not set.");
}
if (!MINIO_BUCKET_NAME) {
throw new Error("MINIO_BUCKET_NAME environment variable is not set.");
}
if (!DB_NAME || !DB_CONN_STRING) {
throw new Error("DB_NAME and/or DB_CONN_STRING environment variable is not set.");
}
const s3ClientConfig = {
endpoint: MINIO_ENDPOINT,
port: parseInt(MINIO_PORT),
accessKey: MINIO_ROOT_USER,
secretAccessKey: MINIO_ROOT_PASSWORD,
bucket: MINIO_BUCKET_NAME
};
const dbConfig = {
dbName: DB_NAME,
dbConnString: DB_CONN_STRING
}
await connectToDatabase(dbConfig.dbName, dbConfig.dbConnString);
const s3Service = S3Service.getInstance(s3ClientConfig);
const userService = await UserService.create();
const spotifyTokenService = new SpotifyTokenService(
SPOTIFY_CLIENT_ID!,
SPOTIFY_CLIENT_SECRET!
);
const spotifyApiService = new SpotifyApiService();
const spotifyPollingService = new SpotifyPollingService(userService, spotifyApiService, spotifyTokenService);
const weatherPollingService = new WeatherPollingService();
const jwtAuthenticator = new JwtAuthenticator(SECRET_KEY);
const server = new Server({
port: baseConfig.port,
jwtSecret: SECRET_KEY,
spotifyClientId: SPOTIFY_CLIENT_ID,
spotifyClientSecret: SPOTIFY_CLIENT_SECRET,
cors: baseConfig.cors,
}, {
s3Service,
userService,
spotifyTokenService,
spotifyPollingService,
weatherPollingService,
jwtAuthenticator
});
await server.start();
+4 -3
View File
@@ -10,9 +10,11 @@ import {UserService} from "../services/db/UserService";
export class RestAuth {
private readonly userService: UserService;
private readonly jwtAuthenticator: JwtAuthenticator;
constructor(userService: UserService) {
constructor(userService: UserService, jwtAuthenticator: JwtAuthenticator) {
this.userService = userService;
this.jwtAuthenticator = jwtAuthenticator;
}
public createRouter() {
@@ -81,8 +83,7 @@ export class RestAuth {
return unauthorized(res, "Invalid password", {field: "password", code: "INVALID_PASSWORD"});
}
const jwtToken = new JwtAuthenticator(process.env.SECRET_KEY!)
.generateToken({
const jwtToken = this.jwtAuthenticator.generateToken({
username: user.name,
id: user.id,
uuid: user.uuid
+44 -35
View File
@@ -1,32 +1,39 @@
import express, { Express, Request, Response, NextFunction } from "express";
import { Server as HttpServer } from "http";
import express, {Express, Request, Response, NextFunction} from "express";
import {Server as HttpServer} from "http";
import cors from "cors";
import cookieParser from 'cookie-parser';
import { randomUUID } from "crypto";
import {randomUUID} from "crypto";
import { ExtendedWebSocketServer } from "./websocket";
import { RestWebSocket } from "./rest/restWebSocket";
import { RestUser } from "./rest/restUser";
import { JwtTokenPropertiesExtractor } from "./rest/jwtTokenPropertiesExtractor";
import { SpotifyTokenGenerator } from "./rest/spotifyTokenGenerator";
import { RestAuth } from "./rest/auth";
import { authLimiter, spotifyLimiter } from "./rest/middleware/rateLimit";
import { extractTokenFromCookie } from "./rest/middleware/extractTokenFromCookie";
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
import { authenticateJwt } from "./rest/middleware/authenticateJwt";
import {ExtendedWebSocketServer} from "./websocket";
import {RestWebSocket} from "./rest/restWebSocket";
import {RestUser} from "./rest/restUser";
import {JwtTokenPropertiesExtractor} from "./rest/jwtTokenPropertiesExtractor";
import {SpotifyTokenGenerator} from "./rest/spotifyTokenGenerator";
import {RestAuth} from "./rest/auth";
import {authLimiter, spotifyLimiter} from "./rest/middleware/rateLimit";
import {extractTokenFromCookie} from "./rest/middleware/extractTokenFromCookie";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
import {authenticateJwt} from "./rest/middleware/authenticateJwt";
import {watchUserChanges} from "./db/models/userWatch";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {SpotifyApiService} from "./services/spotifyApiService";
import {UserService} from "./services/db/UserService";
import {connectToDatabase, disconnectFromDatabase} from "./services/db/database.service";
import {disconnectFromDatabase} from "./services/db/database.service";
import {SpotifyTokenService} from "./services/spotifyTokenService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {S3Service} from "./services/s3Service";
interface ServerDependencies {
userService: UserService;
s3Service: S3Service;
spotifyTokenService: SpotifyTokenService;
spotifyPollingService: SpotifyPollingService;
weatherPollingService: WeatherPollingService;
jwtAuthenticator: JwtAuthenticator;
}
interface ServerConfig {
port: number;
jwtSecret: string;
spotifyClientId: string;
spotifyClientSecret: string;
cors: {
origin: string | string[];
credentials: boolean;
@@ -36,34 +43,36 @@ interface ServerConfig {
export class Server {
public readonly app: Express;
private httpServer: HttpServer | null = null;
private userService: UserService | null = null;
private webSocketServer: ExtendedWebSocketServer | null = null;
constructor(private readonly config: ServerConfig) {
constructor(private readonly config: ServerConfig,
private readonly dependencies: ServerDependencies) {
this.app = express();
}
public async start(): Promise<HttpServer> {
await connectToDatabase();
const {
userService,
s3Service,
spotifyTokenService,
spotifyPollingService,
weatherPollingService,
jwtAuthenticator
} = this.dependencies;
await s3Service.ensureBucketExists()
watchUserChanges();
this.userService = await UserService.create();
const spotifyTokenService = new SpotifyTokenService(this.config.spotifyClientId, this.config.spotifyClientSecret);
const spotifyApiService = new SpotifyApiService();
const spotifyPollingService = new SpotifyPollingService(this.userService, spotifyApiService, spotifyTokenService);
const weatherPollingService = new WeatherPollingService();
this._setupMiddleware();
this._setupRoutes(this.userService, spotifyTokenService);
this._setupRoutes(userService, spotifyTokenService, jwtAuthenticator);
this._setupErrorHandling();
this.httpServer = this.app.listen(this.config.port, () => {
console.log(`Server is running on port ${this.config.port}`);
});
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, this.userService, spotifyPollingService, weatherPollingService);
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, userService, spotifyPollingService, weatherPollingService, jwtAuthenticator);
this._setupGracefulShutdown();
@@ -88,18 +97,18 @@ export class Server {
credentials: this.config.cors.credentials,
}));
this.app.use(this._securityHeaders);
this.app.use(express.json({ limit: "2mb" }));
this.app.use(express.json({limit: "2mb"}));
}
private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService): void {
const _authenticateJwt = authenticateJwt(new JwtAuthenticator(this.config.jwtSecret));
private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService, jwtAuthenticator: JwtAuthenticator): void {
const _authenticateJwt = authenticateJwt(jwtAuthenticator);
const restAuth = new RestAuth(userService);
const restAuth = new RestAuth(userService, jwtAuthenticator);
const restUser = new RestUser(userService);
const spotifyTokenGenerator = new SpotifyTokenGenerator(spotifyTokenService);
const jwtTokenExtractor = new JwtTokenPropertiesExtractor();
this.app.get("/api/healthz", (_req, res) => res.status(200).send({ status: "ok" }));
this.app.get("/api/healthz", (_req, res) => res.status(200).send({status: "ok"}));
this.app.use("/api/auth", authLimiter, restAuth.createRouter());
@@ -148,7 +157,7 @@ export class Server {
ok: false,
data: {
error: errorMessage,
...(statusCode >= 500 && { errorId: errorId }),
...(statusCode >= 500 && {errorId: errorId}),
},
});
});
+4 -14
View File
@@ -4,17 +4,7 @@ import mongoose, { ConnectOptions } from "mongoose";
let isConnected: boolean = false;
let connectionPromise: Promise<void> | null = null;
const connectWithRetry = async (): Promise<void> => {
const dbConnString: string | undefined = process.env.DB_CONN_STRING;
const dbName: string | undefined = process.env.DB_NAME;
if (!dbConnString) {
throw new Error("Missing environment variable: DB_CONN_STRING is required for database connection.");
}
if (!dbName) {
throw new Error("Missing environment variable: DB_NAME is required for database connection.");
}
const connectWithRetry = async (dbName: string, dbConnString: string): Promise<void> => {
const options: ConnectOptions = {
dbName: dbName,
serverSelectionTimeoutMS: 5000,
@@ -29,11 +19,11 @@ const connectWithRetry = async (): Promise<void> => {
} catch (error) {
console.error("Failed to connect to MongoDB. Retrying in 5 seconds...", error);
await new Promise<void>(resolve => setTimeout(resolve, 5000));
return connectWithRetry();
return connectWithRetry(dbName, dbConnString);
}
};
export async function connectToDatabase(): Promise<void> {
export async function connectToDatabase(dbName: string, dbConnString: string): Promise<void> {
if (connectionPromise) {
return connectionPromise;
}
@@ -59,7 +49,7 @@ export async function connectToDatabase(): Promise<void> {
console.error('Mongoose connection error:', err);
});
await connectWithRetry();
await connectWithRetry(dbName, dbConnString);
})();
return connectionPromise;
+85
View File
@@ -0,0 +1,85 @@
import {
S3Client,
CreateBucketCommand,
PutObjectCommand,
GetObjectCommand
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from 'crypto';
export interface S3ClientConfig {
endpoint: string;
port: number;
accessKey: string;
secretAccessKey: string;
bucket: string;
region?: string;
}
export class S3Service {
private static instance: S3Service;
private readonly client: S3Client;
private readonly bucketName: string;
private constructor(clientConfig: S3ClientConfig) {
this.client = new S3Client({
endpoint: `${clientConfig.endpoint}:${clientConfig.port}`,
forcePathStyle: true,
region: clientConfig.region || "us-east-1",
credentials: {
accessKeyId: clientConfig.accessKey,
secretAccessKey: clientConfig.secretAccessKey,
},
});
this.bucketName = clientConfig.bucket;
}
public static getInstance(config?: S3ClientConfig): S3Service {
if (!this.instance) {
if (!config) {
throw new Error("S3Service must be initialized with a config on first use.");
}
this.instance = new S3Service(config);
}
return this.instance;
}
async ensureBucketExists(): Promise<void> {
try {
await this.client.send(new CreateBucketCommand({ Bucket: this.bucketName }));
console.log(`Bucket "${this.bucketName}" created successfully or already existed.`);
} catch (err: any) {
if (err.name === 'BucketAlreadyOwnedByYou' || err.name === 'BucketAlreadyExists') {
console.log(`Bucket "${this.bucketName}" already exists.`);
} else {
throw err;
}
}
}
async uploadFile(file: Express.Multer.File, userId: string): Promise<string> {
const fileExtension = file.originalname.split('.').pop();
const objectKey = `user-${userId}/${randomUUID()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: objectKey,
Body: file.buffer,
ContentType: file.mimetype,
});
await this.client.send(command);
return objectKey;
}
async getSignedDownloadUrl(objectKey: string, expiresIn: number = 60): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: objectKey,
});
return await getSignedUrl(this.client, command, { expiresIn });
}
}
+20 -5
View File
@@ -1,9 +1,24 @@
import { DecodedToken } from "../interfaces/decodedToken";
import {DecodedToken} from "../interfaces/decodedToken";
declare global {
declare namespace Express {
export interface Request {
payload: DecodedToken;
declare namespace Express {
export interface Request {
payload: DecodedToken;
file?: Multer.File;
}
namespace Multer {
export interface File {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
destination: string;
filename: string;
path: string;
buffer: Buffer;
}
}
}
}
}
+1 -4
View File
@@ -6,12 +6,9 @@ import {JwtAuthenticator} from "./jwtAuthenticator";
export function verifyClient(
request: IncomingMessage,
jwtAuthenticator: JwtAuthenticator,
callback: (res: boolean, code?: number, message?: string) => void,
) {
const jwtAuthenticator = new JwtAuthenticator(
process.env.SECRET_KEY as string,
);
const token = jwtAuthenticator.verifyToken(request.headers["authorization"]?.slice("Bearer ".length));
if (!token) {
reject(request, callback);
+4 -2
View File
@@ -15,6 +15,7 @@ import {IUser} from "./db/models/user";
import {SpotifyPollingService} from "./services/spotifyPollingService";
import {UserService} from "./services/db/UserService";
import {WeatherPollingService} from "./services/weatherPollingService";
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
export class ExtendedWebSocketServer {
private readonly _wss: WebSocketServer;
@@ -22,14 +23,15 @@ export class ExtendedWebSocketServer {
private readonly spotifyPollingService: SpotifyPollingService;
private readonly weatherPollingService: WeatherPollingService;
constructor(server: Server, userService: UserService, spotifyPollingService: SpotifyPollingService, weatherPollingService: WeatherPollingService) {
constructor(server: Server, userService: UserService, spotifyPollingService: SpotifyPollingService,
weatherPollingService: WeatherPollingService, jwtAuthenticator: JwtAuthenticator) {
this.userService = userService;
this.spotifyPollingService = spotifyPollingService;
this.weatherPollingService = weatherPollingService;
this._wss = new WebSocketServer({
server,
verifyClient: (info, callback) => verifyClient(info.req, callback),
verifyClient: (info, callback) => verifyClient(info.req, jwtAuthenticator, callback),
});
this._setupConnectionHandling();
+1 -1
View File
@@ -4,7 +4,7 @@ const loadConfigWithEnv = async (envVars: Record<string, string | undefined>) =>
for (const key in envVars) {
vi.stubEnv(key, envVars[key] as string);
}
const { config } = await import("../src/config");
const { config } = await import("../src/config/config");
return config;
};
+2 -1
View File
@@ -35,6 +35,7 @@ describe("RestAuth", () => {
let mockJwtAuthenticator: any;
let mockCrypto: any;
beforeEach(() => {
vi.clearAllMocks();
@@ -46,7 +47,7 @@ describe("RestAuth", () => {
mockJwtAuthenticator = createMockJwtAuthenticator();
vi.mocked(JwtAuthenticator).mockImplementation(() => mockJwtAuthenticator);
const restAuth = new RestAuth(mockUserService);
const restAuth = new RestAuth(mockUserService, mockJwtAuthenticator);
app = createPublicTestApp(restAuth.createRouter(), "/auth");
process.env.SECRET_KEY = "test-secret-key";
+37 -13
View File
@@ -4,15 +4,33 @@ import { Server } from "../src/server";
import { Router, type Request, type Response, type NextFunction } from "express"; // Import Express types
import type { Express } from "express";
import { authLimiter } from "../src/rest/middleware/rateLimit";
import {
createMockJwtAuthenticator,
createMockSpotifyPollingService,
createMockSpotifyTokenService,
createMockUserService
} from "./helpers/testSetup";
const mockS3Service = {
ensureBucketExists: vi.fn().mockResolvedValue(undefined),
uploadFile: vi.fn(),
getSignedDownloadUrl: vi.fn(),
} as any;
const mockUserService = createMockUserService() as any;
const mockSpotifyTokenService = createMockSpotifyTokenService() as any;
const mockSpotifyPollingService = createMockSpotifyPollingService() as any;
const mockWeatherPollingService = {
subscribeUser: vi.fn(),
unsubscribeUser: vi.fn()
} as any;
const mockJwtAuthenticator = createMockJwtAuthenticator() as any;
vi.mock("../src/services/db/database.service", () => ({
connectToDatabase: vi.fn().mockResolvedValue(undefined),
disconnectFromDatabase: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../src/services/db/UserService", () => ({
UserService: { create: vi.fn().mockResolvedValue({}) },
}));
vi.mock("../src/services/spotifyTokenService", () => ({ SpotifyTokenService: vi.fn() }));
vi.mock("../src/websocket", () => ({
ExtendedWebSocketServer: vi.fn().mockImplementation(() => {
@@ -29,12 +47,6 @@ vi.mock("../src/rest/middleware/rateLimit", async (importOriginal) => {
};
});
vi.mock("../src/rest/middleware/authenticateJwt", () => ({
authenticateJwt: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
res.status(401).json({ error: "Unauthorized" });
}),
}));
vi.mock("../src/rest/auth", () => {
const MockRestAuth = vi.fn().mockImplementation(() => {
return {
@@ -59,8 +71,6 @@ vi.mock("../src/rest/auth", () => {
const mockServerConfig = {
port: 8888,
jwtSecret: "a-very-secure-test-secret-that-is-at-least-32-chars-long",
spotifyClientId: "test-id",
spotifyClientSecret: "test-secret",
cors: {
origin: "http://test-origin.com",
credentials: true,
@@ -73,7 +83,14 @@ describe("Server Class Integration Tests", () => {
beforeEach(async () => {
vi.clearAllMocks();
server = new Server(mockServerConfig);
server = new Server(mockServerConfig, {
s3Service: mockS3Service,
userService: mockUserService,
spotifyTokenService: mockSpotifyTokenService,
spotifyPollingService: mockSpotifyPollingService,
weatherPollingService: mockWeatherPollingService,
jwtAuthenticator: mockJwtAuthenticator,
});
await server.start();
app = server.app;
});
@@ -82,6 +99,13 @@ describe("Server Class Integration Tests", () => {
await server.stop();
});
describe('Server Startup', () => {
it('should call ensureBucketExists on S3Service during startup', () => {
expect(mockS3Service.ensureBucketExists).toHaveBeenCalledOnce();
});
});
describe("Server Setup and Middleware", () => {
it("should start and respond to the healthz endpoint", async () => {
const response = await request(app).get("/api/healthz").expect(200);
+57 -83
View File
@@ -1,110 +1,84 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import mongoose from "mongoose";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import mongoose from 'mongoose';
const MODULE_PATH = "../../../src/services/db/database.service";
type SpyInstance<T extends (...args: any) => any> = ReturnType<typeof vi.spyOn<any, Parameters<T>[0]>>;
vi.mock("mongoose", async (importOriginal) => {
vi.mock('mongoose', async (importOriginal) => {
const originalMongoose = await importOriginal<typeof mongoose>();
const mockConnection = {
on: vi.fn(),
};
return {
...originalMongoose,
default: {
...originalMongoose.default,
connect: vi.fn(),
disconnect: vi.fn(),
connection: mockConnection,
connection: {
on: vi.fn(),
},
},
};
});
vi.mock("dotenv/config", () => ({}));
const mockedMongooseConnect = vi.mocked(mongoose.connect);
const mockedMongooseDisconnect = vi.mocked(mongoose.disconnect);
const mockedConnectionOn = vi.mocked(mongoose.connection.on);
describe("database.service", () => {
let consoleLogSpy: SpyInstance<typeof console.log>;
let consoleErrorSpy: SpyInstance<typeof console.error>;
beforeEach(() => {
describe('database.service', () => {
let connectToDatabase: any;
let disconnectFromDatabase: any;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.stubEnv('DB_CONN_STRING', 'mongodb://test-host/testdb');
vi.stubEnv('DB_NAME', 'testdb');
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const dbService = await import('../../../src/services/db/database.service');
connectToDatabase = dbService.connectToDatabase;
disconnectFromDatabase = dbService.disconnectFromDatabase;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("connectToDatabase", () => {
it("should throw error,when DB_CONN_STRING is not set", async () => {
vi.unstubAllEnvs();
vi.stubEnv('DB_NAME', 'testdb');
const { connectToDatabase } = await import(MODULE_PATH);
const TEST_DB_NAME = 'testdb';
const TEST_DB_CONN_STRING = 'mongodb://test-host/testdb';
await expect(connectToDatabase()).rejects.toThrow(
"Missing environment variable: DB_CONN_STRING is required for database connection."
);
describe('connectToDatabase', () => {
it('should attempt to connect to MongoDB with correct options', async () => {
mockedMongooseConnect.mockResolvedValue(undefined as any);
await connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
expect(mockedMongooseConnect).toHaveBeenCalledOnce();
expect(mockedMongooseConnect).toHaveBeenCalledWith(TEST_DB_CONN_STRING, expect.objectContaining({
dbName: TEST_DB_NAME,
family: 4,
}));
});
it("should throw error, when DB_NAME is not set", async () => {
vi.unstubAllEnvs();
vi.stubEnv('DB_CONN_STRING', 'mongodb://test-host/testdb');
const { connectToDatabase } = await import(MODULE_PATH);
it('should correctly set up event listeners on the connection', async () => {
mockedMongooseConnect.mockResolvedValue(undefined as any);
await expect(connectToDatabase()).rejects.toThrow(
"Missing environment variable: DB_NAME is required for database connection."
);
});
it("should connect successfully first try", async () => {
mockedMongooseConnect.mockResolvedValueOnce(undefined as any);
const { connectToDatabase } = await import(MODULE_PATH);
await connectToDatabase();
expect(mockedMongooseConnect).toHaveBeenCalledTimes(1);
expect(mockedMongooseConnect).toHaveBeenCalledWith('mongodb://test-host/testdb', expect.any(Object));
expect(consoleLogSpy).toHaveBeenCalledWith("Attempting to connect to MongoDB...");
});
it("should configure event-listeners", async () => {
mockedMongooseConnect.mockResolvedValueOnce(undefined as any);
const { connectToDatabase } = await import(MODULE_PATH);
await connectToDatabase();
await connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
expect(mockedConnectionOn).toHaveBeenCalledWith('connected', expect.any(Function));
expect(mockedConnectionOn).toHaveBeenCalledWith('disconnected', expect.any(Function));
expect(mockedConnectionOn).toHaveBeenCalledWith('error', expect.any(Function));
expect(mockedConnectionOn).toHaveBeenCalledTimes(3);
});
describe("Singleton", () => {
it("should try to connect once, even if called multiple times", async () => {
mockedMongooseConnect.mockResolvedValue(undefined as any);
const { connectToDatabase } = await import(MODULE_PATH);
it('should only attempt to connect once when called multiple times (singleton pattern)', async () => {
mockedMongooseConnect.mockResolvedValue(undefined as any);
const promise1 = connectToDatabase();
const promise2 = connectToDatabase();
// Rufe die Funktion mehrmals parallel auf
const promise1 = connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
const promise2 = connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
await Promise.all([promise1, promise2]);
await Promise.all([promise1, promise2]);
expect(mockedMongooseConnect).toHaveBeenCalledTimes(1);
});
expect(mockedMongooseConnect).toHaveBeenCalledOnce();
});
describe("Retry Logic", () => {
describe('Retry Logic', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -112,18 +86,19 @@ describe("database.service", () => {
vi.useRealTimers();
});
it("should retry after 5 seconds when first time fails", async () => {
const connectionError = new Error("DB not ready");
it('should retry connecting after a 5-second delay if the first attempt fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const connectionError = new Error('Database unavailable');
mockedMongooseConnect
.mockRejectedValueOnce(connectionError)
.mockResolvedValueOnce(undefined as any);
const { connectToDatabase } = await import(MODULE_PATH);
const connectionPromise = connectToDatabase();
const connectionPromise = connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
await vi.advanceTimersByTimeAsync(1);
await vi.runAllTicks();
expect(mockedMongooseConnect).toHaveBeenCalledTimes(1);
expect(mockedMongooseConnect).toHaveBeenCalledOnce();
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to connect to MongoDB. Retrying in 5 seconds...", connectionError);
await vi.advanceTimersByTimeAsync(5000);
@@ -131,32 +106,31 @@ describe("database.service", () => {
expect(mockedMongooseConnect).toHaveBeenCalledTimes(2);
await expect(connectionPromise).resolves.toBeUndefined();
consoleErrorSpy.mockRestore();
});
});
});
describe("disconnectFromDatabase", () => {
it("should call mongoose.disconnect, when connection is established", async () => {
describe('disconnectFromDatabase', () => {
it('should call mongoose.disconnect if the connection is established', async () => {
mockedMongooseConnect.mockResolvedValue(undefined as any);
mockedMongooseDisconnect.mockResolvedValue(undefined as any);
const { connectToDatabase, disconnectFromDatabase } = await import(MODULE_PATH);
await connectToDatabase();
await connectToDatabase(TEST_DB_NAME, TEST_DB_CONN_STRING);
const connectedCallback = mockedConnectionOn.mock.calls.find(call => call[0] === 'connected')?.[1];
if (connectedCallback) {
if (typeof connectedCallback === 'function') {
connectedCallback();
} else {
throw new Error("Connected callback was not found or is not a function");
}
await disconnectFromDatabase();
expect(mockedMongooseDisconnect).toHaveBeenCalledTimes(1);
expect(consoleLogSpy).toHaveBeenCalledWith("Disconnected from MongoDB.");
expect(mockedMongooseDisconnect).toHaveBeenCalledOnce();
});
it("should NOT call.disconnect NICHT, when no connection is established", async () => {
const { disconnectFromDatabase } = await import(MODULE_PATH);
it('should not call mongoose.disconnect if the connection was never established', async () => {
await disconnectFromDatabase();
expect(mockedMongooseDisconnect).not.toHaveBeenCalled();
+20 -25
View File
@@ -1,34 +1,29 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("../../src/utils/jwtAuthenticator", () => {
return {
JwtAuthenticator: vi.fn().mockImplementation(() => ({
verifyToken: mockVerifyToken,
})),
};
});
const mockVerifyToken = vi.fn();
import {describe, it, expect, vi, beforeEach, afterEach, Mocked} from "vitest";
import type { IncomingMessage } from "node:http";
import { verifyClient } from "../../src/utils/verifyClient";
import {JwtAuthenticator} from "../../src/utils/jwtAuthenticator";
// @ts-ignore
import {createMockJwtAuthenticator} from "../helpers/testSetup";
describe("verifyClient", () => {
const payload = { id: "user-1", username: "hi", uuid: "1234" }
const cb = vi.fn();
let mockJwtAuthenticator: Mocked<JwtAuthenticator>
let consoleSpy: ReturnType<typeof vi.spyOn>;
function makeReq(authHeader?: string) {
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
// socket infos just for log
const socket: any = { remoteAddress: "127.0.0.1", remotePort: 12345 };
return { headers, socket } as unknown as IncomingMessage & { [k: string]: any };
}
beforeEach(() => {
cb.mockReset();
mockVerifyToken.mockReset();
mockJwtAuthenticator = createMockJwtAuthenticator() as any;
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
@@ -38,20 +33,20 @@ describe("verifyClient", () => {
it("accepts connections with valid token and sets payload", () => {
const req = makeReq("Bearer valid.jwt");
mockVerifyToken.mockReturnValue({ sub: "user-1" });
mockJwtAuthenticator.verifyToken.mockReturnValue(payload);
verifyClient(req, cb);
verifyClient(req, mockJwtAuthenticator ,cb);
expect(mockVerifyToken).toHaveBeenCalledWith("valid.jwt");
expect(mockJwtAuthenticator.verifyToken).toHaveBeenCalledWith("valid.jwt");
expect(cb).toHaveBeenCalledWith(true);
expect((req as any).payload).toEqual({ sub: "user-1" });
expect((req as any).payload).toEqual(payload);
});
it("Rejects connection if no Authorization header is set", () => {
const req = makeReq(undefined);
mockVerifyToken.mockReturnValue(null);
mockJwtAuthenticator.verifyToken.mockReturnValue(null);
verifyClient(req, cb);
verifyClient(req, mockJwtAuthenticator, cb);
expect(cb).toHaveBeenCalledWith(false, 401, "Unauthorized");
expect(consoleSpy).toHaveBeenCalled();
@@ -59,22 +54,22 @@ describe("verifyClient", () => {
it("rejects connection, if token is invalid", () => {
const req = makeReq("Bearer bad.jwt");
mockVerifyToken.mockReturnValue(null);
mockJwtAuthenticator.verifyToken.mockReturnValue(null);
verifyClient(req, cb);
verifyClient(req, mockJwtAuthenticator, cb);
expect(mockVerifyToken).toHaveBeenCalledWith("bad.jwt");
expect(mockJwtAuthenticator.verifyToken).toHaveBeenCalledWith("bad.jwt");
expect(cb).toHaveBeenCalledWith(false, 401, "Unauthorized");
});
it("extracts token correctly after 'Bearer ' prefix", () => {
const expectedToken = " fancy.token.with.spaces ";
const req = makeReq(`Bearer ${expectedToken}`);
mockVerifyToken.mockReturnValue({ ok: true });
mockJwtAuthenticator.verifyToken.mockReturnValue(payload);
verifyClient(req, cb);
verifyClient(req,mockJwtAuthenticator, cb);
expect(mockVerifyToken).toHaveBeenCalledWith(expectedToken);
expect(mockJwtAuthenticator.verifyToken).toHaveBeenCalledWith(expectedToken);
expect(cb).toHaveBeenCalledWith(true);
});
});
+6 -3
View File
@@ -1,9 +1,12 @@
import { defineConfig } from 'vitest/config';
import {defineConfig} from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
plugins: [
tsconfigPaths({
projects: ['./tsconfig.test.json']
})
], test: {
globals: true,
environment: 'node',
exclude: [