refactoring, added s3Service.ts, add more depenedncy injection, fix tests as always i geuss
This commit is contained in:
Generated
+2232
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Vendored
+20
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user