Integrate Oudelaa backend features, security, tests, and deployment updates

This commit is contained in:
boutmoun123
2026-07-26 16:58:53 +03:00
parent 1b24ca4294
commit 2fd5322ef7
178 changed files with 19068 additions and 2592 deletions
+372
View File
@@ -0,0 +1,372 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import * as ts from 'typescript';
const sourceRoot = resolve(__dirname);
const validObjectId = '507f1f77bcf86cd799439011';
type Constructor = new (...args: any[]) => any;
type Operation = { dependency: number; method: string; args: unknown[] };
function collectAdapterFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
return collectAdapterFiles(path);
}
return entry.name.endsWith('.controller.ts') || entry.name.endsWith('.repository.ts') ? [path] : [];
});
}
function publicMethodParameters(file: string, className: string): Map<string, string[]> {
const source = ts.createSourceFile(
file,
readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const methods = new Map<string, string[]>();
source.forEachChild((node) => {
if (!ts.isClassDeclaration(node) || node.name?.text !== className) return;
for (const member of node.members) {
if (!ts.isMethodDeclaration(member) || !member.name) continue;
const isPrivate = member.modifiers?.some(
(modifier) =>
modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword,
);
if (isPrivate) continue;
methods.set(
member.name.getText(source).replace(/^['"]|['"]$/g, ''),
member.parameters.map((parameter) => parameter.name.getText(source)),
);
}
});
return methods;
}
function createTransportValue(): any {
const target = function transportValue() {
return value;
};
Object.assign(target, {
status: 'ready',
success: true,
checks: {},
items: [],
data: [],
user: {
sub: validObjectId,
userId: validObjectId,
id: validObjectId,
email: 'member@example.com',
permissions: [],
},
});
const value: any = new Proxy(target, {
apply: () => value,
get: (object, property) => {
if (property === 'then') return undefined;
if (property === Symbol.iterator) return function* emptyIterator() {};
if (property === Symbol.toPrimitive) return () => validObjectId;
if (property === 'toString' || property === 'valueOf') return () => validObjectId;
if (property === 'map' || property === 'filter' || property === 'slice') return () => [];
if (Reflect.has(object, property)) return Reflect.get(object, property);
return value;
},
});
return value;
}
function representativePayload() {
return {
id: validObjectId,
_id: validObjectId,
sub: validObjectId,
userId: validObjectId,
authorId: validObjectId,
ownerId: validObjectId,
ownerAdminId: validObjectId,
postId: validObjectId,
commentId: validObjectId,
targetId: validObjectId,
conversationId: validObjectId,
participantId: validObjectId,
recipientId: validObjectId,
parentCommentId: validObjectId,
email: 'member@example.com',
username: 'member',
name: 'Member',
content: 'contract probe',
title: 'Contract probe',
reason: 'contract probe',
type: 'post',
targetType: 'post',
resourceType: 'post',
status: 'active',
action: 'activate',
isActive: true,
isDisabled: false,
targetIds: [validObjectId],
participantIds: [validObjectId],
userIds: [validObjectId],
deviceId: 'device-contract',
fcmToken: 'token-contract',
imageUrls: [],
tags: [],
page: 1,
limit: 2,
};
}
function argumentFor(parameterName: string): unknown {
const name = parameterName.toLowerCase();
const upload = {
originalname: 'image.jpg',
mimetype: 'image/jpeg',
size: 3,
buffer: Buffer.from([0xff, 0xd8, 0xff]),
};
if (name === 'req' || name === 'request' || name.endsWith('request')) {
return {
user: representativePayload(),
headers: { 'x-request-id': 'contract-request' },
method: 'GET',
originalUrl: '/contract',
};
}
if (name === 'res' || name.includes('response')) {
const response: any = new Proxy(
{},
{
get: (target, property) => {
if (!Reflect.has(target, property)) {
Reflect.set(target, property, jest.fn(() => response));
}
return Reflect.get(target, property);
},
},
);
return response;
}
if (name === 'user' || name.includes('currentuser')) return representativePayload();
if (name.includes('files')) {
return Object.assign([upload], {
imageFiles: [upload],
shopImageFiles: [upload],
avatarFile: [upload],
audioFile: [upload],
videoFile: [upload],
});
}
if (name.includes('file')) return upload;
if (/(^|_)(ids|idlist)$/.test(name) || name.endsWith('ids')) return [validObjectId];
if (name.endsWith('id') || name === 'jti') return validObjectId;
if (name.includes('limit')) return 2;
if (name.includes('skip') || name.includes('offset')) return 0;
if (name.includes('page')) return 1;
if (name.includes('direction')) return -1;
if (name.includes('session')) return undefined;
if (name.includes('email')) return 'member@example.com';
if (name.endsWith('names') || name === 'usernames') return ['member'];
if (name.includes('text') || name.includes('content') || name.includes('title') || name.includes('reason')) {
return 'contract probe';
}
if (name === 'q' || name.includes('search')) return 'contract';
if (name.includes('sort')) return { createdAt: -1 };
if (name.includes('filter')) return {};
return representativePayload();
}
function createControllerDependency(index: number, operations: Operation[]) {
const methods = new Map<PropertyKey, jest.Mock>();
return new Proxy(
{},
{
get: (_target, property) => {
if (property === 'then') return undefined;
if (!methods.has(property)) {
methods.set(
property,
jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: String(property), args });
return createTransportValue();
}),
);
}
return methods.get(property);
},
},
);
}
function createRepositoryModel(index: number, operations: Operation[]) {
const document: any = {
id: validObjectId,
_id: validObjectId,
participantIds: [validObjectId],
read: false,
modifiedCount: 1,
deletedCount: 1,
populate: jest.fn(async () => document),
save: jest.fn(async (...args: unknown[]): Promise<unknown> => {
operations.push({ dependency: index, method: 'document.save', args });
return document;
}),
};
const createQuery = () => {
const queryResult: any[] = [];
Object.assign(queryResult, document);
const query: any = new Proxy(
{},
{
get: (_target, property) => {
if (property === 'then') return undefined;
if (property === 'exec') {
return jest.fn(async () => {
operations.push({ dependency: index, method: 'query.exec', args: [] });
return queryResult;
});
}
return jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: `query.${String(property)}`, args });
return query;
});
},
},
);
return query;
};
const methods = new Map<PropertyKey, jest.Mock>();
const modelTarget = function modelConstructor() {
return document;
};
return new Proxy(modelTarget, {
construct: (_target, args) => {
operations.push({ dependency: index, method: 'new', args });
return document;
},
get: (_target, property) => {
if (property === 'collection') return { name: `collection_${index}` };
if (!methods.has(property)) {
methods.set(
property,
jest.fn((...args: unknown[]) => {
operations.push({ dependency: index, method: String(property), args });
return property === 'create'
? Promise.resolve(Array.isArray(args[0]) ? [document] : document)
: createQuery();
}),
);
}
return methods.get(property);
},
});
}
describe('controller and repository contracts', () => {
const files = collectAdapterFiles(sourceRoot);
it.each(files.filter((file) => file.endsWith('.controller.ts')))(
'%s delegates every public handler or enforces its documented local rejection',
async (file) => {
const moduleExports = require(file) as Record<string, Constructor>;
const controllers = Object.values(moduleExports).filter(
(exported): exported is Constructor =>
typeof exported === 'function' && exported.name.endsWith('Controller'),
);
expect(controllers).not.toHaveLength(0);
for (const Controller of controllers) {
const operations: Operation[] = [];
const dependencies = Array.from({ length: Controller.length }, (_, index) =>
createControllerDependency(index, operations),
);
const instance = Reflect.construct(Controller, dependencies);
const methods = publicMethodParameters(file, Controller.name);
expect(methods.size).toBeGreaterThan(0);
for (const [method, parameters] of methods) {
const key = `${Controller.name}.${method}`;
const args = parameters.map(argumentFor);
const before = operations.length;
if (key === 'ChatController.messagesMissingConversationId') {
await expect(Promise.resolve().then(() => instance[method](...args))).rejects.toThrow(
'conversationId is required',
);
expect(operations).toHaveLength(before);
continue;
}
if (key === 'AuthController.googleAuth') {
await expect(Promise.resolve(instance[method](...args))).resolves.toBeUndefined();
expect(operations).toHaveLength(before);
continue;
}
try {
await instance[method](...args);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${key} failed its delegation contract: ${detail}`);
}
const delegated = operations.slice(before);
if (!delegated.length) {
throw new Error(`${key} did not delegate to an injected dependency`);
}
expect(delegated[0]).toEqual(
expect.objectContaining({ dependency: expect.any(Number), method: expect.any(String) }),
);
}
}
},
);
it.each(files.filter((file) => file.endsWith('.repository.ts')))(
'%s routes every public operation through its persistence model',
async (file) => {
const moduleExports = require(file) as Record<string, Constructor>;
const repositories = Object.values(moduleExports).filter(
(exported): exported is Constructor =>
typeof exported === 'function' && exported.name.endsWith('Repository'),
);
expect(repositories).not.toHaveLength(0);
for (const Repository of repositories) {
const operations: Operation[] = [];
const dependencies = Array.from({ length: Repository.length }, (_, index) =>
createRepositoryModel(index, operations),
);
const instance = Reflect.construct(Repository, dependencies);
const methods = publicMethodParameters(file, Repository.name);
expect(methods.size).toBeGreaterThan(0);
for (const [method, parameters] of methods) {
const before = operations.length;
const args = parameters.map(argumentFor);
if (`${Repository.name}.${method}` === 'FollowsRepository.isDuplicateKeyError') {
expect(instance[method]({ code: 11000 })).toBe(true);
expect(instance[method]({ code: 1 })).toBe(false);
expect(operations).toHaveLength(before);
continue;
}
try {
await instance[method](...args);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${Repository.name}.${method} failed its persistence contract: ${detail}`);
}
const persistenceCalls = operations.slice(before);
if (!persistenceCalls.length) {
throw new Error(`${Repository.name}.${method} did not reach an injected persistence model`);
}
expect(persistenceCalls.some((operation) => operation.method !== 'toString')).toBe(true);
}
}
},
);
});
+8 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('health')
@@ -9,4 +9,11 @@ export class AppController {
getHealth(): { status: string; service: string } {
return this.appService.getHealth();
}
@Get('ready')
async getReadiness() {
const readiness = await this.appService.getReadiness();
if (readiness.status !== 'ready') throw new ServiceUnavailableException(readiness);
return readiness;
}
}
+8
View File
@@ -8,6 +8,9 @@ import { validationSchema } from './config/validation.schema';
import { DatabaseModule } from './database/database.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { LoggingModule } from './infrastructure/logging/logging.module';
import { MetricsModule } from './infrastructure/metrics/metrics.module';
import { MaintenanceModule } from './infrastructure/maintenance/maintenance.module';
import { ReliabilityModule } from './infrastructure/reliability/reliability.module';
import { QueueModule } from './infrastructure/queue/queue.module';
import { RedisModule } from './infrastructure/redis/redis.module';
import { StorageModule } from './infrastructure/storage/storage.module';
@@ -19,6 +22,7 @@ import { CommentsModule } from './modules/comments/comments.module';
import { CollaborationRequestsModule } from './modules/collaboration-requests/collaboration-requests.module';
import { DevicesModule } from './modules/devices/devices.module';
import { FeedModule } from './modules/feed/feed.module';
import { EngagementModule } from './modules/engagement/engagement.module';
import { FollowsModule } from './modules/follows/follows.module';
import { LikesModule } from './modules/likes/likes.module';
import { MediaModule } from './modules/media/media.module';
@@ -46,6 +50,9 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor
validationSchema,
}),
LoggingModule,
MetricsModule,
ReliabilityModule,
MaintenanceModule,
RedisModule,
CacheModule,
StorageModule,
@@ -62,6 +69,7 @@ import { MediaUrlInterceptor } from './common/interceptors/media-url.interceptor
LikesModule,
FollowsModule,
FeedModule,
EngagementModule,
NotificationsModule,
OutboxModule,
ChatModule,
+42
View File
@@ -0,0 +1,42 @@
import { AppService } from './app.service';
describe('AppService readiness', () => {
const createService = (options: { mongoFails?: boolean; storageWritable?: boolean } = {}) => {
const connection = { db: { admin: () => ({
ping: options.mongoFails ? jest.fn().mockRejectedValue(new Error('mongo down')) : jest.fn().mockResolvedValue({ ok: 1 }),
}) } };
const redis = { isEnabled: jest.fn().mockReturnValue(false), getClient: jest.fn() };
const storage = { getHealth: jest.fn().mockResolvedValue({
provider: 'local', local: { readable: true, writable: options.storageWritable ?? true },
}) };
const shutdown = {
isDraining: jest.fn().mockReturnValue(false),
getActiveRequests: jest.fn().mockReturnValue(0),
};
return {
service: new AppService(connection as any, redis as any, storage as any, shutdown as any),
shutdown,
};
};
it('reports ready when required dependencies are healthy', async () => {
await expect(createService().service.getReadiness()).resolves.toEqual(expect.objectContaining({ status: 'ready' }));
});
it('reports degraded and identifies failed dependencies', async () => {
const result = await createService({ mongoFails: true, storageWritable: false }).service.getReadiness();
expect(result).toEqual(expect.objectContaining({ status: 'degraded' }));
expect((result.checks as any).mongodb.status).toBe('down');
expect((result.checks as any).storage.status).toBe('down');
});
it('reports draining immediately during graceful shutdown', async () => {
const { service, shutdown } = createService();
shutdown.isDraining.mockReturnValue(true);
shutdown.getActiveRequests.mockReturnValue(2);
await expect(service.getReadiness()).resolves.toEqual(
expect.objectContaining({ status: 'draining', activeRequests: 2 }),
);
});
});
+79
View File
@@ -1,8 +1,87 @@
import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
import { ManagedStorageService } from './infrastructure/storage/managed-storage.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { ShutdownCoordinatorService } from './infrastructure/reliability/shutdown-coordinator.service';
@Injectable()
export class AppService {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly redis: RedisService,
private readonly storage: ManagedStorageService,
private readonly shutdownCoordinator: ShutdownCoordinatorService,
) {}
getHealth(): { status: string; service: string } {
return { status: 'ok', service: 'oudelaa-backend' };
}
async getReadiness(): Promise<Record<string, unknown>> {
const checks: Record<string, { status: 'up' | 'down'; error?: string }> = {};
if (this.shutdownCoordinator.isDraining()) {
checks.lifecycle = { status: 'down', error: 'Application is draining for shutdown' };
return {
status: 'draining',
service: 'oudelaa-backend',
checks,
activeRequests: this.shutdownCoordinator.getActiveRequests(),
};
}
checks.lifecycle = { status: 'up' };
try {
if (!this.connection.db) throw new Error('MongoDB connection is unavailable');
await this.withTimeout(this.connection.db.admin().ping(), 3000, 'MongoDB ping timeout');
checks.mongodb = { status: 'up' };
} catch (error) {
checks.mongodb = { status: 'down', error: this.errorMessage(error) };
}
if (this.redis.isEnabled()) {
try {
const client = this.redis.getClient();
if (!client) throw new Error('Redis client is unavailable');
await this.withTimeout(client.ping(), 2000, 'Redis ping timeout');
checks.redis = { status: 'up' };
} catch (error) {
checks.redis = { status: 'down', error: this.errorMessage(error) };
}
} else {
checks.redis = { status: 'up' };
}
try {
const health = await this.withTimeout(this.storage.getHealth(), 5000, 'Storage check timeout');
const local = health.local as { readable?: boolean; writable?: boolean } | undefined;
const s3 = health.s3 as { reachable?: boolean; configured?: boolean } | undefined;
const storageUp = health.provider === 'local'
? local?.readable === true && local?.writable === true
: s3?.configured === true && s3?.reachable === true;
checks.storage = storageUp
? { status: 'up' }
: { status: 'down', error: 'Storage is not readable/writable or reachable' };
} catch (error) {
checks.storage = { status: 'down', error: this.errorMessage(error) };
}
const ready = Object.values(checks).every((check) => check.status === 'up');
return { status: ready ? 'ready' : 'degraded', service: 'oudelaa-backend', checks };
}
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); }),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown dependency error';
}
}
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const REQUEST_TIMEOUT_METADATA = 'request_timeout_ms';
/** Override the global request timeout for a controller or a single route. */
export const RequestTimeout = (timeoutMs: number) =>
SetMetadata(REQUEST_TIMEOUT_METADATA, timeoutMs);
/** Disable the application-level timeout for a route that intentionally streams indefinitely. */
export const DisableRequestTimeout = () => SetMetadata(REQUEST_TIMEOUT_METADATA, 0);
@@ -2,6 +2,7 @@ export enum NotificationType {
LIKE = 'like',
COMMENT = 'comment',
FOLLOW = 'follow',
FOLLOW_REQUEST_APPROVED = 'follow_request_approved',
MESSAGE = 'message',
SAVE = 'save',
SHARE = 'share',
@@ -0,0 +1,137 @@
import {
ExecutionContext,
ForbiddenException,
HttpException,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MultipartFormDataGuard } from './multipart-form-data.guard';
import { RolesGuard } from './roles.guard';
import { SuperAdminPermissionsGuard } from './superadmin-permissions.guard';
import { ThrottleGuard } from './throttle.guard';
const contextFor = (request: Record<string, any>): ExecutionContext =>
({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => request }),
}) as unknown as ExecutionContext;
describe('authorization guards', () => {
describe('RolesGuard', () => {
it('allows routes without role metadata', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it.each([
[{ role: 'admin' }, true],
[{ roles: ['support', 'admin'] }, true],
[{ role: 'user' }, false],
[{}, false],
])('evaluates normalized payload roles %#', (user, expected) => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({ user }))).toBe(expected);
});
});
describe('SuperAdminPermissionsGuard', () => {
it('allows routes without permission requirements', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue([]) };
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it('requires every declared permission', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(['users.read', 'users.manage']),
};
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(
guard.canActivate(
contextFor({ user: { permissions: ['users.read', 'users.manage', 'audit.read'] } }),
),
).toBe(true);
expect(() =>
guard.canActivate(contextFor({ user: { permissions: ['users.read'] } })),
).toThrow(ForbiddenException);
expect(() => guard.canActivate(contextFor({}))).toThrow(
'Missing superadmin permission',
);
});
});
describe('ThrottleGuard', () => {
it('does not touch cache for an unthrottled route', async () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const cache = { incr: jest.fn() };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).not.toHaveBeenCalled();
});
it('uses authenticated subject and a minimum one-second window', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 2, windowMs: 10 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(
contextFor({
user: { sub: 'user-1' },
ip: '127.0.0.1',
baseUrl: '/api/v1/auth',
route: { path: '/login' },
}),
),
).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith(
'rate-limit:/api/v1/auth/login:user-1',
1,
);
});
it('falls back to IP/original URL and rejects requests above the limit', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_500 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(contextFor({ ip: '10.0.0.2', originalUrl: '/fallback' })),
).rejects.toBeInstanceOf(HttpException);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:/fallback:10.0.0.2', 2);
});
it('uses stable unknown placeholders if request identity and route are absent', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_000 }),
};
const cache = { incr: jest.fn().mockResolvedValue(1) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:unknown-route:unknown', 1);
});
});
describe('MultipartFormDataGuard', () => {
it('accepts multipart requests and rejects other media types', () => {
const guard = new MultipartFormDataGuard();
expect(guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(true) }))).toBe(true);
expect(() =>
guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(false) })),
).toThrow(UnsupportedMediaTypeException);
});
});
});
@@ -0,0 +1,29 @@
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { firstValueFrom, of } from 'rxjs';
import { ResponseEnvelopeInterceptor } from './response-envelope.interceptor';
describe('ResponseEnvelopeInterceptor', () => {
it('wraps data with response status and timestamp', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({ statusCode: 201 }) }),
} as unknown as ExecutionContext;
const next = { handle: () => of({ id: 'one' }) } as CallHandler;
const value = await firstValueFrom(new ResponseEnvelopeInterceptor().intercept(context, next));
expect(value).toEqual({
data: { id: 'one' },
meta: { statusCode: 201, timestamp: expect.any(String) },
});
});
it('uses HTTP 200 when an adapter does not expose a status', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({}) }),
} as unknown as ExecutionContext;
const value = await firstValueFrom(
new ResponseEnvelopeInterceptor().intercept(context, { handle: () => of(null) }),
);
expect(value).toEqual(
expect.objectContaining({ meta: expect.objectContaining({ statusCode: 200 }) }),
);
});
});
+54 -8
View File
@@ -7,6 +7,7 @@ export type MediaUploadFile = {
originalname?: string;
mimetype?: string;
size?: number;
buffer?: Buffer;
};
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
@@ -168,17 +169,62 @@ function resolveMediaExtensionOrThrow(file: MediaUploadFile, type: AllowedMediaT
function resolveMediaExtension(file: MediaUploadFile, type: AllowedMediaType): string | null {
const extension = getFileExtension(file.originalname);
if (EXTENSIONS_BY_TYPE[type].includes(extension)) {
return extension;
}
const mimetype = file.mimetype?.toLowerCase();
if (mimetype && EXTENSION_BY_MIMETYPE[type][mimetype]) {
return EXTENSION_BY_MIMETYPE[type][mimetype];
const mimeExtension = mimetype ? EXTENSION_BY_MIMETYPE[type][mimetype] : undefined;
const extensionAllowed = EXTENSIONS_BY_TYPE[type].includes(extension);
if (extension && !extensionAllowed) {
return null;
}
if (mimetype && !mimeExtension) {
return null;
}
if (extensionAllowed && mimeExtension && !extensionsAreCompatible(extension, mimeExtension)) {
return null;
}
// TODO: Add magic-byte sniffing before tightening this contract to require both extension and mimetype.
return null;
const resolved = extensionAllowed ? extension : mimeExtension;
if (!resolved) {
return null;
}
if (file.buffer && file.buffer.length >= 12 && !hasExpectedFileSignature(file.buffer, resolved)) {
return null;
}
return resolved;
}
function extensionsAreCompatible(left: string, right: string): boolean {
const normalize = (value: string) => (value === '.jpeg' ? '.jpg' : value);
return normalize(left) === normalize(right);
}
function hasExpectedFileSignature(buffer: Buffer, extension: string): boolean {
const ascii = buffer.subarray(0, 16).toString('ascii');
switch (extension) {
case '.jpg':
case '.jpeg':
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
case '.png':
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
case '.webp':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP';
case '.wav':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WAVE';
case '.ogg':
return ascii.startsWith('OggS');
case '.webm':
return buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3;
case '.mp3':
return ascii.startsWith('ID3') || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
case '.aac':
return buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0;
case '.m4a':
case '.mp4':
case '.mov':
return ascii.slice(4, 8) === 'ftyp';
default:
return false;
}
}
function defaultInvalidMessage(type: AllowedMediaType): string {
+93
View File
@@ -0,0 +1,93 @@
import {
AllowedMediaType,
AUDIO_MIMETYPES,
IMAGE_MIMETYPES,
MEDIA_MAX_SIZE_BYTES,
VIDEO_MIMETYPES,
} from './allowed-media';
export const DIRECT_UPLOAD_FOLDERS = [
'posts/images',
'posts/video',
'posts/audio',
'users/avatar',
'users/cover',
'chat/media',
'marketplace/images',
'support/images',
'collaboration/audio',
] as const;
export type DirectUploadFolder = (typeof DIRECT_UPLOAD_FOLDERS)[number];
export const DIRECT_UPLOAD_MIME_TYPES = [
...IMAGE_MIMETYPES,
...AUDIO_MIMETYPES,
...VIDEO_MIMETYPES,
] as const;
export type DirectUploadMimeType = (typeof DIRECT_UPLOAD_MIME_TYPES)[number];
type DirectUploadMediaDefinition = {
extension: string;
mediaType: AllowedMediaType;
};
type DirectUploadFolderPolicy = Partial<Record<AllowedMediaType, number>>;
const MEDIA_BY_MIME_TYPE: Record<DirectUploadMimeType, DirectUploadMediaDefinition> = {
'image/jpeg': { extension: '.jpg', mediaType: 'image' },
'image/png': { extension: '.png', mediaType: 'image' },
'image/webp': { extension: '.webp', mediaType: 'image' },
'audio/mpeg': { extension: '.mp3', mediaType: 'audio' },
'audio/mp3': { extension: '.mp3', mediaType: 'audio' },
'audio/mp4': { extension: '.m4a', mediaType: 'audio' },
'audio/x-m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/wav': { extension: '.wav', mediaType: 'audio' },
'audio/x-wav': { extension: '.wav', mediaType: 'audio' },
'audio/wave': { extension: '.wav', mediaType: 'audio' },
'audio/aac': { extension: '.aac', mediaType: 'audio' },
'audio/ogg': { extension: '.ogg', mediaType: 'audio' },
'audio/webm': { extension: '.webm', mediaType: 'audio' },
'video/mp4': { extension: '.mp4', mediaType: 'video' },
'video/quicktime': { extension: '.mov', mediaType: 'video' },
'video/webm': { extension: '.webm', mediaType: 'video' },
};
const FOLDER_POLICIES: Record<DirectUploadFolder, DirectUploadFolderPolicy> = {
'posts/images': { image: MEDIA_MAX_SIZE_BYTES.postsImage },
'posts/video': { video: MEDIA_MAX_SIZE_BYTES.postsVideo },
'posts/audio': { audio: MEDIA_MAX_SIZE_BYTES.postsAudio },
'users/avatar': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'users/cover': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'chat/media': {
image: MEDIA_MAX_SIZE_BYTES.postsImage,
audio: MEDIA_MAX_SIZE_BYTES.postsAudio,
video: MEDIA_MAX_SIZE_BYTES.postsVideo,
},
'marketplace/images': { image: MEDIA_MAX_SIZE_BYTES.marketplaceImage },
'support/images': { image: MEDIA_MAX_SIZE_BYTES.supportImage },
'collaboration/audio': { audio: MEDIA_MAX_SIZE_BYTES.collaborationAudio },
};
export type ResolvedDirectUploadPolicy = DirectUploadMediaDefinition & {
maxSizeBytes: number;
};
export function resolveDirectUploadPolicy(
folder: string,
mimeType: string,
): ResolvedDirectUploadPolicy | null {
if (!DIRECT_UPLOAD_FOLDERS.includes(folder as DirectUploadFolder)) {
return null;
}
const media = MEDIA_BY_MIME_TYPE[mimeType as DirectUploadMimeType];
if (!media) {
return null;
}
const maxSizeBytes = FOLDER_POLICIES[folder as DirectUploadFolder][media.mediaType];
return maxSizeBytes ? { ...media, maxSizeBytes } : null;
}
@@ -6,6 +6,7 @@ import { MediaStorageService } from './media-storage.service';
describe('HlsPlaylistService', () => {
const storageService = {
shouldSignResponseUrls: jest.fn(() => true),
isValidHlsPlaylistAccess: jest.fn(() => true),
getBucketName: jest.fn(() => 'oudelaa'),
isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')),
getObjectText: jest.fn(),
@@ -23,6 +24,16 @@ describe('HlsPlaylistService', () => {
beforeEach(() => {
jest.clearAllMocks();
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(true);
});
it('rejects an invalid or expired playlist access signature', async () => {
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(false);
await expect(
service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8'),
).rejects.toThrow(BadRequestException);
expect(storageService.getObjectText).not.toHaveBeenCalled();
});
it('rewrites master playlist variants through the backend endpoint', async () => {
+7 -1
View File
@@ -14,12 +14,18 @@ export class HlsPlaylistService {
private readonly appLogger: AppLoggerService,
) {}
async rewritePlaylist(requestedPath: string): Promise<string> {
async rewritePlaylist(
requestedPath: string,
access?: { expires?: string; signature?: string },
): Promise<string> {
if (!this.mediaStorageService.shouldSignResponseUrls()) {
throw new BadRequestException('Private HLS rewrite is disabled');
}
const key = this.normalizePlaylistKey(requestedPath);
if (!this.mediaStorageService.isValidHlsPlaylistAccess(key, access?.expires, access?.signature)) {
throw new BadRequestException('Invalid or expired HLS access signature');
}
const bucket = this.mediaStorageService.getBucketName();
const startedAt = Date.now();
this.appLogger.log(
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() }));
type StorageInternals = {
getS3Client(): { send(command: unknown): Promise<any> };
getSignedUrlExpiresSeconds(): number;
cacheSignedUrl(key: string, url: string, seconds: number): void;
normalizeFolder(folder: string): string;
normalizeUserSegment(user: string): string;
normalizeKey(key: string): string;
};
const internals = (service: MediaStorageService): StorageInternals =>
service as unknown as StorageInternals;
describe('MediaStorageService behavior', () => {
const values: Record<string, unknown> = {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'security.refreshTokenHashSecret': 'signing-secret',
};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const logger = { error: jest.fn() } as unknown as AppLoggerService;
let send: jest.Mock;
let service: MediaStorageService;
beforeEach(() => {
jest.clearAllMocks();
Object.assign(values, {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'storage.signedUrlExpiresSeconds': 60, 'security.refreshTokenHashSecret': 'signing-secret',
});
send = jest.fn().mockResolvedValue({});
service = new MediaStorageService(config, logger);
jest.spyOn(internals(service), 'getS3Client').mockReturnValue({ send });
});
it('uploads generated and exact keys with content metadata', async () => {
const result = await service.uploadFile({ buffer: Buffer.from('abc'), originalname: 'photo.JPG', mimetype: 'image/jpeg' }, '/posts\\images/');
expect(result).toEqual(expect.objectContaining({ key: expect.stringMatching(/^uploads\/posts\/images\/media-[0-9a-f-]+\.jpg$/i), url: expect.stringContaining('https://cdn.test/uploads/'), size: 3, originalName: 'photo.JPG' }));
expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand);
await expect(service.uploadFileToKey({ buffer: Buffer.from('x') }, '/uploads/hls/master.m3u8')).resolves.toEqual(expect.objectContaining({ key: 'uploads/hls/master.m3u8', mimeType: 'application/octet-stream' }));
const command = send.mock.calls[1][0] as PutObjectCommand;
expect(command.input).toEqual(expect.objectContaining({ CacheControl: 'public, max-age=300', Key: 'uploads/hls/master.m3u8' }));
await expect(service.uploadFileToKey({ buffer: Buffer.alloc(0) }, '../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('deletes valid keys and ignores invalid keys', async () => {
await service.deleteFile('../bad');
expect(send).not.toHaveBeenCalled();
await service.deleteFile('/uploads/file.jpg');
expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand);
});
it('builds public, path-style, and virtual endpoint URLs', () => {
expect(service.getPublicUrl('/uploads/file.jpg')).toBe('https://cdn.test/uploads/file.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/bucket/uploads/file.jpg');
values['storage.s3.forcePathStyle'] = false;
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/uploads/file.jpg');
expect(() => service.getPublicUrl('../bad')).toThrow(BadRequestException);
});
it('resolves managed keys from relative, CDN, endpoint, and application URLs', () => {
expect(service.resolveKeyFromUrl('/uploads/posts/a.jpg?token=1')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://cdn.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.resolveKeyFromUrl('https://s3.test/bucket/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://api.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://external.test/a.jpg')).toBeNull();
expect(service.resolveKeyFromUrl('not a url')).toBeNull();
});
it('loads object text and validates missing bodies and unsafe keys', async () => {
send.mockResolvedValueOnce({ Body: { transformToString: jest.fn().mockResolvedValue('#EXTM3U') } });
await expect(service.getObjectText('uploads/master.m3u8')).resolves.toBe('#EXTM3U');
expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
send.mockResolvedValueOnce({});
await expect(service.getObjectText('uploads/empty.txt')).rejects.toThrow('object body is empty');
await expect(service.getObjectText('../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('builds unsigned and signed HLS endpoints and validates signatures', () => {
const unsigned = service.getHlsPlaylistUrl('uploads/folder name/master.m3u8');
expect(unsigned).toBe('https://api.test/api/v2/media/hls/uploads/folder%20name/master.m3u8');
values['storage.mediaAccessMode'] = 'signed';
const signed = service.getHlsPlaylistUrl('uploads/master.m3u8');
const url = new URL(signed);
const expires = url.searchParams.get('expires') ?? '';
const signature = url.searchParams.get('signature') ?? '';
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, signature)).toBe(true);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', '1', signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('../bad', expires, signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, 'short')).toBe(false);
expect(() => service.getHlsPlaylistUrl('uploads/video.mp4')).toThrow(BadRequestException);
});
it('caches signed object URLs until their refresh boundary', async () => {
values['storage.mediaAccessMode'] = 'signed';
jest.mocked(getSignedUrl).mockResolvedValue('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
expect(getSignedUrl).toHaveBeenCalledTimes(1);
await expect(service.getSignedObjectUrl('../bad')).resolves.toBe('');
service.onModuleDestroy();
await service.getSignedObjectUrl('uploads/a.jpg');
expect(getSignedUrl).toHaveBeenCalledTimes(2);
});
it('checks managed prefixes and clamps signing expiration', () => {
expect(service.isManagedKey('/uploads/posts/a.jpg')).toBe(true);
expect(service.isManagedKey('private/a.jpg')).toBe(false);
values['storage.signedUrlExpiresSeconds'] = 0;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(1);
values['storage.signedUrlExpiresSeconds'] = 9999999;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(604800);
});
it('validates configuration and normalization helpers', () => {
expect(internals(service).normalizeFolder('posts\\images')).toBe('posts/images');
expect(() => internals(service).normalizeFolder('../bad')).toThrow(BadRequestException);
expect(internals(service).normalizeUserSegment(' user_1-2 ')).toBe('user_1-2');
expect(() => internals(service).normalizeUserSegment('bad/user')).toThrow(BadRequestException);
expect(internals(service).normalizeKey('a\\b')).toBe('a/b');
expect(internals(service).normalizeKey('a//b')).toBe('');
values['storage.s3.bucket'] = '';
expect(() => service.getBucketName()).toThrow(BadRequestException);
values['storage.s3.bucket'] = 'bucket';
const unconfigured = new MediaStorageService({ get: jest.fn(() => '') } as unknown as ConfigService, logger);
expect(() => internals(unconfigured).getS3Client()).toThrow(BadRequestException);
});
it('evicts the oldest signing cache entry at its safety limit', () => {
const target = internals(service);
for (let index = 0; index < 10001; index += 1) {
target.cacheSignedUrl(`uploads/${index}`, `url-${index}`, 10);
}
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.size).toBe(10000);
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.has('uploads/0')).toBe(false);
});
});
@@ -0,0 +1,165 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: jest.fn(),
}));
const mockedGetSignedUrl = jest.mocked(getSignedUrl);
describe('MediaStorageService direct uploads', () => {
const config: Record<string, unknown> = {
'storage.provider': 's3',
'storage.mediaAccessMode': 'signed',
'storage.basePath': 'uploads',
'storage.publicBaseUrl': 'https://cdn.oudelaa.test',
'storage.s3.bucket': 'oudelaa-media',
'storage.s3.region': 'us-east-1',
'storage.s3.endpoint': 'https://s3.oudelaa.test',
'storage.s3.accessKeyId': 'test-access-key',
'storage.s3.secretAccessKey': 'test-secret-key',
'storage.s3.forcePathStyle': true,
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
} as unknown as ConfigService;
const appLogger = { error: jest.fn() } as unknown as AppLoggerService;
beforeEach(() => {
mockedGetSignedUrl.mockReset();
mockedGetSignedUrl.mockResolvedValue('https://s3.oudelaa.test/presigned-put');
config['storage.provider'] = 's3';
});
it('creates a user-scoped PUT URL with signed MIME and exact content length', async () => {
const service = new MediaStorageService(configService, appLogger);
const result = await service.createPresignedUpload({
userId: '507f1f77bcf86cd799439011',
folder: 'posts/video',
mimeType: 'video/mp4',
size: 5_242_880,
});
expect(result).toEqual(
expect.objectContaining({
method: 'PUT',
uploadUrl: 'https://s3.oudelaa.test/presigned-put',
mediaUrl: expect.stringMatching(
/^https:\/\/cdn\.oudelaa\.test\/uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
storageKey: expect.stringMatching(
/^uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
mimeType: 'video/mp4',
size: 5_242_880,
requiredHeaders: { 'Content-Type': 'video/mp4' },
expiresIn: 900,
expiresAt: expect.any(String),
}),
);
expect(mockedGetSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
Bucket: 'oudelaa-media',
Key: result.storageKey,
ContentLength: 5_242_880,
ContentType: 'video/mp4',
CacheControl: 'public, max-age=31536000, immutable',
Metadata: {
owner: '507f1f77bcf86cd799439011',
'upload-source': 'direct',
},
}),
}),
{
expiresIn: 900,
signableHeaders: new Set(['content-type']),
},
);
});
it('is disabled for local storage without attempting to sign', async () => {
config['storage.provider'] = 'local';
const service = new MediaStorageService(configService, appLogger);
await expect(
service.createPresignedUpload({
userId: 'user-1',
folder: 'posts/images',
mimeType: 'image/jpeg',
size: 1024,
}),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it('does not rewrite an existing presigned PUT URL as a signed GET URL', async () => {
const service = new MediaStorageService(configService, appLogger);
const uploadUrl =
'https://s3.oudelaa.test/oudelaa-media/uploads/posts/video/file.mp4' +
'?X-Amz-Algorithm=AWS4-HMAC-SHA256' +
'&X-Amz-Credential=test' +
'&X-Amz-Signature=presigned-put';
await expect(service.resolveResponseUrl(uploadUrl)).resolves.toBe(uploadUrl);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it.each([
{
name: 'a MIME type that does not match the folder',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'video/mp4' as const,
size: 1024,
},
},
{
name: 'an oversized image',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'image/jpeg' as const,
size: 5 * 1024 * 1024 + 1,
},
},
{
name: 'a zero-byte body',
input: {
userId: 'user-1',
folder: 'posts/audio' as const,
mimeType: 'audio/mpeg' as const,
size: 0,
},
},
{
name: 'an unsafe user segment',
input: {
userId: '../another-user',
folder: 'posts/images' as const,
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
{
name: 'an unsafe folder',
input: {
userId: 'user-1',
folder: '../posts/images' as unknown as 'posts/images',
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
])('rejects $name', async ({ input }) => {
const service = new MediaStorageService(configService, appLogger);
await expect(service.createPresignedUpload(input)).rejects.toBeInstanceOf(BadRequestException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ describe('MediaStorageService response URLs', () => {
'storage.s3.forcePathStyle': true,
publicBaseUrl: 'https://api.oudelaa.test',
globalPrefix: 'api/v1',
'security.refreshTokenHashSecret': 'test-hls-signing-secret',
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
@@ -64,8 +65,8 @@ describe('MediaStorageService response URLs', () => {
await expect(
service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/hls/stream-1/master.m3u8'),
).resolves.toBe(
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/master.m3u8',
).resolves.toMatch(
/^https:\/\/api\.oudelaa\.test\/api\/v1\/media\/hls\/uploads\/posts\/hls\/stream-1\/master\.m3u8\?expires=\d+&signature=/,
);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
+142 -4
View File
@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import {
BadRequestException,
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
@@ -7,9 +12,14 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import { posix } from 'path';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import {
DirectUploadFolder,
DirectUploadMimeType,
resolveDirectUploadPolicy,
} from './direct-upload.policy';
import { getFileExtension } from './allowed-media';
export type MediaStorageUploadFile = {
@@ -27,6 +37,27 @@ export type MediaStorageUploadResult = {
originalName: string;
};
export type CreatePresignedMediaUploadInput = {
userId: string;
folder: DirectUploadFolder;
mimeType: DirectUploadMimeType;
size: number;
};
export type PresignedMediaUploadResult = {
method: 'PUT';
uploadUrl: string;
storageKey: string;
mediaUrl: string;
mimeType: DirectUploadMimeType;
size: number;
requiredHeaders: { 'Content-Type': DirectUploadMimeType };
expiresIn: number;
expiresAt: string;
};
const DIRECT_UPLOAD_EXPIRES_SECONDS = 15 * 60;
@Injectable()
export class MediaStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
@@ -78,6 +109,64 @@ export class MediaStorageService implements OnModuleDestroy {
};
}
async createPresignedUpload(
input: CreatePresignedMediaUploadInput,
): Promise<PresignedMediaUploadResult> {
if (this.getProvider() !== 's3') {
throw new ServiceUnavailableException(
'Direct media uploads are available only when S3 storage is enabled',
);
}
const policy = resolveDirectUploadPolicy(input.folder, input.mimeType);
if (!policy) {
throw new BadRequestException('Media type is not allowed for this upload folder');
}
if (!Number.isSafeInteger(input.size) || input.size < 1) {
throw new BadRequestException('Media size must be a positive integer');
}
if (input.size > policy.maxSizeBytes) {
throw new BadRequestException(
`Media size exceeds the ${policy.maxSizeBytes}-byte limit for this upload folder`,
);
}
const userSegment = this.normalizeUserSegment(input.userId);
const fileName = `media-${randomUUID()}${policy.extension}`;
const key = posix.join(this.getBasePath(), input.folder, userSegment, fileName);
const uploadUrl = await getSignedUrl(
this.getS3Client(),
new PutObjectCommand({
Bucket: this.getBucket(),
Key: key,
ContentLength: input.size,
ContentType: input.mimeType,
CacheControl: this.resolveCacheControl(key),
Metadata: {
owner: userSegment,
'upload-source': 'direct',
},
}),
{
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
signableHeaders: new Set(['content-type']),
},
);
const expiresAt = new Date(Date.now() + DIRECT_UPLOAD_EXPIRES_SECONDS * 1000).toISOString();
return {
method: 'PUT',
uploadUrl,
storageKey: key,
mediaUrl: this.getPublicUrl(key),
mimeType: input.mimeType,
size: input.size,
requiredHeaders: { 'Content-Type': input.mimeType },
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
expiresAt,
};
}
async deleteFile(key: string): Promise<void> {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
@@ -139,7 +228,11 @@ export class MediaStorageService implements OnModuleDestroy {
}
async resolveResponseUrl(fileUrl: string): Promise<string> {
if (!this.shouldSignResponseUrls() || !fileUrl.trim()) {
if (
!this.shouldSignResponseUrls() ||
!fileUrl.trim() ||
this.isAlreadyPresignedS3Url(fileUrl)
) {
return fileUrl;
}
@@ -236,7 +329,22 @@ export class MediaStorageService implements OnModuleDestroy {
.join('/');
const path = `/${globalPrefix}/media/hls/${encodedKey}`;
return publicBaseUrl ? `${publicBaseUrl}${path}` : path;
const baseUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : path;
if (!this.shouldSignResponseUrls()) return baseUrl;
const expires = Math.floor(Date.now() / 1000) + this.getSignedUrlExpiresSeconds();
const signature = this.signHlsPlaylistKey(normalizedKey, expires);
return `${baseUrl}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
}
isValidHlsPlaylistAccess(key: string, expiresValue?: string, signature?: string): boolean {
const normalizedKey = this.normalizeKey(key);
const expires = Number(expiresValue);
if (!normalizedKey || !Number.isInteger(expires) || expires <= Math.floor(Date.now() / 1000) || !signature) {
return false;
}
const expected = Buffer.from(this.signHlsPlaylistKey(normalizedKey, expires));
const supplied = Buffer.from(signature);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
}
getBucketName(): string {
@@ -253,6 +361,14 @@ export class MediaStorageService implements OnModuleDestroy {
this.signedUrlCache.clear();
}
private signHlsPlaylistKey(key: string, expires: number): string {
const secret =
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ||
this.configService.get<string>('jwt.accessSecret', { infer: true }) ||
'';
return createHmac('sha256', secret).update(`${key}:${expires}`).digest('base64url');
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
@@ -272,6 +388,7 @@ export class MediaStorageService implements OnModuleDestroy {
region: this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto',
endpoint,
forcePathStyle: this.getForcePathStyle(),
requestChecksumCalculation: 'WHEN_REQUIRED',
credentials: {
accessKeyId,
secretAccessKey,
@@ -355,6 +472,19 @@ export class MediaStorageService implements OnModuleDestroy {
);
}
private isAlreadyPresignedS3Url(fileUrl: string): boolean {
try {
const searchParams = new URL(fileUrl).searchParams;
return (
searchParams.has('X-Amz-Algorithm') &&
searchParams.has('X-Amz-Credential') &&
searchParams.has('X-Amz-Signature')
);
} catch {
return false;
}
}
private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void {
if (this.signedUrlCache.size >= 10000) {
const oldestKey = this.signedUrlCache.keys().next().value as string | undefined;
@@ -380,6 +510,14 @@ export class MediaStorageService implements OnModuleDestroy {
return normalized;
}
private normalizeUserSegment(userId: string): string {
const normalized = userId.trim();
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(normalized)) {
throw new BadRequestException('Invalid media owner id');
}
return normalized;
}
private normalizeKey(key: string): string {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (
+7 -3
View File
@@ -9,10 +9,14 @@ export const decodeOffsetCursor = (cursor?: string): number | null => {
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
if (Number.isInteger(parsed) && parsed >= 0) {
return parsed;
}
return parsed;
const payload = JSON.parse(raw) as { offset?: unknown };
return Number.isInteger(payload.offset) && Number(payload.offset) >= 0
? Number(payload.offset)
: null;
} catch {
return null;
}
+22
View File
@@ -0,0 +1,22 @@
import * as bcrypt from 'bcrypt';
import { compareHash, compareStoredHighEntropyValue, hashHighEntropyValue, hashValue } from './hash.util';
describe('hash utilities', () => {
it('hashes and verifies passwords with bcrypt', async () => {
const hash = await hashValue('secret', 4);
await expect(compareHash('secret', hash)).resolves.toBe(true);
await expect(compareHash('wrong', hash)).resolves.toBe(false);
});
it('uses deterministic secret-keyed hashes for high entropy tokens', async () => {
const stored = hashHighEntropyValue('token', 'pepper');
expect(stored).toMatch(/^sha256:[0-9a-f]{64}$/);
await expect(compareStoredHighEntropyValue('token', stored, 'pepper')).resolves.toBe(true);
await expect(compareStoredHighEntropyValue('other', stored, 'pepper')).resolves.toBe(false);
});
it('supports legacy bcrypt token hashes', async () => {
const stored = await bcrypt.hash('legacy-token', 4);
await expect(compareStoredHighEntropyValue('legacy-token', stored, 'unused')).resolves.toBe(true);
});
});
+1
View File
@@ -0,0 +1 @@
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+33
View File
@@ -0,0 +1,33 @@
import { ConfigService } from '@nestjs/config';
import { buildAiMusicShareUrl, buildPostShareUrl, buildProfileShareUrl, normalizeShareBaseUrl, resolveShareBaseUrl } from './share-url.util';
describe('share URL utilities', () => {
const originalShare = process.env.SHARE_BASE_URL;
const originalPublicWeb = process.env.PUBLIC_WEB_URL;
afterEach(() => {
if (originalShare === undefined) delete process.env.SHARE_BASE_URL;
else process.env.SHARE_BASE_URL = originalShare;
if (originalPublicWeb === undefined) delete process.env.PUBLIC_WEB_URL;
else process.env.PUBLIC_WEB_URL = originalPublicWeb;
});
it('normalizes defaults and trailing slashes', () => {
expect(normalizeShareBaseUrl(undefined)).toBe('https://oudelaa.com');
expect(normalizeShareBaseUrl(' https://web.test/// ')).toBe('https://web.test');
});
it('resolves environment before configuration', () => {
process.env.SHARE_BASE_URL = 'https://env.test/';
const config = { get: jest.fn(() => 'https://config.test') } as unknown as ConfigService;
expect(resolveShareBaseUrl(config)).toBe('https://env.test');
expect(config.get).not.toHaveBeenCalled();
});
it('builds encoded profile, post, and AI music links', () => {
expect(buildProfileShareUrl('https://web', { username: 'a/b' })).toBe('https://web/u/a%2Fb');
expect(buildProfileShareUrl('https://web', { _id: { toString: () => 'id one' } })).toBe('https://web/profile/id%20one');
expect(buildPostShareUrl('https://web', 'post/1')).toBe('https://web/posts/post%2F1');
expect(buildAiMusicShareUrl('https://web', 'track 1')).toBe('https://web/ai/music/track%201');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { verifyTotp } from './totp.util';
describe('verifyTotp', () => {
it('accepts a known code and rejects invalid values', () => {
expect(verifyTotp('JBSWY3DPEHPK3PXP', '282760', 0)).toBe(true);
expect(verifyTotp('JBSWY3DPEHPK3PXP', '000000', 0)).toBe(false);
expect(verifyTotp('not-base32!', '282760', 0)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { createHmac, timingSafeEqual } from 'crypto';
const decodeBase32 = (value: string): Buffer => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalized = value.toUpperCase().replace(/=|\s|-/g, '');
let bits = '';
for (const character of normalized) {
const index = alphabet.indexOf(character);
if (index < 0) return Buffer.alloc(0);
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let offset = 0; offset + 8 <= bits.length; offset += 8) {
bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2));
}
return Buffer.from(bytes);
};
const generateTotp = (secret: Buffer, counter: number): string => {
const message = Buffer.alloc(8);
message.writeBigUInt64BE(BigInt(counter));
const digest = createHmac('sha1', secret).update(message).digest();
const offset = digest[digest.length - 1] & 0x0f;
return ((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000)
.toString()
.padStart(6, '0');
};
export const verifyTotp = (base32Secret: string, suppliedCode: string, nowMs = Date.now()): boolean => {
if (!/^\d{6}$/.test(suppliedCode)) return false;
const secret = decodeBase32(base32Secret);
if (!secret.length) return false;
const counter = Math.floor(nowMs / 30_000);
return [-1, 0, 1].filter((drift) => counter + drift >= 0).some((drift) => {
const expected = Buffer.from(generateTotp(secret, counter + drift));
const supplied = Buffer.from(suppliedCode);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
});
};
+52 -6
View File
@@ -13,6 +13,16 @@ export default () => ({
responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
swagger: {
enabled:
typeof process.env.SWAGGER_ENABLED === 'string'
? process.env.SWAGGER_ENABLED.toLowerCase() === 'true'
: (process.env.NODE_ENV ?? 'development') !== 'production',
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
version: process.env.SWAGGER_VERSION ?? '1.0.0',
path: process.env.SWAGGER_PATH ?? 'docs',
},
cors: {
origins: (process.env.CORS_ORIGINS ?? '')
.split(',')
@@ -21,6 +31,22 @@ export default () => ({
},
mongodb: {
uri: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/oudelaa',
autoIndex:
typeof process.env.MONGODB_AUTO_INDEX === 'string'
? process.env.MONGODB_AUTO_INDEX.toLowerCase() === 'true'
: (process.env.NODE_ENV ?? 'development') !== 'production',
minPoolSize: Number(process.env.MONGODB_MIN_POOL_SIZE ?? 5),
maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE ?? 100),
maxIdleTimeMs: Number(process.env.MONGODB_MAX_IDLE_TIME_MS ?? 60_000),
serverSelectionTimeoutMs: Number(process.env.MONGODB_SERVER_SELECTION_TIMEOUT_MS ?? 10_000),
socketTimeoutMs: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS ?? 45_000),
},
search: {
engine: (process.env.SEARCH_ENGINE ?? 'auto').toLowerCase(),
atlasUserIndex: process.env.SEARCH_ATLAS_USER_INDEX ?? 'users_search',
atlasPostIndex: process.env.SEARCH_ATLAS_POST_INDEX ?? 'posts_search',
fallbackEnabled: (process.env.SEARCH_FALLBACK_ENABLED ?? 'true').toLowerCase() === 'true',
retrySeconds: Number(process.env.SEARCH_ATLAS_RETRY_SECONDS ?? 300),
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET ?? '',
@@ -31,10 +57,12 @@ export default () => ({
superAdmin: {
email: (process.env.SUPERADMIN_EMAIL ?? '').toLowerCase(),
password: process.env.SUPERADMIN_PASSWORD ?? '',
passwordHash: process.env.SUPERADMIN_PASSWORD_HASH ?? '',
accessSecret: process.env.SUPERADMIN_ACCESS_SECRET ?? '',
accessExpiresIn: process.env.SUPERADMIN_ACCESS_EXPIRES_IN ?? '15m',
refreshSecret: process.env.SUPERADMIN_REFRESH_SECRET ?? '',
refreshExpiresIn: process.env.SUPERADMIN_REFRESH_EXPIRES_IN ?? '30d',
totpSecret: process.env.SUPERADMIN_TOTP_SECRET ?? '',
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
@@ -65,6 +93,20 @@ export default () => ({
bcryptSaltRounds: Number(process.env.BCRYPT_SALT_ROUNDS ?? 12),
refreshTokenHashSecret:
process.env.REFRESH_TOKEN_HASH_SECRET ?? process.env.JWT_REFRESH_SECRET ?? '',
bodyLimit: process.env.HTTP_BODY_LIMIT ?? '1mb',
},
reliability: {
requestTimeoutMs: Number(process.env.HTTP_REQUEST_TIMEOUT_MS ?? 30_000),
serverRequestTimeoutMs: Number(process.env.HTTP_SERVER_REQUEST_TIMEOUT_MS ?? 120_000),
headersTimeoutMs: Number(process.env.HTTP_HEADERS_TIMEOUT_MS ?? 15_000),
keepAliveTimeoutMs: Number(process.env.HTTP_KEEP_ALIVE_TIMEOUT_MS ?? 5_000),
maxRequestsPerSocket: Number(process.env.HTTP_MAX_REQUESTS_PER_SOCKET ?? 1_000),
shutdownGracePeriodMs: Number(process.env.SHUTDOWN_GRACE_PERIOD_MS ?? 30_000),
},
metrics: {
eventLoopResolutionMs: Number(process.env.METRICS_EVENT_LOOP_RESOLUTION_MS ?? 20),
eventLoopLagWarnMs: Number(process.env.METRICS_EVENT_LOOP_LAG_WARN_MS ?? 100),
maxRoutes: Number(process.env.METRICS_MAX_ROUTES ?? 500),
},
redis: {
enabled: (process.env.REDIS_ENABLED ?? 'false').toLowerCase() === 'true',
@@ -147,10 +189,20 @@ export default () => ({
enabled: (process.env.FEED_CACHE_ENABLED ?? 'true').toLowerCase() === 'true',
userFeedTtlSeconds: Number(process.env.FEED_CACHE_USER_TTL_SECONDS ?? 15),
trendingTtlSeconds: Number(process.env.FEED_CACHE_TRENDING_TTL_SECONDS ?? 30),
rankingProfileTtlSeconds: Number(process.env.FEED_CACHE_RANKING_PROFILE_TTL_SECONDS ?? 30),
},
performance: {
feedTimingLogsEnabled:
(process.env.FEED_TIMING_LOGS_ENABLED ?? 'false').toLowerCase() === 'true',
compressionEnabled:
(process.env.HTTP_COMPRESSION_ENABLED ?? 'true').toLowerCase() === 'true',
compressionThresholdBytes: Number(process.env.HTTP_COMPRESSION_THRESHOLD_BYTES ?? 1024),
},
moderation: {
blockedTerms: (process.env.MODERATION_BLOCKED_TERMS ?? '')
.split(',')
.map((term) => term.trim().toLowerCase())
.filter(Boolean),
},
passwordReset: {
codeExpiresMinutes: Number(process.env.PASSWORD_RESET_CODE_EXPIRES_MINUTES ?? 10),
@@ -162,10 +214,4 @@ export default () => ({
codeExpiresMinutes: Number(process.env.EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES ?? 10),
maxAttempts: Number(process.env.EMAIL_VERIFICATION_MAX_ATTEMPTS ?? 5),
},
swagger: {
title: process.env.SWAGGER_TITLE ?? 'Oudelaa API',
description: process.env.SWAGGER_DESCRIPTION ?? 'Social media backend API documentation',
version: process.env.SWAGGER_VERSION ?? '1.0.0',
path: process.env.SWAGGER_PATH ?? 'docs',
},
});
+3
View File
@@ -3,6 +3,9 @@ import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export const setupSwagger = (app: INestApplication, configService: ConfigService): void => {
if (!(configService.get<boolean>('swagger.enabled', { infer: true }) ?? false)) {
return;
}
const config = new DocumentBuilder()
.setTitle(configService.get<string>('swagger.title', 'Oudelaa API'))
.setDescription(
+53 -2
View File
@@ -11,16 +11,33 @@ export const validationSchema = Joi.object({
GLOBAL_PREFIX: Joi.string().default('api/v1'),
CORS_ORIGINS: Joi.string().allow('').optional(),
MONGODB_URI: Joi.string().required(),
MONGODB_AUTO_INDEX: Joi.boolean().truthy('true').falsy('false').optional(),
MONGODB_MIN_POOL_SIZE: Joi.number().integer().min(0).max(500).default(5),
MONGODB_MAX_POOL_SIZE: Joi.number().integer().min(1).max(1000).default(100),
MONGODB_MAX_IDLE_TIME_MS: Joi.number().integer().min(1000).max(600000).default(60000),
MONGODB_SERVER_SELECTION_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(10000),
MONGODB_SOCKET_TIMEOUT_MS: Joi.number().integer().min(1000).max(600000).default(45000),
SEARCH_ENGINE: Joi.string().valid('auto', 'atlas', 'regex').default('auto'),
SEARCH_ATLAS_USER_INDEX: Joi.string().default('users_search'),
SEARCH_ATLAS_POST_INDEX: Joi.string().default('posts_search'),
SEARCH_FALLBACK_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
SEARCH_ATLAS_RETRY_SECONDS: Joi.number().integer().min(30).max(3600).default(300),
JWT_ACCESS_SECRET: Joi.string().min(16).required(),
JWT_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
JWT_REFRESH_SECRET: Joi.string().min(16).required(),
JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
SUPERADMIN_EMAIL: Joi.string().email().required(),
SUPERADMIN_PASSWORD: Joi.string().min(8).required(),
SUPERADMIN_PASSWORD: Joi.string().min(8).allow('').optional(),
SUPERADMIN_PASSWORD_HASH: Joi.when('NODE_ENV', {
is: 'production',
then: Joi.string().pattern(/^\$2[aby]\$\d{2}\$.{53}$/).required(),
otherwise: Joi.string().allow('').optional(),
}),
SUPERADMIN_ACCESS_SECRET: Joi.string().min(16).required(),
SUPERADMIN_ACCESS_EXPIRES_IN: Joi.string().default('15m'),
SUPERADMIN_REFRESH_SECRET: Joi.string().min(16).required(),
SUPERADMIN_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
SUPERADMIN_TOTP_SECRET: Joi.string().pattern(/^[A-Z2-7\s=-]{16,}$/i).allow('').optional(),
GOOGLE_CLIENT_ID: Joi.string().allow('').optional(),
GOOGLE_CLIENT_SECRET: Joi.string().allow('').optional(),
GOOGLE_CALLBACK_URL: Joi.string().uri().optional(),
@@ -40,6 +57,20 @@ export const validationSchema = Joi.object({
AI_MUSIC_MODEL: Joi.string().default('lyria-002'),
BCRYPT_SALT_ROUNDS: Joi.number().min(8).max(15).default(12),
REFRESH_TOKEN_HASH_SECRET: Joi.string().allow('').optional(),
HTTP_BODY_LIMIT: Joi.string().pattern(/^\d+(kb|mb)$/i).default('1mb'),
HTTP_REQUEST_TIMEOUT_MS: Joi.number().integer().min(1000).max(300000).default(30000),
HTTP_SERVER_REQUEST_TIMEOUT_MS: Joi.number()
.integer()
.min(1000)
.max(900000)
.default(120000),
HTTP_HEADERS_TIMEOUT_MS: Joi.number().integer().min(1000).max(120000).default(15000),
HTTP_KEEP_ALIVE_TIMEOUT_MS: Joi.number().integer().min(1000).max(60000).default(5000),
HTTP_MAX_REQUESTS_PER_SOCKET: Joi.number().integer().min(1).max(100000).default(1000),
SHUTDOWN_GRACE_PERIOD_MS: Joi.number().integer().min(1000).max(120000).default(30000),
METRICS_EVENT_LOOP_RESOLUTION_MS: Joi.number().integer().min(10).max(1000).default(20),
METRICS_EVENT_LOOP_LAG_WARN_MS: Joi.number().integer().min(10).max(10000).default(100),
METRICS_MAX_ROUTES: Joi.number().integer().min(10).max(10000).default(500),
REDIS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
REDIS_URL: Joi.string().allow('').optional(),
REDIS_HOST: Joi.string().default('127.0.0.1'),
@@ -95,7 +126,11 @@ export const validationSchema = Joi.object({
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_USER_TTL_SECONDS: Joi.number().min(1).max(3600).default(15),
FEED_CACHE_TRENDING_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
FEED_CACHE_RANKING_PROFILE_TTL_SECONDS: Joi.number().min(1).max(3600).default(30),
FEED_TIMING_LOGS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
HTTP_COMPRESSION_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
HTTP_COMPRESSION_THRESHOLD_BYTES: Joi.number().integer().min(0).max(1048576).default(1024),
MODERATION_BLOCKED_TERMS: Joi.string().allow('').optional(),
PASSWORD_RESET_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
PASSWORD_RESET_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
PASSWORD_RESET_TOKEN_SECRET: Joi.string().allow('').optional(),
@@ -103,7 +138,23 @@ export const validationSchema = Joi.object({
EMAIL_VERIFICATION_CODE_EXPIRES_MINUTES: Joi.number().min(1).max(60).default(10),
EMAIL_VERIFICATION_MAX_ATTEMPTS: Joi.number().min(1).max(10).default(5),
SWAGGER_TITLE: Joi.string().default('Oudelaa API'),
SWAGGER_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
SWAGGER_DESCRIPTION: Joi.string().default('Social media backend API documentation'),
SWAGGER_VERSION: Joi.string().default('1.0.0'),
SWAGGER_PATH: Joi.string().default('docs'),
});
}).custom((value: Record<string, unknown>, helpers: Joi.CustomHelpers) => {
if (value.STORAGE_PROVIDER === 'local' && value.MEDIA_ACCESS_MODE === 'signed') {
return helpers.message({ custom: 'MEDIA_ACCESS_MODE=signed requires STORAGE_PROVIDER=s3' });
}
if (Number(value.HTTP_HEADERS_TIMEOUT_MS) <= Number(value.HTTP_KEEP_ALIVE_TIMEOUT_MS)) {
return helpers.message({
custom: 'HTTP_HEADERS_TIMEOUT_MS must be greater than HTTP_KEEP_ALIVE_TIMEOUT_MS',
});
}
if (Number(value.HTTP_SERVER_REQUEST_TIMEOUT_MS) < Number(value.HTTP_HEADERS_TIMEOUT_MS)) {
return helpers.message({
custom: 'HTTP_SERVER_REQUEST_TIMEOUT_MS must be at least HTTP_HEADERS_TIMEOUT_MS',
});
}
return value;
}, 'storage access mode validation');
@@ -0,0 +1,27 @@
import { createMongooseOptions } from './mongoose-options.factory';
describe('createMongooseOptions', () => {
const config = (values: Record<string, unknown>) => ({
get: jest.fn((key: string) => values[key]),
}) as any;
it('uses a bounded production pool and disables startup index builds', () => {
const options = createMongooseOptions(config({
nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app',
}));
expect(options).toEqual(expect.objectContaining({
autoIndex: false, minPoolSize: 5, maxPoolSize: 100,
serverSelectionTimeoutMS: 10_000, socketTimeoutMS: 45_000,
}));
});
it('accepts explicit pool and index configuration', () => {
const options = createMongooseOptions(config({
nodeEnv: 'production', 'mongodb.uri': 'mongodb://database/app',
'mongodb.autoIndex': true, 'mongodb.minPoolSize': 10, 'mongodb.maxPoolSize': 200,
}));
expect(options.autoIndex).toBe(true);
expect(options.minPoolSize).toBe(10);
expect(options.maxPoolSize).toBe(200);
});
});
+14 -4
View File
@@ -1,7 +1,17 @@
import { ConfigService } from '@nestjs/config';
import { MongooseModuleOptions } from '@nestjs/mongoose';
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => ({
uri: configService.get<string>('mongodb.uri', { infer: true }),
autoIndex: true,
});
export const createMongooseOptions = (configService: ConfigService): MongooseModuleOptions => {
const nodeEnv = configService.get<string>('nodeEnv', { infer: true }) ?? 'development';
return {
uri: configService.get<string>('mongodb.uri', { infer: true }),
autoIndex:
configService.get<boolean>('mongodb.autoIndex', { infer: true }) ?? nodeEnv !== 'production',
minPoolSize: configService.get<number>('mongodb.minPoolSize', { infer: true }) ?? 5,
maxPoolSize: configService.get<number>('mongodb.maxPoolSize', { infer: true }) ?? 100,
maxIdleTimeMS: configService.get<number>('mongodb.maxIdleTimeMs', { infer: true }) ?? 60_000,
serverSelectionTimeoutMS:
configService.get<number>('mongodb.serverSelectionTimeoutMs', { infer: true }) ?? 10_000,
socketTimeoutMS: configService.get<number>('mongodb.socketTimeoutMs', { infer: true }) ?? 45_000,
};
};
+150
View File
@@ -0,0 +1,150 @@
import { RedisService } from '../redis/redis.service';
import { AppCacheService, CacheFillLock } from './app-cache.service';
type RedisDouble = {
get: jest.Mock;
set: jest.Mock;
del: jest.Mock;
incr: jest.Mock;
expire: jest.Mock;
eval: jest.Mock;
exists: jest.Mock;
};
const redisDouble = (): RedisDouble => ({
get: jest.fn(), set: jest.fn(), del: jest.fn(), incr: jest.fn(), expire: jest.fn(),
eval: jest.fn(), exists: jest.fn(),
});
const redisService = (client: RedisDouble | null): RedisService =>
({ getClient: jest.fn(() => client), getKeyPrefix: jest.fn(() => 'spec') }) as unknown as RedisService;
type CacheInternals = {
normalizeLockTtlMilliseconds(value: number): number;
normalizeDurationMilliseconds(value: number, minimum: number): number;
withJitter(value: number): number;
errorMessage(error: unknown): string;
};
describe('AppCacheService behavior', () => {
it('supports memory get, set, expiration, deletion, and absent writes', async () => {
const service = new AppCacheService(redisService(null));
await service.set('plain', { id: 1 });
await expect(service.get('plain')).resolves.toEqual({ id: 1 });
await expect(service.setIfAbsent('plain', 2, 10)).resolves.toBe(false);
await service.del('plain');
await expect(service.get('plain')).resolves.toBeNull();
await expect(service.setIfAbsent('plain', 2, 0)).resolves.toBe(true);
await expect(service.get('plain')).resolves.toBe(2);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.set('short', 'value', 1);
now.mockReturnValue(2001);
await expect(service.get('short')).resolves.toBeNull();
now.mockRestore();
});
it('uses Redis for CRUD, NX, and JSON serialization', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce('{"id":4}');
await expect(service.get('missing')).resolves.toBeNull();
await expect(service.get('item')).resolves.toEqual({ id: 4 });
await service.set('ttl', { yes: true }, 4);
expect(redis.set).toHaveBeenCalledWith('spec:ttl', '{"yes":true}', 'EX', 4);
await service.set('forever', 1, 0);
expect(redis.set).toHaveBeenCalledWith('spec:forever', '1');
redis.set.mockResolvedValueOnce('OK').mockResolvedValueOnce(null);
await expect(service.setIfAbsent('one', 1, 0)).resolves.toBe(true);
await expect(service.setIfAbsent('one', 1, 10)).resolves.toBe(false);
expect(redis.set).toHaveBeenCalledWith('spec:one', '1', 'EX', 1, 'NX');
await service.del('one');
expect(redis.del).toHaveBeenCalledWith('spec:one');
});
it('increments Redis values and expires only a newly created counter', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.incr.mockResolvedValueOnce(1).mockResolvedValueOnce(2);
await expect(service.incr('counter', 5)).resolves.toBe(1);
await expect(service.incr('counter', 5)).resolves.toBe(2);
expect(redis.expire).toHaveBeenCalledTimes(1);
expect(redis.expire).toHaveBeenCalledWith('spec:counter', 5);
});
it('increments memory counters and resets expired values', async () => {
const service = new AppCacheService(redisService(null));
await expect(service.incr('counter')).resolves.toBe(1);
await expect(service.incr('counter')).resolves.toBe(2);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.set('expiring-counter', 9, 1);
now.mockReturnValue(2001);
await expect(service.incr('expiring-counter', 5)).resolves.toBe(1);
now.mockRestore();
});
it('enforces memory lock ownership, extension, expiration, and one-time release', async () => {
const service = new AppCacheService(redisService(null));
const lock = await service.acquireFillLock('fill', 10);
expect(lock.acquired).toBe(true);
expect((await service.acquireFillLock('fill', 10)).acquired).toBe(false);
await expect(lock.extend(20)).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(true);
await expect(lock.extend()).resolves.toBe(false);
await expect(lock.release()).resolves.toBe(false);
const now = jest.spyOn(Date, 'now');
now.mockReturnValueOnce(1000);
await service.acquireFillLock('expired', 1);
now.mockReturnValue(3000);
expect((await service.acquireFillLock('expired', 1)).acquired).toBe(true);
now.mockRestore();
});
it('handles Redis lock contention, extension, and release', async () => {
const redis = redisDouble();
const service = new AppCacheService(redisService(redis));
redis.set.mockResolvedValueOnce(null);
const unavailable = await service.acquireFillLock('fill', Number.NaN);
expect(unavailable.acquired).toBe(false);
await expect(unavailable.extend()).resolves.toBe(false);
await expect(unavailable.release()).resolves.toBe(false);
redis.set.mockResolvedValueOnce('OK');
redis.eval.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
const lock = await service.acquireFillLock('fill', 0);
expect(lock.acquired).toBe(true);
await expect(lock.extend(2)).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(true);
await expect(lock.release()).resolves.toBe(false);
expect(redis.set).toHaveBeenLastCalledWith(expect.stringContaining('__fill_lock'), expect.any(String), 'PX', 1000, 'NX');
});
it('returns immediately when waiting has no time budget', async () => {
const service = new AppCacheService(redisService(null));
await expect(service.waitForValue('never', 0, Number.NaN)).resolves.toBeNull();
});
it('releases a remember lease even when factories reject and tolerates release failures', async () => {
const service = new AppCacheService(redisService(null));
const release = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
const lock: CacheFillLock = { acquired: true, extend: jest.fn().mockResolvedValue(true), release };
jest.spyOn(service, 'acquireFillLock').mockResolvedValue(lock);
await expect(service.remember('broken', 10, async () => { throw new Error('factory failed'); })).rejects.toThrow('factory failed');
expect(release).toHaveBeenCalled();
});
it('normalizes unsafe durations and formats unknown errors', () => {
const service = new AppCacheService(redisService(null));
const target = service as unknown as CacheInternals;
expect(target.normalizeLockTtlMilliseconds(-1)).toBe(1000);
expect(target.normalizeLockTtlMilliseconds(0.0001)).toBe(1);
expect(target.normalizeDurationMilliseconds(Number.NaN, 3)).toBe(3);
expect(target.normalizeDurationMilliseconds(2.9, 3)).toBe(3);
expect(target.withJitter(0)).toBeGreaterThanOrEqual(1);
expect(target.errorMessage(new Error('boom'))).toBe('boom');
expect(target.errorMessage('boom')).toBe('boom');
});
});
+142
View File
@@ -0,0 +1,142 @@
import { RedisService } from '../redis/redis.service';
import { AppCacheService } from './app-cache.service';
class FakeRedis {
readonly values = new Map<string, string>();
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async set(
key: string,
value: string,
...options: Array<string | number>
): Promise<'OK' | null> {
if (options.includes('NX') && this.values.has(key)) {
return null;
}
this.values.set(key, value);
return 'OK';
}
async exists(key: string): Promise<number> {
return this.values.has(key) ? 1 : 0;
}
async eval(
script: string,
_numberOfKeys: number,
key: string,
ownerToken: string,
_ttlMs?: number,
): Promise<number> {
if (this.values.get(key) !== ownerToken) {
return 0;
}
if (script.includes("redis.call('pexpire'")) {
return 1;
}
this.values.delete(key);
return 1;
}
}
const createRedisService = (client: FakeRedis | null): RedisService =>
({
getClient: jest.fn(() => client),
getKeyPrefix: jest.fn(() => 'test'),
}) as unknown as RedisService;
const nextTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
describe('AppCacheService stampede protection', () => {
it('coalesces concurrent local cache misses into one factory call', async () => {
const service = new AppCacheService(createRedisService(null));
let resolveFactory!: (value: { id: number }) => void;
const factory = jest.fn(
() =>
new Promise<{ id: number }>((resolve) => {
resolveFactory = resolve;
}),
);
const requests = Array.from({ length: 25 }, () => service.remember('feed:1', 60, factory));
await nextTurn();
expect(factory).toHaveBeenCalledTimes(1);
resolveFactory({ id: 42 });
await expect(Promise.all(requests)).resolves.toEqual(
Array.from({ length: 25 }, () => ({ id: 42 })),
);
await expect(service.remember('feed:1', 60, factory)).resolves.toEqual({ id: 42 });
expect(factory).toHaveBeenCalledTimes(1);
});
it('clears a failed local single-flight so a later request can retry', async () => {
const service = new AppCacheService(createRedisService(null));
const failedFactory = jest.fn().mockRejectedValue(new Error('database unavailable'));
await expect(service.remember('feed:failure', 60, failedFactory)).rejects.toThrow(
'database unavailable',
);
await expect(
service.remember('feed:failure', 60, async () => ({ recovered: true })),
).resolves.toEqual({ recovered: true });
expect(failedFactory).toHaveBeenCalledTimes(1);
});
it('uses a shared Redis lease to coalesce fills across service instances', async () => {
const redis = new FakeRedis();
const firstService = new AppCacheService(createRedisService(redis));
const secondService = new AppCacheService(createRedisService(redis));
let resolveFirst!: (value: string) => void;
const firstFactory = jest.fn(
() =>
new Promise<string>((resolve) => {
resolveFirst = resolve;
}),
);
const secondFactory = jest.fn(async () => 'duplicate');
const first = firstService.remember('feed:shared', 60, firstFactory);
await nextTurn();
expect(firstFactory).toHaveBeenCalledTimes(1);
const second = secondService.remember('feed:shared', 60, secondFactory);
await nextTurn();
resolveFirst('canonical');
await expect(Promise.all([first, second])).resolves.toEqual(['canonical', 'canonical']);
expect(secondFactory).not.toHaveBeenCalled();
});
it('never releases or extends a Redis lock now owned by another request', async () => {
const redis = new FakeRedis();
const service = new AppCacheService(createRedisService(redis));
const lease = await service.acquireFillLock('feed:ownership', 10);
const lockKey = 'test:feed:ownership:__fill_lock';
expect(lease.acquired).toBe(true);
redis.values.set(lockKey, 'replacement-owner');
await expect(lease.extend(20)).resolves.toBe(false);
await expect(lease.release()).resolves.toBe(false);
expect(redis.values.get(lockKey)).toBe('replacement-owner');
});
it('releases its own Redis lock atomically and only once', async () => {
const redis = new FakeRedis();
const service = new AppCacheService(createRedisService(redis));
const lease = await service.acquireFillLock('feed:release', 10);
await expect(lease.release()).resolves.toBe(true);
await expect(lease.release()).resolves.toBe(false);
expect(redis.values.has('test:feed:release:__fill_lock')).toBe(false);
});
it('waits for a value populated by another request', async () => {
const service = new AppCacheService(createRedisService(null));
const waiting = service.waitForValue<{ ready: boolean }>('feed:waiting', 200, 5);
setTimeout(() => void service.set('feed:waiting', { ready: true }, 60), 10);
await expect(waiting).resolves.toEqual({ ready: true });
});
});
+354 -7
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
type MemoryEntry = {
@@ -6,9 +7,41 @@ type MemoryEntry = {
expiresAt: number | null;
};
type MemoryLock = {
ownerToken: string;
expiresAt: number;
};
export type CacheFillLock = {
readonly acquired: boolean;
extend: (ttlSeconds?: number) => Promise<boolean>;
release: () => Promise<boolean>;
};
const RELEASE_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
`;
const EXTEND_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
end
return 0
`;
@Injectable()
export class AppCacheService {
private static readonly DEFAULT_FILL_LOCK_TTL_SECONDS = 30;
private static readonly DEFAULT_FILL_WAIT_TIMEOUT_MS = 30_000;
private static readonly DEFAULT_FILL_POLL_MS = 50;
private readonly logger = new Logger(AppCacheService.name);
private readonly memory = new Map<string, MemoryEntry>();
private readonly memoryLocks = new Map<string, MemoryLock>();
private readonly inFlight = new Map<string, Promise<unknown>>();
constructor(private readonly redisService: RedisService) {}
@@ -56,6 +89,28 @@ export class AppCacheService {
this.memory.set(fullKey, { value: serialized, expiresAt });
}
async setIfAbsent<T>(key: string, value: T, ttlSeconds: number): Promise<boolean> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
const serialized = JSON.stringify(value);
if (redis) {
const result = await redis.set(fullKey, serialized, 'EX', Math.max(1, ttlSeconds), 'NX');
return result === 'OK';
}
const existing = this.memory.get(fullKey);
if (existing && (!existing.expiresAt || existing.expiresAt > Date.now())) {
return false;
}
this.memory.set(fullKey, {
value: serialized,
expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000,
});
return true;
}
async del(key: string): Promise<void> {
const redis = this.redisService.getClient();
const fullKey = this.buildKey(key);
@@ -73,9 +128,132 @@ export class AppCacheService {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
const fullKey = this.buildKey(key);
const existingFill = this.inFlight.get(fullKey) as Promise<T> | undefined;
if (existingFill) {
return existingFill;
}
const fill = this.fillRememberedValue(key, ttlSeconds, factory);
this.inFlight.set(fullKey, fill);
try {
return await fill;
} finally {
if (this.inFlight.get(fullKey) === fill) {
this.inFlight.delete(fullKey);
}
}
}
/**
* Acquires a short-lived cache-fill lease. The returned release/extend
* functions are ownership checked, so an expired lock can never be removed
* or extended by its previous owner.
*/
async acquireFillLock(key: string, ttlSeconds: number): Promise<CacheFillLock> {
const redis = this.redisService.getClient();
const lockKey = this.buildLockKey(key);
const ownerToken = randomUUID();
const ttlMs = this.normalizeLockTtlMilliseconds(ttlSeconds);
if (redis) {
const result = await redis.set(lockKey, ownerToken, 'PX', ttlMs, 'NX');
if (result !== 'OK') {
return this.createUnavailableLock();
}
let released = false;
return {
acquired: true,
extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
if (released) {
return false;
}
const extended = await redis.eval(
EXTEND_LOCK_SCRIPT,
1,
lockKey,
ownerToken,
this.normalizeLockTtlMilliseconds(nextTtlSeconds),
);
return Number(extended) === 1;
},
release: async (): Promise<boolean> => {
if (released) {
return false;
}
const deleted = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, ownerToken);
if (Number(deleted) === 1) {
released = true;
return true;
}
return false;
},
};
}
const now = Date.now();
const current = this.memoryLocks.get(lockKey);
if (current && current.expiresAt > now) {
return this.createUnavailableLock();
}
this.memoryLocks.set(lockKey, { ownerToken, expiresAt: now + ttlMs });
let released = false;
return {
acquired: true,
extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
if (released) {
return false;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock || lock.ownerToken !== ownerToken || lock.expiresAt <= Date.now()) {
return false;
}
lock.expiresAt = Date.now() + this.normalizeLockTtlMilliseconds(nextTtlSeconds);
return true;
},
release: async (): Promise<boolean> => {
if (released) {
return false;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock || lock.ownerToken !== ownerToken) {
return false;
}
this.memoryLocks.delete(lockKey);
released = true;
return true;
},
};
}
async waitForValue<T>(key: string, timeoutMs: number, pollMs = 50): Promise<T | null> {
const timeout = this.normalizeDurationMilliseconds(timeoutMs, 0);
const pollInterval = this.normalizeDurationMilliseconds(pollMs, 1);
const deadline = Date.now() + timeout;
while (true) {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
return null;
}
await this.delay(Math.min(this.withJitter(pollInterval), remaining));
}
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
@@ -90,13 +268,182 @@ export class AppCacheService {
return nextValue;
}
const existing = await this.get<number>(key);
const nextValue = (existing ?? 0) + 1;
await this.set(key, nextValue, ttlSeconds);
const existing = this.memory.get(fullKey);
const currentValue =
existing && (!existing.expiresAt || existing.expiresAt > Date.now())
? (JSON.parse(existing.value) as number)
: 0;
const nextValue = currentValue + 1;
this.memory.set(fullKey, {
value: JSON.stringify(nextValue),
expiresAt: ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null,
});
return nextValue;
}
private buildKey(key: string): string {
return `${this.redisService.getKeyPrefix()}:${key}`;
}
private buildLockKey(key: string): string {
return `${this.buildKey(key)}:__fill_lock`;
}
private async fillRememberedValue<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
): Promise<T> {
const cachedAfterJoining = await this.get<T>(key);
if (cachedAfterJoining !== null) {
return cachedAfterJoining;
}
const lockTtlSeconds = AppCacheService.DEFAULT_FILL_LOCK_TTL_SECONDS;
const deadline = Date.now() + AppCacheService.DEFAULT_FILL_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
const lock = await this.acquireFillLock(key, lockTtlSeconds);
if (lock.acquired) {
return this.fillAsLockOwner(key, ttlSeconds, factory, lock, lockTtlSeconds);
}
const value = await this.waitForValueWhileLocked<T>(
key,
Math.max(0, deadline - Date.now()),
AppCacheService.DEFAULT_FILL_POLL_MS,
);
if (value !== null) {
return value;
}
}
// Keep request latency bounded if a remote owner is unhealthy or its
// factory never completes. This rare fallback may duplicate work, but it
// prevents an orphaned cache lease from taking down the request path.
const lastCachedValue = await this.get<T>(key);
if (lastCachedValue !== null) {
return lastCachedValue;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
private async fillAsLockOwner<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
lock: CacheFillLock,
lockTtlSeconds: number,
): Promise<T> {
const heartbeat = setInterval(() => {
void lock.extend(lockTtlSeconds).catch((error: unknown) => {
this.logger.warn(`Could not extend cache fill lock for ${key}: ${this.errorMessage(error)}`);
});
}, Math.max(1_000, Math.floor((lockTtlSeconds * 1000) / 3)));
heartbeat.unref?.();
try {
// A different owner may have filled the value immediately before this
// process acquired the lease.
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
} finally {
clearInterval(heartbeat);
try {
await lock.release();
} catch (error: unknown) {
// A successful cache fill must not be turned into a failed request by
// a transient Redis error during best-effort lease cleanup.
this.logger.warn(`Could not release cache fill lock for ${key}: ${this.errorMessage(error)}`);
}
}
}
private async waitForValueWhileLocked<T>(
key: string,
timeoutMs: number,
pollMs: number,
): Promise<T | null> {
const deadline = Date.now() + timeoutMs;
while (true) {
const cached = await this.get<T>(key);
if (cached !== null) {
return cached;
}
if (!(await this.isFillLockHeld(key))) {
return null;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
return null;
}
await this.delay(Math.min(this.withJitter(pollMs), remaining));
}
}
private async isFillLockHeld(key: string): Promise<boolean> {
const redis = this.redisService.getClient();
const lockKey = this.buildLockKey(key);
if (redis) {
return (await redis.exists(lockKey)) === 1;
}
const lock = this.memoryLocks.get(lockKey);
if (!lock) {
return false;
}
if (lock.expiresAt <= Date.now()) {
this.memoryLocks.delete(lockKey);
return false;
}
return true;
}
private createUnavailableLock(): CacheFillLock {
return {
acquired: false,
extend: async () => false,
release: async () => false,
};
}
private normalizeLockTtlMilliseconds(ttlSeconds: number): number {
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
return 1_000;
}
return Math.max(1, Math.ceil(ttlSeconds * 1000));
}
private normalizeDurationMilliseconds(value: number, minimum: number): number {
if (!Number.isFinite(value)) {
return minimum;
}
return Math.max(minimum, Math.floor(value));
}
private withJitter(durationMs: number): number {
return Math.max(1, Math.round(durationMs * (0.8 + Math.random() * 0.4)));
}
private delay(durationMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { AppCacheService } from './app-cache.service';
import { FeedVersionService } from './feed-version.service';
describe('FeedVersionService', () => {
const cache = { get: jest.fn(), set: jest.fn(), incr: jest.fn() } as unknown as AppCacheService;
const service = new FeedVersionService(cache);
beforeEach(() => jest.clearAllMocks());
it('returns existing positive global and user versions', async () => {
(cache.get as jest.Mock).mockResolvedValueOnce(7).mockResolvedValueOnce(3);
await expect(service.getGlobalVersion()).resolves.toBe(7);
await expect(service.getUserVersion('u1')).resolves.toBe(3);
expect(cache.set).not.toHaveBeenCalled();
});
it.each([null, 0, -1, '1'])('initializes invalid global version %p', async (stored) => {
(cache.get as jest.Mock).mockResolvedValue(stored);
await expect(service.getGlobalVersion()).resolves.toBe(1);
expect(cache.set).toHaveBeenCalledWith('feed:global:version', 1);
});
it('initializes an absent user version and bumps both scopes', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(cache.incr as jest.Mock).mockResolvedValueOnce(4).mockResolvedValueOnce(9);
await expect(service.getUserVersion('u2')).resolves.toBe(1);
expect(cache.set).toHaveBeenCalledWith('feed:user:u2:version', 1);
await expect(service.bumpGlobalVersion()).resolves.toBe(4);
await expect(service.bumpUserVersion('u2')).resolves.toBe(9);
expect(cache.incr).toHaveBeenNthCalledWith(1, 'feed:global:version');
expect(cache.incr).toHaveBeenNthCalledWith(2, 'feed:user:u2:version');
});
});
+12
View File
@@ -20,4 +20,16 @@ export class FeedVersionService {
async bumpGlobalVersion(): Promise<number> {
return this.cacheService.incr(FeedVersionService.GLOBAL_VERSION_KEY);
}
async getUserVersion(userId: string): Promise<number> {
const key = `feed:user:${userId}:version`;
const current = await this.cacheService.get<number>(key);
if (typeof current === 'number' && current > 0) return current;
await this.cacheService.set(key, 1);
return 1;
}
async bumpUserVersion(userId: string): Promise<number> {
return this.cacheService.incr(`feed:user:${userId}:version`);
}
}
@@ -0,0 +1,55 @@
import { ConfigService } from '@nestjs/config';
import { AppLoggerService } from './app-logger.service';
describe('AppLoggerService', () => {
let level = 'verbose';
const config = { get: jest.fn(() => level) } as unknown as ConfigService;
let stdout: jest.SpyInstance;
let stderr: jest.SpyInstance;
beforeEach(() => {
level = 'verbose';
stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
stdout.mockRestore();
stderr.mockRestore();
});
it('writes structured messages, errors, traces, and HTTP metadata', () => {
const logger = new AppLoggerService(config);
logger.log('hello', 'Test');
logger.warn({ id: 1 });
logger.debug(new Error('debug failed'));
logger.verbose('details');
logger.logHttp({ method: 'GET', path: '/health' });
logger.error('broken', 'trace-text', 'Test');
expect(stdout).toHaveBeenCalledTimes(5);
const logEntry = JSON.parse(String(stdout.mock.calls[0][0]));
expect(logEntry).toEqual(expect.objectContaining({ level: 'log', context: 'Test', message: 'hello' }));
const structured = JSON.parse(String(stdout.mock.calls[1][0]));
expect(structured).toEqual(expect.objectContaining({ message: 'structured_log', payload: { id: 1 } }));
const errorObject = JSON.parse(String(stdout.mock.calls[2][0]));
expect(errorObject).toEqual(expect.objectContaining({ errorName: 'Error', message: 'debug failed' }));
const http = JSON.parse(String(stdout.mock.calls[4][0]));
expect(http).toEqual(expect.objectContaining({ context: 'HttpLogger', method: 'GET', path: '/health' }));
const error = JSON.parse(String(stderr.mock.calls[0][0]));
expect(error).toEqual(expect.objectContaining({ level: 'error', trace: 'trace-text' }));
});
it('filters messages below the configured level and defaults to log', () => {
level = 'warn';
const logger = new AppLoggerService(config);
logger.log('hidden');
logger.debug('hidden');
logger.warn('visible');
expect(stdout).toHaveBeenCalledTimes(1);
(config.get as jest.Mock).mockReturnValueOnce(undefined);
logger.verbose('default-hidden');
expect(stdout).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,80 @@
import { Types } from 'mongoose';
import { AppQueueService } from '../queue/app-queue.service';
import { CounterReconciliationService } from './counter-reconciliation.service';
import { MaintenanceController } from './maintenance.controller';
describe('CounterReconciliationService', () => {
const postOne = new Types.ObjectId();
const postTwo = new Types.ObjectId();
let rows: Record<string, Array<{ _id: Types.ObjectId; count: number }>>;
let postsBulkWrite: jest.Mock;
let collection: jest.Mock;
const queue = { registerProcessor: jest.fn(), enqueue: jest.fn() } as unknown as AppQueueService;
beforeEach(() => {
jest.clearAllMocks();
rows = {
likes: [{ _id: postOne, count: 3 }],
comments: [{ _id: postOne, count: 2 }],
saves: [{ _id: postTwo, count: 5 }],
postshares: [{ _id: postOne, count: 1 }],
};
postsBulkWrite = jest.fn();
collection = jest.fn((name: string) =>
name === 'posts'
? {
find: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([{ _id: postOne }, { _id: postTwo }]) })),
bulkWrite: postsBulkWrite,
}
: { aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue(rows[name]) })) },
);
});
it('registers and enqueues reconciliation jobs', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
service.onModuleInit();
expect(queue.registerProcessor).toHaveBeenCalledWith(
'maintenance.reconcile-post-counters',
expect.any(Function),
);
const callback = (queue.registerProcessor as jest.Mock).mock.calls[0][1] as () => Promise<void>;
const reconcile = jest.spyOn(service, 'reconcile').mockResolvedValue({ dryRun: false, scannedPosts: 0, updates: 0 });
await callback();
expect(reconcile).toHaveBeenCalledWith(false);
await expect(service.enqueue()).resolves.toEqual({ queued: true });
expect(queue.enqueue).toHaveBeenCalledWith('maintenance.reconcile-post-counters', {});
});
it('computes every counter without writes during a dry run', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
await expect(service.reconcile()).resolves.toEqual({ dryRun: true, scannedPosts: 2, updates: 2 });
expect(postsBulkWrite).not.toHaveBeenCalled();
const likesAggregate = collection.mock.results.find((result) => result.value.aggregate)?.value.aggregate;
expect(likesAggregate).toHaveBeenCalledWith([
{ $match: { targetType: 'post' } },
{ $group: { _id: '$targetId', count: { $sum: 1 } } },
{ $match: { _id: { $type: 'objectId' } } },
]);
});
it('writes reconciled values including zeros', async () => {
const service = new CounterReconciliationService({ collection } as never, queue);
await service.reconcile(false);
expect(postsBulkWrite).toHaveBeenCalledWith(
[
expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 3, commentsCount: 2, savesCount: 0, shareCount: 1 } } }) }),
expect.objectContaining({ updateOne: expect.objectContaining({ update: { $set: { likesCount: 0, commentsCount: 0, savesCount: 5, shareCount: 0 } } }) }),
],
{ ordered: false },
);
});
it('controller executes only on an explicit true flag', async () => {
const counters = { enqueue: jest.fn().mockResolvedValue({ queued: true }), reconcile: jest.fn().mockResolvedValue({ dryRun: true }) };
const controller = new MaintenanceController(counters as never);
await controller.reconcile('true');
await controller.reconcile('TRUE');
expect(counters.enqueue).toHaveBeenCalledTimes(1);
expect(counters.reconcile).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,64 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection, Types } from 'mongoose';
import { AppQueueService } from '../queue/app-queue.service';
const JOB_NAME = 'maintenance.reconcile-post-counters';
@Injectable()
export class CounterReconciliationService implements OnModuleInit {
constructor(
@InjectConnection() private readonly connection: Connection,
private readonly queue: AppQueueService,
) {}
onModuleInit(): void {
this.queue.registerProcessor(JOB_NAME, async () => { await this.reconcile(false); });
}
async enqueue(): Promise<{ queued: true }> {
await this.queue.enqueue(JOB_NAME, {});
return { queued: true };
}
async reconcile(dryRun = true): Promise<{ dryRun: boolean; scannedPosts: number; updates: number }> {
const posts = await this.connection.collection('posts').find(
{ isDeleted: { $ne: true } },
{ projection: { _id: 1 } },
).toArray();
const [likes, comments, saves, shares] = await Promise.all([
this.countByPost('likes', { targetType: 'post' }, '$targetId'),
this.countByPost('comments', { isDeleted: { $ne: true } }, '$postId'),
this.countByPost('saves', {}, '$postId'),
this.countByPost('postshares', { isDeleted: { $ne: true } }, '$postId'),
]);
const maps = [likes, comments, saves, shares].map((rows) =>
new Map(rows.map((row) => [row._id.toString(), row.count])),
);
const operations = posts.map((post) => ({
updateOne: {
filter: { _id: post._id },
update: { $set: {
likesCount: maps[0].get(post._id.toString()) ?? 0,
commentsCount: maps[1].get(post._id.toString()) ?? 0,
savesCount: maps[2].get(post._id.toString()) ?? 0,
shareCount: maps[3].get(post._id.toString()) ?? 0,
} },
},
}));
if (!dryRun) {
for (let offset = 0; offset < operations.length; offset += 500) {
await this.connection.collection('posts').bulkWrite(operations.slice(offset, offset + 500), { ordered: false });
}
}
return { dryRun, scannedPosts: posts.length, updates: operations.length };
}
private countByPost(collection: string, match: Record<string, unknown>, id: string) {
return this.connection.collection(collection).aggregate<{ _id: Types.ObjectId; count: number }>([
{ $match: match },
{ $group: { _id: id, count: { $sum: 1 } } },
{ $match: { _id: { $type: 'objectId' } } },
]).toArray();
}
}
@@ -0,0 +1,17 @@
import { Controller, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { CounterReconciliationService } from './counter-reconciliation.service';
@ApiTags('Operations')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Controller('internal/maintenance')
export class MaintenanceController {
constructor(private readonly counters: CounterReconciliationService) {}
@Post('reconcile-post-counters')
reconcile(@Query('execute') execute?: string) {
return execute === 'true' ? this.counters.enqueue() : this.counters.reconcile(true);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { QueueModule } from '../queue/queue.module';
import { CounterReconciliationService } from './counter-reconciliation.service';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [QueueModule],
controllers: [MaintenanceController],
providers: [CounterReconciliationService],
})
export class MaintenanceModule {}
@@ -0,0 +1,9 @@
import { MetricsController } from './metrics.controller';
describe('MetricsController', () => {
it('returns a current metrics snapshot', () => {
const metrics = { snapshot: jest.fn(() => ({ requests: 4 })) };
expect(new MetricsController(metrics as never).getMetrics()).toEqual({ requests: 4 });
expect(metrics.snapshot).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { MetricsService } from './metrics.service';
@ApiTags('Operations')
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard)
@Controller('internal/metrics')
export class MetricsController {
constructor(private readonly metrics: MetricsService) {}
@Get()
getMetrics() {
return this.metrics.snapshot();
}
}
@@ -0,0 +1,60 @@
import { CallHandler, ExecutionContext, InternalServerErrorException } from '@nestjs/common';
import { firstValueFrom, of, throwError } from 'rxjs';
import { MetricsInterceptor } from './metrics.interceptor';
import { MetricsService } from './metrics.service';
describe('MetricsInterceptor', () => {
const createContext = (statusCode = 200) =>
({
getType: () => 'http',
switchToHttp: () => ({
getRequest: () => ({
method: 'GET',
baseUrl: '/api/v1/posts',
route: { path: '/:id' },
}),
getResponse: () => ({ statusCode }),
}),
}) as unknown as ExecutionContext;
it('uses a route template instead of a high-cardinality raw URL', async () => {
const metrics = {
requestStarted: jest.fn(),
requestFinished: jest.fn(),
} as unknown as MetricsService;
const interceptor = new MetricsInterceptor(metrics);
await firstValueFrom(
interceptor.intercept(createContext(), { handle: () => of({ ok: true }) } as CallHandler),
);
expect(metrics.requestStarted).toHaveBeenCalledTimes(1);
expect(metrics.requestFinished).toHaveBeenCalledWith(
'GET',
'/api/v1/posts/:id',
200,
expect.any(Number),
);
});
it('records the actual exception status', async () => {
const metrics = {
requestStarted: jest.fn(),
requestFinished: jest.fn(),
} as unknown as MetricsService;
const interceptor = new MetricsInterceptor(metrics);
const handler = {
handle: () => throwError(() => new InternalServerErrorException()),
} as CallHandler;
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf(
InternalServerErrorException,
);
expect(metrics.requestFinished).toHaveBeenCalledWith(
'GET',
'/api/v1/posts/:id',
500,
expect.any(Number),
);
});
});
@@ -0,0 +1,56 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, finalize, throwError } from 'rxjs';
import { MetricsService } from './metrics.service';
type MetricRequest = {
method?: string;
baseUrl?: string;
route?: { path?: unknown };
};
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
constructor(private readonly metrics: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<MetricRequest>();
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
const startedAt = Date.now();
const route = this.normalizedRoute(request);
let errorStatusCode: number | undefined;
this.metrics.requestStarted();
return next.handle().pipe(
catchError((error: unknown) => {
errorStatusCode = error instanceof HttpException ? error.getStatus() : 500;
return throwError(() => error);
}),
finalize(() => {
this.metrics.requestFinished(
request.method ?? 'UNKNOWN',
route,
errorStatusCode ?? response.statusCode,
Date.now() - startedAt,
);
}),
);
}
private normalizedRoute(request: MetricRequest): string {
const routePath = request.route?.path;
if (typeof routePath !== 'string') {
return '__unmatched__';
}
const joined = `${request.baseUrl ?? ''}/${routePath}`.replace(/\/{2,}/g, '/');
return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { MetricsController } from './metrics.controller';
import { MetricsInterceptor } from './metrics.interceptor';
import { MetricsService } from './metrics.service';
@Module({
controllers: [MetricsController],
providers: [MetricsService, { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }],
exports: [MetricsService],
})
export class MetricsModule {}
@@ -0,0 +1,65 @@
import { ConfigService } from '@nestjs/config';
import { MetricsService } from './metrics.service';
describe('MetricsService', () => {
const createService = (values: Record<string, number> = {}) => {
const config = {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
return new MetricsService(config);
};
it('aggregates latency, errors, timeouts, and an approximate p95 by route', () => {
const service = createService();
service.observe('GET', '/feed/me', 200, 20);
service.observe('GET', '/feed/me', 408, 40);
service.observe('GET', '/feed/me', 500, 260);
expect(service.snapshot().routes[0]).toEqual(
expect.objectContaining({
route: 'GET /feed/me',
requests: 3,
clientErrors: 1,
errors: 1,
timeouts: 1,
errorRate: 1 / 3,
averageDurationMs: 106.67,
p95DurationMs: 500,
maxDurationMs: 260,
}),
);
});
it('tracks current and peak in-flight work', () => {
const service = createService();
service.requestStarted();
service.requestStarted();
service.requestFinished('GET', '/health', 200, 2);
expect(service.snapshot().operational).toEqual({
totalRequests: 2,
inFlightRequests: 1,
peakInFlightRequests: 2,
});
});
it('caps route cardinality and exposes finite event-loop metrics', () => {
const service = createService({ 'metrics.maxRoutes': 1 });
service.onModuleInit();
service.observe('GET', '/first', 200, 1);
service.observe('GET', '/second', 200, 1);
const snapshot = service.snapshot();
service.onModuleDestroy();
expect(snapshot.routes.map((route) => route.route)).toEqual(
expect.arrayContaining(['GET /first', '__other_routes__']),
);
expect(snapshot.eventLoop).toEqual(
expect.objectContaining({
healthy: expect.any(Boolean),
utilization: expect.any(Number),
lagP99Ms: expect.any(Number),
}),
);
});
});
@@ -0,0 +1,165 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntervalHistogram, monitorEventLoopDelay, performance } from 'perf_hooks';
const LATENCY_BUCKETS_MS = [10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000];
const OVERFLOW_ROUTE = '__other_routes__';
type RouteMetric = {
requests: number;
clientErrors: number;
errors: number;
timeouts: number;
totalDurationMs: number;
maxDurationMs: number;
latencyBuckets: number[];
};
@Injectable()
export class MetricsService implements OnModuleInit, OnModuleDestroy {
private readonly startedAt = Date.now();
private readonly routes = new Map<string, RouteMetric>();
private readonly eventLoopBaseline = performance.eventLoopUtilization();
private eventLoopDelay?: IntervalHistogram;
private totalRequests = 0;
private inFlightRequests = 0;
private peakInFlightRequests = 0;
constructor(private readonly configService: ConfigService) {}
onModuleInit(): void {
const resolution =
this.configService.get<number>('metrics.eventLoopResolutionMs', { infer: true }) ?? 20;
this.eventLoopDelay = monitorEventLoopDelay({ resolution });
this.eventLoopDelay.enable();
}
onModuleDestroy(): void {
this.eventLoopDelay?.disable();
}
requestStarted(): void {
this.totalRequests += 1;
this.inFlightRequests += 1;
this.peakInFlightRequests = Math.max(this.peakInFlightRequests, this.inFlightRequests);
}
requestFinished(method: string, route: string, statusCode: number, durationMs: number): void {
this.inFlightRequests = Math.max(0, this.inFlightRequests - 1);
this.observe(method, route, statusCode, durationMs);
}
observe(method: string, route: string, statusCode: number, durationMs: number): void {
const key = this.resolveRouteKey(`${method.toUpperCase()} ${route}`);
const current = this.routes.get(key) ?? this.createRouteMetric();
current.requests += 1;
current.clientErrors += statusCode >= 400 && statusCode < 500 ? 1 : 0;
current.errors += statusCode >= 500 ? 1 : 0;
current.timeouts += statusCode === 408 ? 1 : 0;
current.totalDurationMs += durationMs;
current.maxDurationMs = Math.max(current.maxDurationMs, durationMs);
const bucketIndex = LATENCY_BUCKETS_MS.findIndex((boundary) => durationMs <= boundary);
current.latencyBuckets[bucketIndex === -1 ? LATENCY_BUCKETS_MS.length : bucketIndex] += 1;
this.routes.set(key, current);
}
snapshot() {
const routes = Array.from(this.routes.entries()).map(([route, metric]) => ({
route,
requests: metric.requests,
clientErrors: metric.clientErrors,
errors: metric.errors,
timeouts: metric.timeouts,
errorRate: metric.requests ? metric.errors / metric.requests : 0,
averageDurationMs: metric.requests
? Math.round((metric.totalDurationMs / metric.requests) * 100) / 100
: 0,
p95DurationMs: this.estimatePercentile(metric, 0.95),
maxDurationMs: metric.maxDurationMs,
}));
const eventLoopUtilization = performance.eventLoopUtilization(this.eventLoopBaseline);
const eventLoop = this.eventLoopSnapshot(eventLoopUtilization.utilization);
return {
uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000),
process: {
pid: process.pid,
nodeVersion: process.version,
platform: process.platform,
},
memory: process.memoryUsage(),
operational: {
totalRequests: this.totalRequests,
inFlightRequests: this.inFlightRequests,
peakInFlightRequests: this.peakInFlightRequests,
},
eventLoop,
routes: routes.sort((left, right) => right.requests - left.requests),
};
}
private createRouteMetric(): RouteMetric {
return {
requests: 0,
clientErrors: 0,
errors: 0,
timeouts: 0,
totalDurationMs: 0,
maxDurationMs: 0,
latencyBuckets: Array.from({ length: LATENCY_BUCKETS_MS.length + 1 }, () => 0),
};
}
private resolveRouteKey(candidate: string): string {
if (this.routes.has(candidate)) {
return candidate;
}
const maxRoutes = this.configService.get<number>('metrics.maxRoutes', { infer: true }) ?? 500;
return this.routes.size < maxRoutes ? candidate : OVERFLOW_ROUTE;
}
private estimatePercentile(metric: RouteMetric, percentile: number): number {
if (!metric.requests) {
return 0;
}
const target = Math.ceil(metric.requests * percentile);
let cumulative = 0;
for (let index = 0; index < metric.latencyBuckets.length; index += 1) {
cumulative += metric.latencyBuckets[index];
if (cumulative >= target) {
return index < LATENCY_BUCKETS_MS.length
? LATENCY_BUCKETS_MS[index]
: Math.ceil(metric.maxDurationMs);
}
}
return Math.ceil(metric.maxDurationMs);
}
private eventLoopSnapshot(utilization: number) {
const warnThresholdMs =
this.configService.get<number>('metrics.eventLoopLagWarnMs', { infer: true }) ?? 100;
const meanMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.mean);
const p50Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(50));
const p95Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(95));
const p99Ms = this.nanosecondsToMilliseconds(this.eventLoopDelay?.percentile(99));
const maxMs = this.nanosecondsToMilliseconds(this.eventLoopDelay?.max);
return {
healthy: p99Ms <= warnThresholdMs,
warnThresholdMs,
utilization: Math.round(utilization * 10_000) / 10_000,
lagMeanMs: meanMs,
lagP50Ms: p50Ms,
lagP95Ms: p95Ms,
lagP99Ms: p99Ms,
lagMaxMs: maxMs,
};
}
private nanosecondsToMilliseconds(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return 0;
}
return Math.round((value / 1_000_000) * 100) / 100;
}
}
@@ -0,0 +1,109 @@
import { ConfigService } from '@nestjs/config';
import { Queue, Worker } from 'bullmq';
import { AppLoggerService } from '../logging/app-logger.service';
import { RedisService } from '../redis/redis.service';
import { AppQueueService } from './app-queue.service';
jest.mock('bullmq', () => ({ Queue: jest.fn(), Worker: jest.fn() }));
describe('AppQueueService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const redis = { isEnabled: jest.fn(), createQueueClient: jest.fn() } as unknown as RedisService;
const logger = { error: jest.fn() } as unknown as AppLoggerService;
let queueInstance: { add: jest.Mock; close: jest.Mock };
let workerInstance: { on: jest.Mock; close: jest.Mock };
let workerProcessor: (job: { name: string; data: unknown }) => Promise<void>;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
queueInstance = { add: jest.fn(), close: jest.fn() };
workerInstance = { on: jest.fn(), close: jest.fn() };
jest.mocked(Queue).mockImplementation(() => queueInstance as never);
jest.mocked(Worker).mockImplementation((_name, processor) => {
workerProcessor = processor as typeof workerProcessor;
return workerInstance as never;
});
});
it('does not connect when queues or Redis are disabled', () => {
(redis.isEnabled as jest.Mock).mockReturnValue(true);
const service = new AppQueueService(config, redis, logger);
service.onModuleInit();
service.onApplicationBootstrap();
expect(Queue).not.toHaveBeenCalled();
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(false);
service.onApplicationBootstrap();
expect(redis.createQueueClient).not.toHaveBeenCalled();
});
it('starts BullMQ with configured defaults and dispatches registered processors', async () => {
Object.assign(values, {
'queue.enabled': true,
'queue.name': 'important-jobs',
'queue.workerConcurrency': 8,
'queue.defaultJobAttempts': 5,
'queue.defaultJobBackoffMs': 250,
'queue.removeOnComplete': false,
});
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValueOnce({ id: 'queue' }).mockReturnValueOnce({ id: 'worker' });
const service = new AppQueueService(config, redis, logger);
const processor = jest.fn().mockResolvedValue(undefined);
service.registerProcessor('send', processor);
service.onApplicationBootstrap();
expect(Queue).toHaveBeenCalledWith('important-jobs', expect.objectContaining({ defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 250 }, removeOnComplete: false } }));
expect(Worker).toHaveBeenCalledWith('important-jobs', expect.any(Function), expect.objectContaining({ concurrency: 8 }));
await workerProcessor({ name: 'send', data: { id: 1 } });
expect(processor).toHaveBeenCalledWith({ id: 1 });
await expect(workerProcessor({ name: 'missing', data: {} })).rejects.toThrow('No processor registered');
const failed = workerInstance.on.mock.calls.find(([event]) => event === 'failed')?.[1] as (
job: { name: string; id: string },
error: Error,
) => void;
failed({ name: 'send', id: 'j1' }, new Error('boom'));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ queue: 'important-jobs', jobId: 'j1', error: 'boom' }), undefined, 'AppQueueService');
});
it('enqueues remotely with per-job overrides and closes resources', async () => {
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValue({});
const service = new AppQueueService(config, redis, logger);
service.onApplicationBootstrap();
await service.enqueue('job', { id: 1 }, { attempts: 9 });
expect(queueInstance.add).toHaveBeenCalledWith('job', { id: 1 }, expect.objectContaining({ attempts: 9, removeOnComplete: true }));
await service.onModuleDestroy();
expect(workerInstance.close).toHaveBeenCalled();
expect(queueInstance.close).toHaveBeenCalled();
});
it('falls back to a microtask and logs processor failures', async () => {
const service = new AppQueueService(config, redis, logger);
const success = jest.fn().mockResolvedValue(undefined);
service.registerProcessor('success', success);
await service.enqueue('success', { ok: true });
await new Promise((resolve) => setImmediate(resolve));
expect(success).toHaveBeenCalledWith({ ok: true });
const failed = jest.fn().mockRejectedValue(new Error('offline'));
service.registerProcessor('failed', failed);
await service.enqueue('failed', { id: 4 });
await new Promise((resolve) => setImmediate(resolve));
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ jobName: 'failed', error: 'offline' }), expect.any(String), 'AppQueueService');
await expect(service.enqueue('unknown', {})).resolves.toBeUndefined();
});
it('aborts startup when either Redis connection cannot be created', () => {
values['queue.enabled'] = true;
(redis.isEnabled as jest.Mock).mockReturnValue(true);
(redis.createQueueClient as jest.Mock).mockReturnValueOnce({}).mockReturnValueOnce(null);
new AppQueueService(config, redis, logger).onApplicationBootstrap();
expect(Queue).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
jest.mock('ioredis', () => ({ __esModule: true, default: jest.fn() }));
describe('RedisService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const clients: Array<{ duplicate: jest.Mock; quit: jest.Mock; disconnect: jest.Mock }> = [];
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
clients.length = 0;
jest.mocked(Redis).mockImplementation(() => {
const duplicateClient = { quit: jest.fn(), disconnect: jest.fn() };
const client = {
duplicate: jest.fn(() => duplicateClient),
quit: jest.fn().mockResolvedValue('OK'),
disconnect: jest.fn(),
};
clients.push(client);
return client as never;
});
});
it('stays inert when disabled and exposes default prefix', () => {
const service = new RedisService(config);
expect(service.isEnabled()).toBe(false);
expect(service.getKeyPrefix()).toBe('oudelaa');
expect(service.getClient()).toBeNull();
expect(service.createPubSubClients()).toBeNull();
expect(service.createQueueClient()).toBeNull();
expect(Redis).not.toHaveBeenCalled();
});
it('creates one shared host-based client and independent queue/pubsub clients', () => {
Object.assign(values, {
'redis.enabled': true,
'redis.keyPrefix': 'test',
'redis.host': 'redis.internal',
'redis.port': 6380,
'redis.username': 'app',
'redis.password': 'secret',
'redis.db': 2,
});
const service = new RedisService(config);
expect(service.getKeyPrefix()).toBe('test');
expect(service.getClient()).toBe(service.getClient());
expect(Redis).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
host: 'redis.internal', port: 6380, username: 'app', password: 'secret', db: 2,
maxRetriesPerRequest: null, lazyConnect: false, enableReadyCheck: true,
}),
);
const pair = service.createPubSubClients();
expect(pair?.pubClient).toBeDefined();
expect(pair?.subClient).toBeDefined();
expect(clients[1].duplicate).toHaveBeenCalled();
expect(service.createQueueClient()).toBeDefined();
});
it('prefers a URL and omits host credentials', () => {
Object.assign(values, { 'redis.enabled': true, 'redis.url': 'rediss://redis.example/1' });
const service = new RedisService(config);
service.getClient();
expect(Redis).toHaveBeenCalledWith('rediss://redis.example/1', {
maxRetriesPerRequest: null,
lazyConnect: false,
enableReadyCheck: true,
});
});
it('quits the shared client and falls back to disconnect on failure', async () => {
values['redis.enabled'] = true;
const service = new RedisService(config);
service.getClient();
clients[0].quit.mockRejectedValue(new Error('closed'));
service.onModuleDestroy();
await new Promise((resolve) => setImmediate(resolve));
expect(clients[0].disconnect).toHaveBeenCalled();
expect(service.getClient()).not.toBe(clients[0]);
});
});
+2 -1
View File
@@ -48,7 +48,8 @@ export class RedisService implements OnModuleDestroy {
onModuleDestroy(): void {
if (this.client) {
void this.client.quit().catch(() => this.client?.disconnect());
const client = this.client;
void client.quit().catch(() => client.disconnect());
this.client = null;
}
}
@@ -0,0 +1,31 @@
import { ConfigService } from '@nestjs/config';
import { ConfigurableHttpServer, configureHttpServer } from './http-server.config';
describe('configureHttpServer', () => {
it('applies transport settings from application configuration', () => {
const values: Record<string, number> = {
'reliability.serverRequestTimeoutMs': 90_000,
'reliability.headersTimeoutMs': 12_000,
'reliability.keepAliveTimeoutMs': 4_000,
'reliability.maxRequestsPerSocket': 500,
};
const config = {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
const server: ConfigurableHttpServer = {
requestTimeout: 0,
headersTimeout: 0,
keepAliveTimeout: 0,
maxRequestsPerSocket: 0,
};
configureHttpServer(server, config);
expect(server).toEqual({
requestTimeout: 90_000,
headersTimeout: 12_000,
keepAliveTimeout: 4_000,
maxRequestsPerSocket: 500,
});
});
});
@@ -0,0 +1,22 @@
import { ConfigService } from '@nestjs/config';
export type ConfigurableHttpServer = {
requestTimeout: number;
headersTimeout: number;
keepAliveTimeout: number;
maxRequestsPerSocket: number;
};
export const configureHttpServer = (
server: ConfigurableHttpServer,
configService: ConfigService,
): void => {
server.requestTimeout =
configService.get<number>('reliability.serverRequestTimeoutMs', { infer: true }) ?? 120_000;
server.headersTimeout =
configService.get<number>('reliability.headersTimeoutMs', { infer: true }) ?? 15_000;
server.keepAliveTimeout =
configService.get<number>('reliability.keepAliveTimeoutMs', { infer: true }) ?? 5_000;
server.maxRequestsPerSocket =
configService.get<number>('reliability.maxRequestsPerSocket', { infer: true }) ?? 1_000;
};
@@ -0,0 +1,43 @@
import { validationSchema } from '../../config/validation.schema';
describe('reliability environment validation', () => {
const validEnvironment = {
NODE_ENV: 'test',
MONGODB_URI: 'mongodb://127.0.0.1:27017/test',
JWT_ACCESS_SECRET: 'access-secret-at-least-16',
JWT_REFRESH_SECRET: 'refresh-secret-at-least-16',
SUPERADMIN_EMAIL: 'admin@example.com',
SUPERADMIN_PASSWORD: 'StrongPassword123!',
SUPERADMIN_ACCESS_SECRET: 'admin-access-secret-at-least-16',
SUPERADMIN_REFRESH_SECRET: 'admin-refresh-secret-at-least-16',
STORAGE_PROVIDER: 'local',
MEDIA_ACCESS_MODE: 'direct',
};
it('applies safe reliability defaults', () => {
const { error, value } = validationSchema.validate(validEnvironment);
expect(error).toBeUndefined();
expect(value).toEqual(
expect.objectContaining({
HTTP_REQUEST_TIMEOUT_MS: 30_000,
HTTP_SERVER_REQUEST_TIMEOUT_MS: 120_000,
HTTP_HEADERS_TIMEOUT_MS: 15_000,
HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000,
SHUTDOWN_GRACE_PERIOD_MS: 30_000,
}),
);
});
it('rejects unsafe or contradictory timeout settings', () => {
expect(
validationSchema.validate({ ...validEnvironment, HTTP_REQUEST_TIMEOUT_MS: 500 }).error,
).toBeDefined();
expect(
validationSchema.validate({
...validEnvironment,
HTTP_HEADERS_TIMEOUT_MS: 4_000,
HTTP_KEEP_ALIVE_TIMEOUT_MS: 5_000,
}).error,
).toBeDefined();
});
});
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestTimeoutInterceptor } from './request-timeout.interceptor';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
@Global()
@Module({
providers: [
ShutdownCoordinatorService,
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
],
exports: [ShutdownCoordinatorService],
})
export class ReliabilityModule {}
@@ -0,0 +1,75 @@
import { CallHandler, ExecutionContext, RequestTimeoutException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { firstValueFrom, of, timer } from 'rxjs';
import { RequestTimeoutInterceptor } from './request-timeout.interceptor';
describe('RequestTimeoutInterceptor', () => {
const createContext = (url = '/api/v1/feed/me') =>
({
getType: () => 'http',
getHandler: () => createContext,
getClass: () => RequestTimeoutInterceptor,
switchToHttp: () => ({ getRequest: () => ({ originalUrl: url }) }),
}) as unknown as ExecutionContext;
const createCoordinator = (draining = false) => ({
isDraining: jest.fn().mockReturnValue(draining),
beginRequest: jest.fn().mockReturnValue(!draining),
endRequest: jest.fn(),
});
const createInterceptor = (options: { timeoutMs?: number; override?: number; draining?: boolean }) => {
const config = {
get: jest.fn().mockReturnValue(options.timeoutMs ?? 10),
} as unknown as ConfigService;
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(options.override),
} as unknown as Reflector;
const coordinator = createCoordinator(options.draining);
return {
coordinator,
interceptor: new RequestTimeoutInterceptor(config, reflector, coordinator as any),
};
};
it('returns the response and releases the in-flight request', async () => {
const { interceptor, coordinator } = createInterceptor({ timeoutMs: 50 });
const handler: CallHandler = { handle: () => of({ ok: true }) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toEqual({
ok: true,
});
expect(coordinator.beginRequest).toHaveBeenCalledTimes(1);
expect(coordinator.endRequest).toHaveBeenCalledTimes(1);
});
it('turns a slow handler into HTTP 408 and releases the request', async () => {
const { interceptor, coordinator } = createInterceptor({ timeoutMs: 5 });
const handler: CallHandler = { handle: () => timer(30) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toBeInstanceOf(
RequestTimeoutException,
);
expect(coordinator.endRequest).toHaveBeenCalledTimes(1);
});
it('allows a route-level zero override to disable the timeout', async () => {
const { interceptor } = createInterceptor({ timeoutMs: 5, override: 0 });
const handler: CallHandler = { handle: () => timer(15) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).resolves.toBe(0);
});
it('rejects new work while draining but still allows readiness checks', async () => {
const { interceptor } = createInterceptor({ draining: true });
const handler: CallHandler = { handle: () => of({ status: 'draining' }) };
await expect(firstValueFrom(interceptor.intercept(createContext(), handler))).rejects.toMatchObject({
status: 503,
});
await expect(
firstValueFrom(interceptor.intercept(createContext('/api/v1/health/ready'), handler)),
).resolves.toEqual({ status: 'draining' });
});
});
@@ -0,0 +1,91 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
RequestTimeoutException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { Observable, TimeoutError, catchError, finalize, throwError, timeout } from 'rxjs';
import { REQUEST_TIMEOUT_METADATA } from '../../common/decorators/request-timeout.decorator';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
type HttpRequest = {
originalUrl?: string;
url?: string;
};
@Injectable()
export class RequestTimeoutInterceptor implements NestInterceptor {
constructor(
private readonly configService: ConfigService,
private readonly reflector: Reflector,
private readonly shutdownCoordinator: ShutdownCoordinatorService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') {
return next.handle();
}
const request = context.switchToHttp().getRequest<HttpRequest>();
const healthRequest = this.isHealthRequest(request.originalUrl ?? request.url ?? '');
if (this.shutdownCoordinator.isDraining()) {
if (healthRequest) {
return next.handle();
}
return throwError(
() =>
new ServiceUnavailableException({
statusCode: 503,
error: 'Service Unavailable',
message: 'Server is shutting down; retry the request on another instance',
}),
);
}
if (!this.shutdownCoordinator.beginRequest()) {
return throwError(() => new ServiceUnavailableException('Server is shutting down'));
}
const timeoutOverride = this.reflector.getAllAndOverride<number>(REQUEST_TIMEOUT_METADATA, [
context.getHandler(),
context.getClass(),
]);
const timeoutMs =
timeoutOverride ??
this.configService.get<number>('reliability.requestTimeoutMs', { infer: true }) ??
30_000;
const response$ = next.handle();
if (timeoutMs <= 0) {
return response$.pipe(finalize(() => this.shutdownCoordinator.endRequest()));
}
return response$.pipe(
timeout({ first: timeoutMs }),
catchError((error: unknown) => {
if (error instanceof TimeoutError) {
return throwError(
() =>
new RequestTimeoutException({
statusCode: 408,
error: 'Request Timeout',
message: `Request did not complete within ${timeoutMs}ms`,
}),
);
}
return throwError(() => error);
}),
finalize(() => this.shutdownCoordinator.endRequest()),
);
}
private isHealthRequest(url: string): boolean {
const path = url.split('?', 1)[0].replace(/\/+$/, '');
return /\/health(?:\/ready)?$/.test(path);
}
}
@@ -0,0 +1,35 @@
import { ConfigService } from '@nestjs/config';
import { AppLoggerService } from '../logging/app-logger.service';
import { ShutdownCoordinatorService } from './shutdown-coordinator.service';
describe('ShutdownCoordinatorService', () => {
const createService = () => {
const config = { get: jest.fn().mockReturnValue(25) } as unknown as ConfigService;
const logger = { warn: jest.fn(), log: jest.fn() } as unknown as AppLoggerService;
return { logger, service: new ShutdownCoordinatorService(config, logger) };
};
it('tracks active requests without allowing the counter below zero', () => {
const { service } = createService();
expect(service.beginRequest()).toBe(true);
expect(service.getActiveRequests()).toBe(1);
service.endRequest();
service.endRequest();
expect(service.getActiveRequests()).toBe(0);
});
it('enters drain mode before shutdown and refuses new requests', async () => {
const { logger, service } = createService();
await service.beforeApplicationShutdown('SIGTERM');
expect(service.getState()).toBe('draining');
expect(service.beginRequest()).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('SIGTERM'),
'ShutdownCoordinator',
);
service.onApplicationShutdown('SIGTERM');
expect(service.getState()).toBe('stopped');
});
});
@@ -0,0 +1,79 @@
import {
BeforeApplicationShutdown,
Injectable,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { setTimeout as delay } from 'timers/promises';
import { AppLoggerService } from '../logging/app-logger.service';
export type ApplicationLifecycleState = 'running' | 'draining' | 'stopped';
@Injectable()
export class ShutdownCoordinatorService
implements BeforeApplicationShutdown, OnApplicationShutdown
{
private state: ApplicationLifecycleState = 'running';
private activeRequests = 0;
constructor(
private readonly configService: ConfigService,
private readonly logger: AppLoggerService,
) {}
getState(): ApplicationLifecycleState {
return this.state;
}
isDraining(): boolean {
return this.state !== 'running';
}
getActiveRequests(): number {
return this.activeRequests;
}
beginRequest(): boolean {
if (this.state !== 'running') {
return false;
}
this.activeRequests += 1;
return true;
}
endRequest(): void {
this.activeRequests = Math.max(0, this.activeRequests - 1);
}
async beforeApplicationShutdown(signal?: string): Promise<void> {
this.state = 'draining';
const gracePeriodMs =
this.configService.get<number>('reliability.shutdownGracePeriodMs', { infer: true }) ??
30_000;
const deadline = Date.now() + gracePeriodMs;
this.logger.warn(
`Shutdown initiated${signal ? ` by ${signal}` : ''}; draining ${this.activeRequests} active request(s) for up to ${gracePeriodMs}ms`,
'ShutdownCoordinator',
);
while (this.activeRequests > 0 && Date.now() < deadline) {
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
}
if (this.activeRequests > 0) {
this.logger.warn(
`Grace period elapsed with ${this.activeRequests} active request(s) still running`,
'ShutdownCoordinator',
);
}
}
onApplicationShutdown(signal?: string): void {
this.state = 'stopped';
this.logger.log(
`Shutdown complete${signal ? ` (${signal})` : ''}`,
'ShutdownCoordinator',
);
}
}
@@ -0,0 +1,43 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { RedisService } from '../redis/redis.service';
import { RedisIoAdapter } from './redis-io.adapter';
jest.mock('@socket.io/redis-adapter', () => ({ createAdapter: jest.fn(() => 'redis-adapter') }));
describe('RedisIoAdapter', () => {
const redis = { createPubSubClients: jest.fn() } as unknown as RedisService;
let createServer: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
createServer = jest.spyOn(IoAdapter.prototype, 'createIOServer').mockReturnValue({ adapter: jest.fn() });
});
afterEach(() => createServer.mockRestore());
it('uses the default Socket.IO adapter without Redis clients', async () => {
(redis.createPubSubClients as jest.Mock).mockReturnValue(null);
const adapter = new RedisIoAdapter({} as never, redis);
await adapter.connectToRedis();
const server = adapter.createIOServer(4000, { path: '/socket' } as never);
expect(createAdapter).not.toHaveBeenCalled();
expect(server.adapter).not.toHaveBeenCalled();
});
it('attaches Redis adapter and closes both clients safely', async () => {
const pubClient = { quit: jest.fn().mockResolvedValue('OK'), disconnect: jest.fn() };
const subClient = { quit: jest.fn().mockRejectedValue(new Error('closed')), disconnect: jest.fn() };
(redis.createPubSubClients as jest.Mock).mockReturnValue({ pubClient, subClient });
const adapter = new RedisIoAdapter({} as never, redis);
await adapter.connectToRedis();
const server = adapter.createIOServer(4000);
expect(createAdapter).toHaveBeenCalledWith(pubClient, subClient);
expect(server.adapter).toHaveBeenCalledWith('redis-adapter');
await adapter.close();
expect(pubClient.quit).toHaveBeenCalled();
expect(subClient.disconnect).toHaveBeenCalled();
await adapter.close();
expect(pubClient.quit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,88 @@
import { ConfigService } from '@nestjs/config';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { ImageProcessingService } from './image-processing.service';
jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), readFile: jest.fn(), rm: jest.fn(), writeFile: jest.fn() }));
type ImageInternals = {
ensureFfmpegAvailable(): Promise<boolean>;
runFfmpeg(args: string[]): Promise<void>;
};
const internals = (service: ImageProcessingService): ImageInternals =>
service as unknown as ImageInternals;
describe('ImageProcessingService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/image-work');
(writeFile as jest.Mock).mockResolvedValue(undefined);
(readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path));
(rm as jest.Mock).mockResolvedValue(undefined);
});
it.each([
[{ originalname: 'photo.PNG', mimetype: '', size: 1, buffer: Buffer.from('x') }, '.png', 'image/png'],
[{ mimetype: 'image/webp', size: 1, buffer: Buffer.from('x') }, '.webp', 'image/webp'],
[{ mimetype: 'image/gif', size: 1, buffer: Buffer.from('x') }, '.gif', 'image/gif'],
[{ mimetype: 'unknown', size: 1, buffer: Buffer.from('x') }, '.jpg', 'unknown'],
])('returns original when disabled %#', async (file, extension, contentType) => {
const result = await new ImageProcessingService(config).processForResponsiveDelivery(file);
expect(result).toEqual({ primaryVariantName: 'original', variants: [expect.objectContaining({ relativePath: `original${extension}`, contentType })] });
});
it('falls back when ffmpeg is unavailable', async () => {
values['imageProcessing.enabled'] = true;
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(false);
const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('original');
expect(mkdtemp).not.toHaveBeenCalled();
});
it('creates low, medium, and high WebP variants', async () => {
Object.assign(values, {
'imageProcessing.enabled': true, 'imageProcessing.lowWidth': 320,
'imageProcessing.mediumWidth': 640, 'imageProcessing.highWidth': 1024,
'imageProcessing.quality': 70,
});
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const result = await service.processForResponsiveDelivery({ originalname: 'x.jpg', mimetype: 'image/jpeg', size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('medium');
expect(result.variants.map((item) => item.name)).toEqual(['original', 'low', 'medium', 'high']);
expect(ffmpeg).toHaveBeenCalledTimes(3);
expect(ffmpeg).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining('min(320,iw)'), '70']));
expect(rm).toHaveBeenCalledWith('/tmp/image-work', { recursive: true, force: true });
});
it('returns the original and cleans up after conversion errors', async () => {
values['videoProcessing.enabled'] = true;
const service = new ImageProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
(writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
const result = await service.processForResponsiveDelivery({ size: 1, buffer: Buffer.from('x') });
expect(result.primaryVariantName).toBe('original');
expect(rm).toHaveBeenCalled();
});
it('probes ffmpeg once and caches both success and failure', async () => {
values['imageProcessing.ffmpegPath'] = ' custom-ffmpeg ';
const success = new ImageProcessingService(config);
const successRun = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined);
await expect((success as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable()).resolves.toBe(true);
await (success as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable();
expect(successRun).toHaveBeenCalledTimes(1);
const failure = new ImageProcessingService(config);
const failRun = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing');
await expect((failure as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable()).resolves.toBe(false);
await (failure as never as { ensureFfmpegAvailable(): Promise<boolean> }).ensureFfmpegAvailable();
expect(failRun).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,190 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { MediaStorageService } from '../../common/media/media-storage.service';
import { ManagedStorageService } from './managed-storage.service';
jest.mock('fs/promises', () => ({
access: jest.fn(), mkdir: jest.fn(), rm: jest.fn(), stat: jest.fn(), unlink: jest.fn(), writeFile: jest.fn(),
}));
type ManagedInternals = {
deleteS3Prefix(prefix: string): Promise<void>;
getS3Bucket(): string;
getS3Client(): { send(command: unknown): Promise<unknown> };
resolveCacheControl(key: string): string;
};
const internals = (service: ManagedStorageService): ManagedInternals =>
service as unknown as ManagedInternals;
describe('ManagedStorageService', () => {
const values: Record<string, unknown> = {
'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const media = {
uploadFile: jest.fn(), uploadFileToKey: jest.fn(), deleteFile: jest.fn(),
} as unknown as MediaStorageService;
beforeEach(() => {
jest.clearAllMocks();
Object.assign(values, {
'storage.provider': 'local', 'storage.basePath': 'uploads', publicBaseUrl: 'https://api.test',
'storage.publicBaseUrl': '', 'storage.s3.bucket': '', 'storage.s3.endpoint': '',
'storage.s3.accessKeyId': '', 'storage.s3.secretAccessKey': '',
'storage.s3.forcePathStyle': false, 'storage.s3.healthWriteTestEnabled': false,
});
(mkdir as jest.Mock).mockResolvedValue(undefined);
(writeFile as jest.Mock).mockResolvedValue(undefined);
(unlink as jest.Mock).mockResolvedValue(undefined);
(rm as jest.Mock).mockResolvedValue(undefined);
(stat as jest.Mock).mockResolvedValue({});
(access as jest.Mock).mockResolvedValue(undefined);
});
it('saves one or many files locally with normalized managed paths', async () => {
const service = new ManagedStorageService(config, media);
const one = await service.saveFile({
folderSegments: ['/posts\\images/', 'user-1'], extension: '.jpg', buffer: Buffer.from('jpg'),
contentType: 'image/jpeg', fileNamePrefix: 'photo',
});
expect(one).toMatch(/^\/uploads\/posts\/images\/user-1\/photo-[0-9a-f-]+\.jpg$/);
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('photo-'), Buffer.from('jpg'));
await expect(service.saveFiles({ folderSegments: [], files: [] })).resolves.toEqual({});
await expect(service.saveFiles({
folderSegments: ['hls'],
files: [
{ relativePath: 'master.m3u8', buffer: Buffer.from('playlist') },
{ relativePath: 'stream_0\\segment.m4s', buffer: Buffer.from('segment') },
],
})).resolves.toEqual({
'master.m3u8': '/uploads/hls/master.m3u8',
'stream_0/segment.m4s': '/uploads/hls/stream_0/segment.m4s',
});
expect(mkdir).toHaveBeenCalled();
});
it.each(['', '.', '../secret', 'a//b'])('rejects unsafe relative file path %p', async (relativePath) => {
const service = new ManagedStorageService(config, media);
await expect(service.saveFiles({ folderSegments: [], files: [{ relativePath, buffer: Buffer.alloc(1) }] }))
.rejects.toBeInstanceOf(BadRequestException);
});
it('delegates S3 writes and returns uploaded URLs', async () => {
values['storage.provider'] = 's3';
(media.uploadFile as jest.Mock).mockResolvedValue({ url: 'https://cdn/one.jpg' });
(media.uploadFileToKey as jest.Mock).mockResolvedValue({ url: 'https://cdn/master.m3u8' });
const service = new ManagedStorageService(config, media);
await expect(service.saveFile({ folderSegments: ['posts'], extension: '.jpg', buffer: Buffer.from('x') }))
.resolves.toBe('https://cdn/one.jpg');
expect(media.uploadFile).toHaveBeenCalledWith(expect.objectContaining({ size: 1 }), 'posts');
await expect(service.saveFiles({ folderSegments: ['hls'], files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('x') }] }))
.resolves.toEqual({ 'master.m3u8': 'https://cdn/master.m3u8' });
expect(media.uploadFileToKey).toHaveBeenCalledWith(expect.anything(), 'uploads/hls/master.m3u8');
});
it('deletes and resolves only safe local managed paths', async () => {
const service = new ManagedStorageService(config, media);
await service.deleteFile();
await service.deleteFile('https://external/file.jpg');
await service.deleteFile('/uploads/../secret');
expect(unlink).not.toHaveBeenCalled();
await service.deleteFile('/uploads/posts/file.jpg?token=one');
expect(unlink).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]file\.jpg$/));
(unlink as jest.Mock).mockRejectedValueOnce(new Error('missing'));
await expect(service.deleteFile('/uploads/posts/missing.jpg')).resolves.toBeUndefined();
expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toMatch(/uploads[\\/]posts[\\/]file\.jpg$/);
expect(service.resolveLocalFilePath('/other/file.jpg')).toBeNull();
values['storage.provider'] = 's3';
expect(service.resolveLocalFilePath('/uploads/posts/file.jpg')).toBeNull();
});
it('deletes safe containing directories locally without deleting the root', async () => {
const service = new ManagedStorageService(config, media);
await service.deleteContainingDirectory();
await service.deleteContainingDirectory('/uploads/file.jpg');
await service.deleteContainingDirectory('/uploads/../secret/file.jpg');
expect(rm).not.toHaveBeenCalled();
await service.deleteContainingDirectory('/uploads/posts/hls/master.m3u8');
expect(rm).toHaveBeenCalledWith(expect.stringMatching(/uploads[\\/]posts[\\/]hls$/), { recursive: true, force: true });
});
it('deletes S3 objects and containing prefixes resolved from public and endpoint URLs', async () => {
Object.assign(values, {
'storage.provider': 's3', 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
'storage.s3.forcePathStyle': true,
});
const service = new ManagedStorageService(config, media);
await service.deleteFile('https://cdn.test/uploads/posts/file.jpg?x=1');
expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/file.jpg');
const deletePrefix = jest.spyOn(internals(service), 'deleteS3Prefix').mockResolvedValue(undefined);
await service.deleteContainingDirectory('https://cdn.test/uploads/posts/hls/master.m3u8');
expect(deletePrefix).toHaveBeenCalledWith('uploads/posts/hls/');
await service.deleteContainingDirectory('https://cdn.test/uploads/file.jpg');
expect(deletePrefix).toHaveBeenCalledTimes(1);
values['storage.publicBaseUrl'] = '';
await service.deleteFile('https://s3.test/bucket/uploads/posts/two.jpg');
expect(media.deleteFile).toHaveBeenCalledWith('uploads/posts/two.jpg');
});
it('reports healthy and failed local probes', async () => {
const service = new ManagedStorageService(config, media);
await expect(service.getHealth()).resolves.toEqual(expect.objectContaining({
provider: 'local', isLocalStorage: true, uploadPathExists: true,
uploadPathReadable: true, uploadPathWritable: true,
}));
(writeFile as jest.Mock).mockRejectedValueOnce(new Error('read only'));
(unlink as jest.Mock).mockRejectedValueOnce(new Error('not created'));
(stat as jest.Mock).mockRejectedValueOnce(new Error('missing'));
(access as jest.Mock).mockRejectedValueOnce(new Error('denied'));
const failed = await service.getHealth();
expect(failed).toEqual(expect.objectContaining({ uploadPathExists: false, uploadPathReadable: false, uploadPathWritable: false }));
expect(failed.local).toEqual(expect.objectContaining({ error: 'read only' }));
});
it('reports incomplete and reachable S3 health including write probes', async () => {
values['storage.provider'] = 's3';
const service = new ManagedStorageService(config, media);
expect(await service.getHealth()).toEqual(expect.objectContaining({ s3: expect.objectContaining({ configured: false, reachable: false }) }));
Object.assign(values, {
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.healthWriteTestEnabled': true,
});
const client = { send: jest.fn().mockResolvedValue({}) };
jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
const healthy = await service.getHealth();
expect(healthy.s3).toEqual(expect.objectContaining({ configured: true, reachable: true, writable: true }));
expect(client.send).toHaveBeenCalledTimes(3);
client.send.mockRejectedValueOnce(new Error('unreachable'));
const unhealthy = await service.getHealth();
expect(unhealthy.s3).toEqual(expect.objectContaining({ reachable: false, writable: false, error: 'unreachable' }));
service.onModuleDestroy();
});
it('validates S3 setup and paginates prefix deletion', async () => {
values['storage.provider'] = 's3';
const service = new ManagedStorageService(config, media);
expect(() => internals(service).getS3Bucket()).toThrow(BadRequestException);
expect(() => internals(service).getS3Client()).toThrow(BadRequestException);
Object.assign(values, { 'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test', 'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret' });
const client = { send: jest.fn()
.mockResolvedValueOnce({ Contents: [{ Key: 'uploads/a' }, {}], IsTruncated: true, NextContinuationToken: 'next' })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ Contents: [{ Key: 'uploads/b' }], IsTruncated: false })
.mockResolvedValueOnce({}) };
jest.spyOn(internals(service), 'getS3Client').mockReturnValue(client);
await internals(service).deleteS3Prefix('uploads/posts/');
expect(client.send.mock.calls.filter(([command]) => command instanceof ListObjectsV2Command)).toHaveLength(2);
expect(client.send.mock.calls.filter(([command]) => command instanceof DeleteObjectsCommand)).toHaveLength(2);
expect(internals(service).resolveCacheControl('master.m3u8')).toBe('public, max-age=300');
expect(internals(service).resolveCacheControl('file.jpg')).toContain('immutable');
});
});
@@ -239,14 +239,11 @@ export class ManagedStorageService implements OnModuleDestroy {
const exists = await this.pathExists(uploadDir);
const readable = await this.canAccess(uploadDir, constants.R_OK);
health.local = {
runtimePath: uploadDir,
absolutePath: uploadDir,
exists,
writable,
readable,
error,
};
health.absolutePath = uploadDir;
health.uploadPathExists = exists;
health.uploadPathReadable = readable;
health.uploadPathWritable = writable;
@@ -0,0 +1,142 @@
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { MediaProbeService } from './media-probe.service';
jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({ mkdtemp: jest.fn(), rm: jest.fn(), writeFile: jest.fn() }));
type FakeChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: jest.Mock;
};
const fakeChild = (): FakeChild => {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = jest.fn();
return child;
};
type ProbeInternals = {
runCommand(command: string, args: string[], timeout: number): Promise<string>;
};
const internals = (service: MediaProbeService): ProbeInternals =>
service as unknown as ProbeInternals;
describe('MediaProbeService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
beforeEach(() => {
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/media-probe');
(writeFile as jest.Mock).mockResolvedValue(undefined);
(rm as jest.Mock).mockResolvedValue(undefined);
});
it('derives ffprobe beside configured ffmpeg on Windows and Unix', () => {
values['videoProcessing.ffmpegPath'] = ' C:\\ffmpeg\\ffmpeg.exe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('C:\\ffmpeg\\ffprobe.exe');
values['videoProcessing.ffmpegPath'] = '/usr/local/bin/ffmpeg';
expect(new MediaProbeService(config).getFfprobePath()).toBe('/usr/local/bin/ffprobe');
values['videoProcessing.ffprobePath'] = ' custom-probe ';
expect(new MediaProbeService(config).getFfprobePath()).toBe('custom-probe');
values['videoProcessing.ffprobePath'] = '';
values['videoProcessing.ffmpegPath'] = 'wrapper';
expect(new MediaProbeService(config).getFfprobePath()).toBe('ffprobe');
});
it('caches successful and failed command probes', async () => {
const success = new MediaProbeService(config);
const run = jest.spyOn(internals(success), 'runCommand').mockResolvedValue('ffmpeg version 7\nother');
await expect(success.checkFfmpeg()).resolves.toEqual({ path: 'ffmpeg', available: true, version: 'ffmpeg version 7' });
await success.checkFfmpeg();
expect(run).toHaveBeenCalledTimes(1);
const failure = new MediaProbeService(config);
const fail = jest.spyOn(internals(failure), 'runCommand').mockRejectedValue('not installed');
await expect(failure.checkFfprobe()).resolves.toEqual({ path: 'ffprobe', available: false, version: '', error: 'unknown command error' });
await failure.checkFfprobe();
expect(fail).toHaveBeenCalledTimes(1);
});
it('extracts rounded positive durations and rejects unusable output', async () => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: true, version: '1' });
const run = jest.spyOn(internals(service), 'runCommand');
run.mockResolvedValueOnce('12.6\n').mockResolvedValueOnce('0').mockResolvedValueOnce('NaN').mockRejectedValueOnce(new Error('bad media'));
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBe(13);
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: false, version: '' });
await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
});
it.each([
['clip.mp4', undefined, '.mp4'], [undefined, 'video/quicktime', '.mov'],
[undefined, 'video/webm', '.webm'], [undefined, 'audio/mpeg', '.mp3'],
[undefined, 'audio/x-m4a', '.m4a'], [undefined, 'audio/x-wav', '.wav'],
[undefined, 'audio/aac', '.aac'], [undefined, 'audio/ogg', '.ogg'],
[undefined, 'other', '.media'],
])('uses safe buffer extension for %p/%p', async (originalname, mimetype, expected) => {
const service = new MediaProbeService(config);
jest.spyOn(service, 'extractDurationSeconds').mockResolvedValue(8);
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('content'), { originalname, mimetype })).resolves.toBe(8);
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining(expected), Buffer.from('content'));
expect(rm).toHaveBeenCalledWith('/tmp/media-probe', { recursive: true, force: true });
});
it('handles empty buffers and temporary file failures', async () => {
const service = new MediaProbeService(config);
await expect(service.extractDurationSecondsFromBuffer(Buffer.alloc(0))).resolves.toBeNull();
expect(mkdtemp).not.toHaveBeenCalled();
(writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
await expect(service.extractDurationSecondsFromBuffer(Buffer.from('x'), { originalname: 'bad.mp4' })).resolves.toBeNull();
expect(rm).toHaveBeenCalled();
});
it('runs commands and handles output, process errors, and nonzero exits', async () => {
const service = new MediaProbeService(config);
const invoke = internals(service).runCommand.bind(service);
const success = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(success);
const successPromise = invoke('probe', ['-version'], 100);
success.stdout.emit('data', Buffer.from('version'));
success.stderr.emit('data', Buffer.from('warning'));
success.emit('close', 0);
await expect(successPromise).resolves.toBe('version');
const failed = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(failed);
const failedPromise = invoke('probe', [], 100);
failed.stderr.emit('data', Buffer.from('invalid'));
failed.emit('close', 2);
await expect(failedPromise).rejects.toThrow('invalid');
const errored = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(errored);
const errorPromise = invoke('probe', [], 100);
errored.emit('error', new Error('spawn error'));
await expect(errorPromise).rejects.toThrow('spawn error');
});
it('kills commands that exceed their timeout', async () => {
jest.useFakeTimers();
const service = new MediaProbeService(config);
const child = fakeChild();
(spawn as jest.Mock).mockReturnValue(child);
const promise = internals(service).runCommand('slow', [], 10);
jest.advanceTimersByTime(11);
await expect(promise).rejects.toThrow('timed out after 10ms');
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
jest.useRealTimers();
});
});
@@ -0,0 +1,271 @@
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
import { VideoProcessingService } from './video-processing.service';
jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({
mkdir: jest.fn(), mkdtemp: jest.fn(), readFile: jest.fn(), readdir: jest.fn(), rm: jest.fn(), writeFile: jest.fn(),
}));
type Internals = {
ensureFfmpegAvailable(): Promise<boolean>;
runFfmpeg(args: string[]): Promise<void>;
runCommand(command: string, args: string[]): Promise<string>;
generateHlsPackage(input: string, output: string): Promise<any>;
generateSingleRenditionHlsPackage(input: string, output: string): Promise<any>;
buildAdaptiveHlsFilterComplex(renditions: Array<{ width: number }>): string;
buildHlsRenditions(width: number): Array<Record<string, number>>;
resolveHlsRendition(width: number): Record<string, number>;
probeVideo(path: string): Promise<{ hasAudio: boolean; width: number }>;
readFilesRecursively(base: string, current?: string): Promise<any[]>;
resolveStreamingContentType(name: string): string;
resolveInputExtension(file: any): string;
buildOptimizedFileName(name?: string): string;
buildVideoFilter(): string;
buildThumbnailFilter(): string;
getFfprobePath(): string;
};
const internals = (service: VideoProcessingService): Internals => service as unknown as Internals;
type FakeChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
const fakeChild = (): FakeChild => {
const child = new EventEmitter() as FakeChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
};
describe('VideoProcessingService', () => {
const values: Record<string, unknown> = {};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const file = { originalname: 'concert.mov', mimetype: 'video/quicktime', size: 5, buffer: Buffer.from('video') };
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
Object.keys(values).forEach((key) => delete values[key]);
(mkdtemp as jest.Mock).mockResolvedValue('/tmp/video-work');
(mkdir as jest.Mock).mockResolvedValue(undefined);
(writeFile as jest.Mock).mockResolvedValue(undefined);
(readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path.includes('thumbnail') ? 'thumb' : 'optimized'));
(rm as jest.Mock).mockResolvedValue(undefined);
});
it('returns uploads untouched when processing is disabled or ffmpeg unavailable', async () => {
const disabled = new VideoProcessingService(config);
await expect(disabled.optimizeForPlayback(file)).resolves.toEqual({ file });
expect(mkdtemp).not.toHaveBeenCalled();
values['videoProcessing.enabled'] = true;
const unavailable = new VideoProcessingService(config);
jest.spyOn(internals(unavailable), 'ensureFfmpegAvailable').mockResolvedValue(false);
await expect(unavailable.optimizeForPlayback(file)).resolves.toEqual({ file });
});
it('optimizes video and returns optional thumbnail and HLS assets', async () => {
Object.assign(values, {
'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true,
'videoProcessing.generateHls': true, 'videoProcessing.maxWidth': 1080,
'videoProcessing.thumbnailWidth': 600, 'videoProcessing.maxFps': 24,
'videoProcessing.crf': 25, 'videoProcessing.preset': ' fast ',
'videoProcessing.audioBitrateKbps': 96,
});
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const hls = { playlistRelativePath: 'master.m3u8', files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('hls'), contentType: 'application/vnd.apple.mpegurl' }] };
jest.spyOn(internals(service), 'generateHlsPackage').mockResolvedValue(hls);
const result = await service.optimizeForPlayback(file);
expect(result.file).toEqual(expect.objectContaining({ mimetype: 'video/mp4', originalname: 'concert-optimized.mp4', size: 9 }));
expect(result.generatedThumbnail).toEqual({ buffer: Buffer.from('thumb'), extension: '.jpg', contentType: 'image/jpeg' });
expect(result.generatedHls).toEqual(hls);
expect(ffmpeg).toHaveBeenCalledTimes(2);
expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining(['-crf', '25', '-preset', 'fast', '-b:a', '96k']));
expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(1080,iw)')]));
expect(ffmpeg.mock.calls[1][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(600,iw)')]));
expect(rm).toHaveBeenCalledWith('/tmp/video-work', { recursive: true, force: true });
});
it('keeps optimized output when thumbnail and HLS generation fail', async () => {
Object.assign(values, { 'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true, 'videoProcessing.generateHls': true });
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValueOnce(undefined).mockRejectedValueOnce('thumbnail failed');
jest.spyOn(internals(service), 'generateHlsPackage').mockRejectedValue('HLS failed');
const result = await service.optimizeForPlayback(file);
expect(result.file.mimetype).toBe('video/mp4');
expect(result.generatedThumbnail).toBeUndefined();
expect(result.generatedHls).toBeUndefined();
});
it('falls back to original and cleans up after optimization errors', async () => {
values['videoProcessing.enabled'] = true;
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
jest.spyOn(internals(service), 'runFfmpeg').mockRejectedValue(new Error('codec failed'));
await expect(service.optimizeForPlayback(file)).resolves.toEqual({ file });
expect(rm).toHaveBeenCalled();
});
it('supports configured and inferred executable paths and upload extensions', () => {
const service = new VideoProcessingService(config);
values['videoProcessing.ffmpegPath'] = 'C:\\bin\\ffmpeg.exe';
expect(internals(service).getFfprobePath()).toBe('C:\\bin\\ffprobe.exe');
values['videoProcessing.ffprobePath'] = ' custom-ffprobe ';
expect(internals(service).getFfprobePath()).toBe('custom-ffprobe');
values['videoProcessing.ffprobePath'] = '';
values['videoProcessing.ffmpegPath'] = 'wrapper';
expect(internals(service).getFfprobePath()).toBe('ffprobe');
expect(internals(service).buildOptimizedFileName()).toBe('video-optimized.mp4');
expect(internals(service).buildOptimizedFileName('a.b.webm')).toBe('a.b-optimized.mp4');
expect(internals(service).resolveInputExtension({ originalname: 'A.MOV' })).toBe('.mov');
expect(internals(service).resolveInputExtension({ mimetype: 'video/webm' })).toBe('.webm');
expect(internals(service).resolveInputExtension({ mimetype: 'video/x-matroska' })).toBe('.mkv');
expect(internals(service).resolveInputExtension({ mimetype: 'video/x-msvideo' })).toBe('.avi');
expect(internals(service).resolveInputExtension({ mimetype: 'unknown' })).toBe('.mp4');
});
it('builds ordered adaptive rendition profiles and filters', () => {
values['videoProcessing.maxWidth'] = 1280;
values['videoProcessing.maxFps'] = 30;
const service = new VideoProcessingService(config);
expect(internals(service).buildHlsRenditions(200).map((item) => item.width)).toEqual([240]);
expect(internals(service).buildHlsRenditions(720).map((item) => item.width)).toEqual([480, 720]);
expect(internals(service).buildHlsRenditions(1920).map((item) => item.width)).toEqual([480, 720, 1280]);
expect(internals(service).resolveHlsRendition(480)).toEqual(expect.objectContaining({ videoBitrateKbps: 800, audioBitrateKbps: 96 }));
expect(internals(service).resolveHlsRendition(720)).toEqual(expect.objectContaining({ videoBitrateKbps: 1600 }));
expect(internals(service).resolveHlsRendition(1080)).toEqual(expect.objectContaining({ videoBitrateKbps: 2800 }));
expect(internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }])).toContain('[v0out]');
const adaptive = internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }, { width: 720 }]);
expect(adaptive).toContain('split=2[v0][v1]');
expect(adaptive).toContain('fps=30');
expect(internals(service).buildVideoFilter()).toContain('format=yuv420p');
expect(internals(service).buildThumbnailFilter()).toContain('force_original_aspect_ratio=decrease');
});
it('generates adaptive HLS with audio and gathers generated files', async () => {
Object.assign(values, { 'videoProcessing.maxWidth': 1280, 'videoProcessing.maxFps': 25, 'videoProcessing.hlsSegmentDurationSeconds': 6 });
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 1280, hasAudio: true });
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
const generated = [{ relativePath: 'master.m3u8', buffer: Buffer.from('x'), contentType: 'application/vnd.apple.mpegurl' }];
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue(generated);
await expect(internals(service).generateHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'master.m3u8', files: generated });
const args = run.mock.calls[0][0];
expect(args).toEqual(expect.arrayContaining(['-filter_complex', expect.stringContaining('split=3'), '-var_stream_map', 'v:0,a:0 v:1,a:1 v:2,a:2']));
expect(args.filter((arg) => arg === '0:a:0')).toHaveLength(3);
});
it('generates adaptive HLS without audio mappings', async () => {
const service = new VideoProcessingService(config);
jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 720, hasAudio: false });
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
await internals(service).generateHlsPackage('/in.mp4', '/hls');
expect(run.mock.calls[0][0]).toEqual(expect.arrayContaining(['-var_stream_map', 'v:0 v:1']));
expect(run.mock.calls[0][0]).not.toContain('0:a:0');
});
it('falls back to a single HLS stream when probing fails or only one rendition fits', async () => {
const failedProbe = new VideoProcessingService(config);
jest.spyOn(internals(failedProbe), 'probeVideo').mockRejectedValue(new Error('probe failed'));
const single = jest.spyOn(internals(failedProbe), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
await internals(failedProbe).generateHlsPackage('/in.mp4', '/hls');
expect(single).toHaveBeenCalled();
const narrow = new VideoProcessingService(config);
jest.spyOn(internals(narrow), 'probeVideo').mockResolvedValue({ width: 200, hasAudio: false });
const narrowSingle = jest.spyOn(internals(narrow), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
await internals(narrow).generateHlsPackage('/in.mp4', '/hls');
expect(narrowSingle).toHaveBeenCalled();
});
it('generates a single fMP4 rendition with default settings', async () => {
const service = new VideoProcessingService(config);
const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
await expect(internals(service).generateSingleRenditionHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'playlist.m3u8', files: [] });
expect(run).toHaveBeenCalledWith(expect.arrayContaining(['-hls_time', '4', '-hls_segment_type', 'fmp4']));
});
it('parses video probe output and rejects missing widths', async () => {
const service = new VideoProcessingService(config);
const run = jest.spyOn(internals(service), 'runCommand');
run.mockResolvedValueOnce(JSON.stringify({ streams: [{ codec_type: 'video', width: 1920 }, { codec_type: 'audio' }] }));
await expect(internals(service).probeVideo('/in.mp4')).resolves.toEqual({ width: 1920, hasAudio: true });
run.mockResolvedValueOnce('{}');
await expect(internals(service).probeVideo('/bad.mp4')).rejects.toThrow('video width');
});
it('reads nested HLS output in stable order and assigns content types', async () => {
const service = new VideoProcessingService(config);
(readdir as jest.Mock).mockImplementation(async (path: string) =>
path === '/hls'
? [
{ name: 'stream_0', isDirectory: () => true },
{ name: 'master.m3u8', isDirectory: () => false },
]
: [
{ name: 'segment.ts', isDirectory: () => false },
{ name: 'init.mp4', isDirectory: () => false },
{ name: 'segment.m4s', isDirectory: () => false },
{ name: 'unknown.bin', isDirectory: () => false },
],
);
const files = await internals(service).readFilesRecursively('/hls');
expect(files.map((item) => [item.relativePath, item.contentType])).toEqual([
['master.m3u8', 'application/vnd.apple.mpegurl'],
['stream_0/init.mp4', 'video/mp4'],
['stream_0/segment.m4s', 'video/iso.segment'],
['stream_0/segment.ts', 'video/mp2t'],
['stream_0/unknown.bin', 'application/octet-stream'],
]);
});
it('probes ffmpeg only once and caches success or failure', async () => {
const success = new VideoProcessingService(config);
const run = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined);
await expect(internals(success).ensureFfmpegAvailable()).resolves.toBe(true);
await internals(success).ensureFfmpegAvailable();
expect(run).toHaveBeenCalledTimes(1);
const failure = new VideoProcessingService(config);
const fail = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing');
await expect(internals(failure).ensureFfmpegAvailable()).resolves.toBe(false);
await internals(failure).ensureFfmpegAvailable();
expect(fail).toHaveBeenCalledTimes(1);
});
it('runs child commands and propagates stderr, generic exit, and spawn errors', async () => {
const service = new VideoProcessingService(config);
const ok = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(ok);
const okPromise = internals(service).runCommand('ffmpeg', ['-version']);
ok.stdout.emit('data', Buffer.from('version'));
ok.emit('close', 0);
await expect(okPromise).resolves.toBe('version');
const failed = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(failed);
const failedPromise = internals(service).runCommand('ffmpeg', []);
failed.stderr.emit('data', Buffer.from('invalid codec'));
failed.emit('close', 1);
await expect(failedPromise).rejects.toThrow('invalid codec');
const generic = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(generic);
const genericPromise = internals(service).runCommand('ffmpeg', []);
generic.emit('close', null);
await expect(genericPromise).rejects.toThrow('exited with code unknown');
const errored = fakeChild();
(spawn as jest.Mock).mockReturnValueOnce(errored);
const errorPromise = internals(service).runCommand('ffmpeg', []);
errored.emit('error', new Error('spawn failed'));
await expect(errorPromise).rejects.toThrow('spawn failed');
});
});
+230
View File
@@ -0,0 +1,230 @@
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import { existsSync, mkdirSync } from 'fs';
import { networkInterfaces } from 'os';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { configureHttpServer } from './infrastructure/reliability/http-server.config';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
import { bootstrap, getLocalIpv4Addresses, getStaticMediaHeaders, isPrivateIpv4Host } from './main';
jest.mock('./app.module', () => ({ AppModule: class AppModule {} }));
jest.mock('@nestjs/core', () => ({ NestFactory: { create: jest.fn() } }));
jest.mock('@nestjs/swagger', () => {
class DocumentBuilder {
setTitle() { return this; }
setDescription() { return this; }
setVersion() { return this; }
addBearerAuth() { return this; }
build() { return { openapi: '3.0.0' }; }
}
return {
DocumentBuilder,
SwaggerModule: { createDocument: jest.fn(() => ({ paths: {} })), setup: jest.fn() },
};
});
jest.mock('compression', () => jest.fn(() => 'compression-middleware'));
jest.mock('express', () => ({
json: jest.fn(() => 'json-middleware'),
urlencoded: jest.fn(() => 'urlencoded-middleware'),
static: jest.fn(() => 'static-middleware'),
}));
jest.mock('fs', () => ({
...jest.requireActual<typeof import('fs')>('fs'),
existsSync: jest.fn(),
mkdirSync: jest.fn(),
}));
jest.mock('os', () => ({
...jest.requireActual<typeof import('os')>('os'),
networkInterfaces: jest.fn(),
}));
jest.mock('./infrastructure/reliability/http-server.config', () => ({ configureHttpServer: jest.fn() }));
jest.mock('./infrastructure/socket/redis-io.adapter', () => ({
RedisIoAdapter: jest.fn().mockImplementation(() => ({
connectToRedis: jest.fn().mockResolvedValue(undefined),
})),
}));
describe('main bootstrap', () => {
const build = (overrides: Record<string, unknown> = {}) => {
const values: Record<string, unknown> = {
'cors.origins': ['https://app.example.com'],
nodeEnv: 'production',
'security.bodyLimit': '2mb',
'performance.compressionEnabled': true,
'performance.compressionThresholdBytes': 2048,
'storage.provider': 'local',
'storage.basePath': '/uploads/',
publicBaseUrl: 'http://localhost:4000/',
'email.enabled': true,
'email.smtpHost': 'smtp.example.com',
'email.smtpUser': 'smtp-user',
'email.fromEmail': 'noreply@example.com',
globalPrefix: 'api/v1',
responseEnvelopeEnabled: true,
'redis.enabled': true,
'redis.socketAdapterEnabled': true,
'swagger.enabled': true,
'swagger.path': 'docs',
'swagger.title': 'Oudelaa API',
'swagger.description': 'API',
'swagger.version': '1.0.0',
port: 4000,
host: '0.0.0.0',
...overrides,
};
const config = {
get: jest.fn((key: string, defaultValue?: unknown) =>
Object.prototype.hasOwnProperty.call(values, key) ? values[key] : defaultValue,
),
};
const logger = {
log: jest.fn(),
warn: jest.fn(),
logHttp: jest.fn(),
};
const redis = {};
const httpServer = { keepAliveTimeout: 0, headersTimeout: 0, requestTimeout: 0 };
const app = {
get: jest.fn((token: unknown) => {
if (token === ConfigService) return config;
if (token === AppLoggerService) return logger;
if (token === RedisService) return redis;
return undefined;
}),
useLogger: jest.fn(),
enableShutdownHooks: jest.fn(),
use: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
useGlobalPipes: jest.fn(),
useGlobalInterceptors: jest.fn(),
useWebSocketAdapter: jest.fn(),
listen: jest.fn().mockResolvedValue(undefined),
getHttpServer: jest.fn(() => httpServer),
};
(NestFactory.create as jest.Mock).mockResolvedValue(app);
return { app, config, logger, redis, httpServer };
};
beforeEach(() => {
jest.clearAllMocks();
(existsSync as jest.Mock).mockReturnValue(false);
(networkInterfaces as jest.Mock).mockReturnValue({
Ethernet: [
{ family: 'IPv4', internal: false, address: '192.168.1.20' },
{ family: 'IPv6', internal: false, address: '::1' },
null,
],
Loopback: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
});
});
it('classifies local addresses and static-media response headers', () => {
expect(getLocalIpv4Addresses()).toEqual(['192.168.1.20']);
expect(isPrivateIpv4Host('10.0.0.1')).toBe(true);
expect(isPrivateIpv4Host('192.168.1.1')).toBe(true);
expect(isPrivateIpv4Host('172.16.0.1')).toBe(true);
expect(isPrivateIpv4Host('172.31.0.1')).toBe(true);
expect(isPrivateIpv4Host('172.15.0.1')).toBe(false);
expect(getStaticMediaHeaders('.mp3', '/audio/song.mp3')).toEqual(
expect.objectContaining({ contentType: 'audio/mpeg', acceptRanges: true }),
);
expect(getStaticMediaHeaders('.gif')).toEqual(expect.objectContaining({ contentType: 'image/gif' }));
expect(getStaticMediaHeaders('.m3u8')).toEqual(
expect.objectContaining({ contentType: 'application/vnd.apple.mpegurl', acceptRanges: true }),
);
expect(getStaticMediaHeaders('.m4s')).toEqual(expect.objectContaining({ contentType: 'video/iso.segment' }));
expect(getStaticMediaHeaders('.ts')).toEqual(expect.objectContaining({ contentType: 'video/mp2t' }));
expect(getStaticMediaHeaders('.unknown')).toEqual({});
});
it('boots the production stack with security, local media, Redis sockets, and Swagger', async () => {
const { app, logger, httpServer } = build();
await bootstrap();
expect(NestFactory.create).toHaveBeenCalledWith(expect.any(Function), {
bufferLogs: true,
bodyParser: false,
});
expect(mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/uploads$/), { recursive: true });
expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ credentials: true }));
expect(app.setGlobalPrefix).toHaveBeenCalledWith('api/v1');
expect(app.useGlobalPipes).toHaveBeenCalled();
expect(app.useGlobalInterceptors).toHaveBeenCalled();
expect(app.listen).toHaveBeenCalledWith(4000, '0.0.0.0');
expect(configureHttpServer).toHaveBeenCalledWith(httpServer, expect.anything());
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Mobile devices on the LAN'), 'Bootstrap');
expect(RedisIoAdapter).toHaveBeenCalled();
const adapter = (RedisIoAdapter as unknown as jest.Mock).mock.results[0].value;
expect(adapter.connectToRedis).toHaveBeenCalled();
expect(app.useWebSocketAdapter).toHaveBeenCalledWith(adapter);
expect(SwaggerModule.createDocument).toHaveBeenCalled();
expect(SwaggerModule.setup).toHaveBeenCalledWith('docs', app, expect.anything());
const middleware = app.use.mock.calls
.filter((call) => typeof call[0] === 'function')
.map((call) => call[0]);
expect(middleware).toHaveLength(2);
const setHeader = jest.fn();
const next = jest.fn();
middleware[0]({}, { setHeader } as any, next);
expect(setHeader).toHaveBeenCalledWith('Strict-Transport-Security', expect.any(String));
let finish: (() => void) | undefined;
const request = { headers: { 'x-request-id': 'invalid id' }, method: 'GET', originalUrl: '/health' } as any;
const response = {
statusCode: 200,
setHeader,
on: jest.fn((_event: string, listener: () => void) => { finish = listener; }),
} as any;
middleware[1](request, response, next);
finish?.();
expect(request.headers['x-request-id']).toEqual(expect.any(String));
expect(logger.logHttp).toHaveBeenCalledWith(expect.objectContaining({ method: 'GET', statusCode: 200 }));
const suppliedIdRequest = {
headers: { 'x-request-id': 'mobile-client:request-1' },
method: 'GET',
originalUrl: '/health',
} as any;
middleware[1](suppliedIdRequest, response, next);
expect(suppliedIdRequest.headers['x-request-id']).toBe('mobile-client:request-1');
const staticOptions = (express.static as unknown as jest.Mock).mock.calls[0][1];
const staticHeader = jest.fn();
staticOptions.setHeaders({ setHeader: staticHeader }, '/uploads/stream.m3u8');
expect(staticHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.mpegurl');
expect(staticHeader).toHaveBeenCalledWith('Accept-Ranges', 'bytes');
});
it('boots without optional integrations and warns about a mismatched private public URL', async () => {
const { app, logger } = build({
'cors.origins': [],
nodeEnv: 'development',
'performance.compressionEnabled': false,
'storage.provider': 's3',
publicBaseUrl: 'http://192.168.1.99:4000',
responseEnvelopeEnabled: false,
'redis.enabled': false,
'redis.socketAdapterEnabled': false,
'swagger.enabled': false,
'email.enabled': false,
'email.smtpHost': '',
'email.smtpUser': '',
'email.fromEmail': '',
});
await bootstrap();
expect(mkdirSync).not.toHaveBeenCalled();
expect(app.enableCors).toHaveBeenCalledWith(expect.objectContaining({ origin: true, credentials: true }));
expect(app.useGlobalInterceptors).not.toHaveBeenCalled();
expect(app.useWebSocketAdapter).not.toHaveBeenCalled();
expect(SwaggerModule.setup).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not assigned to the current machine'), 'Bootstrap');
});
});
+59 -17
View File
@@ -1,8 +1,9 @@
import { ValidationPipe } from '@nestjs/common';
import { ShutdownSignal, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as express from 'express';
import compression = require('compression');
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { existsSync, mkdirSync } from 'fs';
@@ -13,22 +14,26 @@ import { ResponseEnvelopeInterceptor } from './common/interceptors/response-enve
import { getFileExtension, getStaticMediaContentType } from './common/media/allowed-media';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import {
ConfigurableHttpServer,
configureHttpServer,
} from './infrastructure/reliability/http-server.config';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
const getLocalIpv4Addresses = (): string[] =>
export const getLocalIpv4Addresses = (): string[] =>
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry): entry is NetworkInterfaceInfo => !!entry)
.filter((entry) => entry.family === 'IPv4' && !entry.internal)
.map((entry) => entry.address);
const isPrivateIpv4Host = (host: string): boolean =>
export const isPrivateIpv4Host = (host: string): boolean =>
/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
const IMMUTABLE_MEDIA_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60';
const getStaticMediaHeaders = (
export const getStaticMediaHeaders = (
extension: string,
filePath = '',
): {
@@ -74,12 +79,19 @@ const getStaticMediaHeaders = (
}
};
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
export async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true, bodyParser: false });
const configService = app.get(ConfigService);
const appLogger = app.get(AppLoggerService);
app.useLogger(appLogger);
app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM]);
const corsOrigins = configService.get<string[]>('cors.origins', []);
const nodeEnv = configService.get<string>('nodeEnv', 'development');
const bodyLimit = configService.get<string>('security.bodyLimit', { infer: true }) ?? '1mb';
const compressionEnabled =
configService.get<boolean>('performance.compressionEnabled', { infer: true }) ?? true;
const compressionThresholdBytes =
configService.get<number>('performance.compressionThresholdBytes', { infer: true }) ?? 1024;
const storageProvider = configService.get<string>('storage.provider', { infer: true }) ?? 'local';
const storageBasePath =
(configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads').replace(
@@ -94,13 +106,31 @@ async function bootstrap(): Promise<void> {
const emailFromEmail = configService.get<string>('email.fromEmail', { infer: true }) ?? '';
const uploadsDir = join(process.cwd(), storageBasePath);
if (compressionEnabled) {
app.use(compression({ threshold: compressionThresholdBytes }));
}
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
app.use((_req: Request, res: Response, next: NextFunction) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
if (nodeEnv === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
if (storageProvider === 'local' && !existsSync(uploadsDir)) {
mkdirSync(uploadsDir, { recursive: true });
}
app.enableCors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
origin: corsOrigins.length ? corsOrigins : nodeEnv === 'production' ? false : true,
credentials: corsOrigins.length > 0 || nodeEnv !== 'production',
exposedHeaders: [
'Accept-Ranges',
'Content-Length',
@@ -125,7 +155,11 @@ async function bootstrap(): Promise<void> {
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
const requestId = (req.headers['x-request-id'] as string | undefined) ?? randomUUID();
const suppliedRequestId = req.headers['x-request-id'];
const requestId =
typeof suppliedRequestId === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId)
? suppliedRequestId
: randomUUID();
req.headers['x-request-id'] = requestId;
res.setHeader('x-request-id', requestId);
@@ -147,9 +181,10 @@ async function bootstrap(): Promise<void> {
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
}
app.use(
`/${storageBasePath}`,
express.static(uploadsDir, {
if (storageProvider === 'local') {
app.use(
`/${storageBasePath}`,
express.static(uploadsDir, {
acceptRanges: true,
setHeaders: (res, filePath) => {
const extension = getFileExtension(filePath);
@@ -167,8 +202,9 @@ async function bootstrap(): Promise<void> {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}),
);
}),
);
}
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
const socketAdapterEnabled =
@@ -188,8 +224,11 @@ async function bootstrap(): Promise<void> {
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
const swaggerEnabled = configService.get<boolean>('swagger.enabled', { infer: true }) ?? false;
if (swaggerEnabled) {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup(configService.get<string>('swagger.path', 'docs'), app, document);
}
const port = configService.get<number>('port', 4000);
const host = configService.get<string>('host', '0.0.0.0');
@@ -221,6 +260,7 @@ async function bootstrap(): Promise<void> {
}
await app.listen(port, host);
configureHttpServer(app.getHttpServer() as ConfigurableHttpServer, configService);
appLogger.log(`Server listening on http://${host}:${port}`, 'Bootstrap');
appLogger.log(`Resolved PUBLIC_BASE_URL=${publicBaseUrl || `http://localhost:${port}`}`, 'Bootstrap');
appLogger.log(
@@ -229,4 +269,6 @@ async function bootstrap(): Promise<void> {
);
}
void bootstrap();
if (require.main === module) {
void bootstrap();
}
+119
View File
@@ -0,0 +1,119 @@
import { SortOrder } from '../../common/enums/sort-order.enum';
import { AuditService } from './audit.service';
describe('AuditService', () => {
const createService = () => {
const auditRepository = {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
};
return {
service: new AuditService(auditRepository as any),
auditRepository,
};
};
it('writes a complete super-admin audit record', async () => {
const { service, auditRepository } = createService();
auditRepository.create.mockResolvedValue(undefined);
await expect(
service.logSuperAdminAction('root@example.com', 'suspend', 'user', 'user-1', {
reason: 'abuse',
}),
).resolves.toBeUndefined();
expect(auditRepository.create).toHaveBeenCalledWith({
actorType: 'superadmin',
actorIdentifier: 'root@example.com',
action: 'suspend',
targetType: 'user',
targetId: 'user-1',
metadata: { reason: 'abuse' },
});
});
it('uses default pagination, filters, and descending order', async () => {
const { service, auditRepository } = createService();
const items = [{ id: 'log-1' }, { id: 'log-2' }];
auditRepository.findMany.mockResolvedValue(items);
auditRepository.count.mockResolvedValue(25);
const result = await service.listSuperAdminLogs({});
expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 });
expect(auditRepository.count).toHaveBeenCalledWith({});
expect(result).toMatchObject({
items,
count: 2,
page: 1,
limit: 20,
total: 25,
totalPages: 2,
pagination: {
hasNextPage: true,
hasPreviousPage: false,
nextPage: 2,
previousPage: null,
},
});
});
it('builds escaped search filters and applies explicit ascending pagination', async () => {
const { service, auditRepository } = createService();
auditRepository.findMany.mockResolvedValue([{ id: 'log-3' }]);
auditRepository.count.mockResolvedValue(21);
const result = await service.listSuperAdminLogs({
q: ' user.* ',
actorType: 'system',
targetType: ' post ',
page: 3,
limit: 10,
sortOrder: SortOrder.ASC,
});
const filter = {
$or: [
{ action: { $regex: 'user\\.\\*', $options: 'i' } },
{ targetType: { $regex: 'user\\.\\*', $options: 'i' } },
{ targetId: { $regex: 'user\\.\\*', $options: 'i' } },
{ actorIdentifier: { $regex: 'user\\.\\*', $options: 'i' } },
],
actorType: 'system',
targetType: 'post',
};
expect(auditRepository.findMany).toHaveBeenCalledWith(filter, 20, 10, { createdAt: 1 });
expect(auditRepository.count).toHaveBeenCalledWith(filter);
expect(result.pagination).toMatchObject({
hasNextPage: false,
hasPreviousPage: true,
nextPage: null,
previousPage: 2,
});
});
it('ignores whitespace-only optional filters', async () => {
const { service, auditRepository } = createService();
auditRepository.findMany.mockResolvedValue([]);
auditRepository.count.mockResolvedValue(0);
await service.listSuperAdminLogs({ q: ' ', targetType: ' ' });
expect(auditRepository.findMany).toHaveBeenCalledWith({}, 0, 20, { createdAt: -1 });
});
it('propagates write and read failures', async () => {
const { service, auditRepository } = createService();
auditRepository.create.mockRejectedValue(new Error('write failed'));
await expect(
service.logSuperAdminAction('root', 'action', 'target'),
).rejects.toThrow('write failed');
auditRepository.findMany.mockRejectedValue(new Error('read failed'));
auditRepository.count.mockResolvedValue(0);
await expect(service.listSuperAdminLogs({})).rejects.toThrow('read failed');
});
});
+5 -4
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { AuditRepository } from './audit.repository';
import { escapeRegex } from '../../common/utils/regex.util';
import { AuditQueryDto } from './dto/audit-query.dto';
@Injectable()
@@ -33,10 +34,10 @@ export class AuditService {
if (query.q?.trim()) {
filter.$or = [
{ action: { $regex: query.q.trim(), $options: 'i' } },
{ targetType: { $regex: query.q.trim(), $options: 'i' } },
{ targetId: { $regex: query.q.trim(), $options: 'i' } },
{ actorIdentifier: { $regex: query.q.trim(), $options: 'i' } },
{ action: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ targetType: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ targetId: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
{ actorIdentifier: { $regex: escapeRegex(query.q.trim()), $options: 'i' } },
];
}
+6 -1
View File
@@ -12,6 +12,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { Throttle } from '../../common/decorators/throttle.decorator';
@@ -73,7 +74,11 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@Post('signup/complete')
@Throttle(10, 60_000)
@UseInterceptors(FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }]))
@UseInterceptors(
FileFieldsInterceptor([{ name: 'avatarFile', maxCount: 1 }], {
limits: { fileSize: MEDIA_MAX_SIZE_BYTES.userImage, files: 1 },
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
+2 -2
View File
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { JwtModule, JwtSignOptions } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { EmailModule } from '../email/email.module';
import { UsersModule } from '../users/users.module';
@@ -34,7 +34,7 @@ import { SuperAdminJwtStrategy } from './strategies/super-admin-jwt.strategy';
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('jwt.accessSecret', { infer: true }),
signOptions: {
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }),
expiresIn: configService.get<string>('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
}),
}),
+49
View File
@@ -59,6 +59,17 @@ export class AuthRepository {
.exec();
}
async consumeUserTokenByJti(userId: string, jti: string): Promise<boolean> {
const consumed = await this.refreshTokenModel
.findOneAndUpdate(
{ userId: new Types.ObjectId(userId), jti, revoked: false, expiresAt: { $gt: new Date() } },
{ revoked: true },
{ new: false },
)
.exec();
return !!consumed;
}
async findActiveUserTokenByJti(userId: string, jti: string): Promise<RefreshTokenDocument | null> {
return this.refreshTokenModel
.findOne({ userId: new Types.ObjectId(userId), jti, revoked: false })
@@ -135,6 +146,22 @@ export class AuthRepository {
.exec();
}
async consumeSuperAdminTokenByJti(adminEmail: string, jti: string): Promise<boolean> {
const consumed = await this.superAdminRefreshTokenModel
.findOneAndUpdate(
{
adminEmail: adminEmail.toLowerCase(),
jti,
revoked: false,
expiresAt: { $gt: new Date() },
},
{ revoked: true },
{ new: false },
)
.exec();
return !!consumed;
}
async removeExpiredAndRevokedSuperAdmin(adminEmail: string): Promise<void> {
await this.superAdminRefreshTokenModel
.deleteMany({
@@ -284,6 +311,28 @@ export class AuthRepository {
.exec();
}
async consumeValidVerifiedPasswordResetCode(
codeId: string,
userId: string,
): Promise<PasswordResetCodeDocument | null> {
if (!Types.ObjectId.isValid(codeId) || !Types.ObjectId.isValid(userId)) {
return null;
}
return this.passwordResetCodeModel
.findOneAndUpdate(
{
_id: new Types.ObjectId(codeId),
userId: new Types.ObjectId(userId),
used: false,
verified: true,
expiresAt: { $gt: new Date() },
},
{ used: true },
{ new: false },
)
.exec();
}
async upsertSignupDraft(params: {
email: string;
passwordHash: string;
@@ -0,0 +1,844 @@
import {
BadRequestException,
ForbiddenException,
GoneException,
HttpException,
UnauthorizedException,
} from '@nestjs/common';
import { hashHighEntropyValue, hashValue } from '../../common/utils/hash.util';
import { AuthService } from './auth.service';
type MockMap = Record<string, jest.Mock>;
const future = () => new Date(Date.now() + 60_000);
const createHarness = (options: {
config?: Record<string, unknown>;
users?: Partial<MockMap>;
repository?: Partial<MockMap>;
jwt?: Partial<MockMap>;
email?: Partial<MockMap>;
} = {}) => {
const user = {
id: 'user-1',
email: 'user@example.com',
username: 'artist',
role: 'user',
password: '',
isDisabled: false,
isEmailVerified: false,
googleId: 'google-1',
toObject: () => ({ id: 'user-1', email: 'user@example.com', username: 'artist' }),
};
const users: MockMap = {
create: jest.fn().mockResolvedValue(user),
createWithOptionalAvatarFile: jest.fn().mockResolvedValue(user),
rollbackSignupCreatedUser: jest.fn().mockResolvedValue(undefined),
findByEmailWithPassword: jest.fn(),
findByIdWithPassword: jest.fn(),
findByIdOrFail: jest.fn().mockResolvedValue(user),
findByEmail: jest.fn().mockResolvedValue(null),
findByUsername: jest.fn().mockResolvedValue(null),
findByGoogleId: jest.fn().mockResolvedValue(null),
linkGoogleAccount: jest.fn().mockResolvedValue(user),
markEmailVerified: jest.fn().mockResolvedValue(undefined),
updatePassword: jest.fn().mockResolvedValue(undefined),
...options.users,
};
const repository: MockMap = {
invalidateActiveEmailVerificationCodes: jest.fn().mockResolvedValue(undefined),
createEmailVerificationCode: jest.fn().mockResolvedValue(undefined),
findLatestActiveEmailVerificationCode: jest.fn(),
incrementEmailVerificationAttempts: jest.fn().mockResolvedValue(undefined),
markEmailVerificationCodeUsed: jest.fn().mockResolvedValue(undefined),
markAllEmailVerificationCodesUsedByUser: jest.fn().mockResolvedValue(undefined),
createRefreshToken: jest.fn().mockResolvedValue(undefined),
findActiveUserTokenByJti: jest.fn(),
consumeUserTokenByJti: jest.fn().mockResolvedValue(true),
markCompromisedAndRevokeAll: jest.fn().mockResolvedValue(undefined),
revokeAllUserTokens: jest.fn().mockResolvedValue(undefined),
revokeUserTokenByJti: jest.fn().mockResolvedValue(undefined),
removeExpiredAndRevoked: jest.fn().mockResolvedValue(undefined),
createSuperAdminRefreshToken: jest.fn().mockResolvedValue(undefined),
findActiveSuperAdminTokenByJti: jest.fn(),
consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(true),
revokeAllSuperAdminTokens: jest.fn().mockResolvedValue(undefined),
revokeSuperAdminTokenByJti: jest.fn().mockResolvedValue(undefined),
removeExpiredAndRevokedSuperAdmin: jest.fn().mockResolvedValue(undefined),
listUserSessions: jest.fn().mockResolvedValue([]),
listSuperAdminSessions: jest.fn().mockResolvedValue([]),
revokeSuperAdminSessionById: jest.fn().mockResolvedValue(true),
invalidateActivePasswordResetCodes: jest.fn().mockResolvedValue(undefined),
createPasswordResetCode: jest.fn().mockResolvedValue(undefined),
findLatestActivePasswordResetCode: jest.fn(),
incrementPasswordResetAttempts: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeVerified: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeUsed: jest.fn().mockResolvedValue(undefined),
markPasswordResetCodeUsedByUser: jest.fn().mockResolvedValue(undefined),
consumeValidVerifiedPasswordResetCode: jest.fn(),
...options.repository,
};
const jwt: MockMap = {
signAsync: jest.fn((payload: { tokenType?: string }) =>
Promise.resolve(payload.tokenType?.includes('refresh') ? 'new-refresh-token' : 'new-access-token'),
),
verify: jest.fn(),
...options.jwt,
};
const email: MockMap = {
sendVerificationCode: jest.fn().mockResolvedValue(undefined),
sendPasswordResetCode: jest.fn().mockResolvedValue(undefined),
...options.email,
};
const config: Record<string, unknown> = {
nodeEnv: 'test',
'security.bcryptSaltRounds': 4,
'security.refreshTokenHashSecret': 'hash-secret',
'jwt.accessSecret': 'access-secret',
'jwt.accessExpiresIn': '15m',
'jwt.refreshSecret': 'refresh-secret',
'jwt.refreshExpiresIn': '30d',
'superAdmin.email': 'root@example.com',
'superAdmin.password': 'RootPass123!',
'superAdmin.accessSecret': 'admin-access-secret',
'superAdmin.accessExpiresIn': '15m',
'superAdmin.refreshSecret': 'admin-refresh-secret',
'superAdmin.refreshExpiresIn': '30d',
'emailVerification.codeExpiresMinutes': 20,
'emailVerification.maxAttempts': 3,
'passwordReset.codeExpiresMinutes': 10,
'passwordReset.maxAttempts': 3,
'passwordReset.tokenSecret': 'password-reset-secret',
'passwordReset.tokenExpiresIn': '15m',
'google.clientId': 'google-client-id',
...options.config,
};
const configService = {
get: jest.fn((key: string) => config[key]),
};
const service = new AuthService(
users as any,
repository as any,
jwt as any,
configService as any,
email as any,
);
return { service, users, repository, jwt, email, configService, user };
};
describe('AuthService security-sensitive account flows', () => {
describe('registration and login', () => {
it('rejects registration when password confirmation differs', async () => {
const { service, users } = createHarness();
await expect(
service.register({
email: 'user@example.com',
username: 'artist',
password: 'StrongPass123!',
confirmPassword: 'DifferentPass123!',
} as any),
).rejects.toBeInstanceOf(BadRequestException);
expect(users.create).not.toHaveBeenCalled();
});
it('normalizes a generated unique username and never stores the confirmation', async () => {
const { service, users, repository, email } = createHarness({
users: {
findByUsername: jest
.fn()
.mockResolvedValueOnce({ id: 'collision' })
.mockResolvedValueOnce(null),
},
});
const result = await service.register({
email: 'A+rtist@example.com',
stageName: 'Stage Artist',
password: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
} as any);
expect(users.create).toHaveBeenCalledWith(
expect.objectContaining({ username: 'artist_1', name: 'Stage Artist' }),
);
expect(users.create.mock.calls[0][0]).not.toHaveProperty('confirmPassword');
expect(repository.invalidateActiveEmailVerificationCodes).toHaveBeenCalledWith('user-1');
expect(email.sendVerificationCode).toHaveBeenCalled();
expect(result.debugCode).toMatch(/^\d{6}$/);
});
it('omits verification codes in production registration responses', async () => {
const { service } = createHarness({ config: { nodeEnv: 'production' } });
const result = await service.registerBasic({
email: 'new@example.com',
password: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
});
expect(result).toEqual({
message: 'Registration successful. Verification code sent to email.',
email: 'user@example.com',
});
});
it('rejects a missing account, disabled account, and incorrect password', async () => {
const missing = createHarness();
await expect(
missing.service.login({ email: 'none@example.com', password: 'StrongPass123!' }),
).rejects.toBeInstanceOf(UnauthorizedException);
const disabledHash = await hashValue('StrongPass123!', 4);
const disabled = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
...missing.user,
password: disabledHash,
isDisabled: true,
}),
},
});
await expect(
disabled.service.login({ email: 'user@example.com', password: 'StrongPass123!' }),
).rejects.toBeInstanceOf(ForbiddenException);
const wrong = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
...missing.user,
password: disabledHash,
}),
},
});
await expect(
wrong.service.login({ email: 'user@example.com', password: 'WrongPass123!' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns a safe user and persists a hashed refresh token on login', async () => {
const password = await hashValue('StrongPass123!', 4);
const { service, users, repository } = createHarness({
users: {
findByEmailWithPassword: jest.fn().mockResolvedValue({
id: 'user-1',
username: 'artist',
role: undefined,
password,
isDisabled: false,
}),
},
});
const result = await service.login({
email: 'user@example.com',
password: 'StrongPass123!',
});
expect(result).toMatchObject({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
expect(users.findByIdOrFail).toHaveBeenCalledWith('user-1');
expect(repository.createRefreshToken).toHaveBeenCalledWith(
'user-1',
expect.any(String),
hashHighEntropyValue('new-refresh-token', 'hash-secret'),
expect.any(Date),
);
});
});
describe('email verification', () => {
it('does not reveal whether an email is absent or disabled', async () => {
const absent = createHarness();
const disabled = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ isDisabled: true }) },
});
await expect(absent.service.sendEmailVerification({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual(
{ message: 'If this email exists, a verification code was sent' },
);
await expect(disabled.service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual(
{ message: 'If this email exists, a verification code was sent' },
);
expect(absent.repository.createEmailVerificationCode).not.toHaveBeenCalled();
});
it('short-circuits an already verified account', async () => {
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ isEmailVerified: true }) },
});
await expect(service.sendEmailVerification({ email: 'user@example.com' })).resolves.toEqual({
message: 'Email is already verified',
});
expect(repository.createEmailVerificationCode).not.toHaveBeenCalled();
});
it('invalidates a code at the attempt limit', async () => {
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
attempts: 3,
}),
},
});
await expect(
service.verifyEmail({ email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1');
});
it('increments a wrong-code attempt and consumes the final attempt', async () => {
const codeHash = await hashValue('123456', 4);
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
codeHash,
attempts: 2,
}),
},
});
await expect(
service.verifyEmail({ email: 'user@example.com', code: '654321' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.incrementEmailVerificationAttempts).toHaveBeenCalledWith('code-1');
expect(repository.markEmailVerificationCodeUsed).toHaveBeenCalledWith('code-1');
});
it('atomically verifies the user and invalidates every outstanding code', async () => {
const codeHash = await hashValue('123456', 4);
const { service, users, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActiveEmailVerificationCode: jest.fn().mockResolvedValue({
id: 'code-1',
codeHash,
attempts: 0,
}),
},
});
await expect(
service.verifyEmail({ email: 'USER@EXAMPLE.COM', code: '123456' }),
).resolves.toEqual({ message: 'Email verified successfully' });
expect(users.markEmailVerified).toHaveBeenCalledWith('user-1');
expect(repository.markAllEmailVerificationCodesUsedByUser).toHaveBeenCalledWith('user-1');
});
});
describe('refresh-token rotation', () => {
it('rejects access tokens passed as refresh tokens before repository lookup', async () => {
const { service, repository } = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) },
});
await expect(service.refresh({ refreshToken: 'token' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.findActiveUserTokenByJti).not.toHaveBeenCalled();
});
it.each([
['missing record', null, true],
['hash mismatch', { tokenHash: hashHighEntropyValue('other-token', 'hash-secret') }, true],
])('revokes all sessions on %s', async (_name, tokenRecord, consume) => {
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue(tokenRecord),
consumeUserTokenByJti: jest.fn().mockResolvedValue(consume),
},
});
await expect(service.refresh({ refreshToken: 'refresh-token' })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalledWith('user-1');
});
it('detects a concurrent double-use when token consumption loses the race', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
consumeUserTokenByJti: jest.fn().mockResolvedValue(false),
},
});
await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(repository.markCompromisedAndRevokeAll).toHaveBeenCalled();
});
it('blocks rotation for a disabled user', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
users: { findByIdOrFail: jest.fn().mockResolvedValue({ isDisabled: true }) },
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(service.refresh({ refreshToken: raw })).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(repository.consumeUserTokenByJti).not.toHaveBeenCalled();
});
it('consumes the old token before issuing a new pair', async () => {
const raw = 'refresh-token';
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'refresh', jti: 'jti-1' }),
},
repository: {
findActiveUserTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(service.refresh({ refreshToken: raw })).resolves.toMatchObject({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
expect(repository.consumeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1');
});
it('revokes and cleans sessions at logout but maps invalid tokens to 400', async () => {
const valid = createHarness({ jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1' }) } });
await expect(valid.service.logout({ refreshToken: 'valid' })).resolves.toBeUndefined();
expect(valid.repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1');
const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('bad'); }) } });
await expect(invalid.service.logout({ refreshToken: 'bad' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('Google and superadmin authentication', () => {
it('creates and links a new Google user while defaulting a missing avatar', async () => {
const { service, users } = createHarness({
users: {
create: jest.fn().mockResolvedValue({
id: 'user-1',
username: 'new',
role: 'user',
isDisabled: false,
}),
},
});
const result = await service.loginWithGoogle({
googleId: 'google-new',
email: 'new@example.com',
name: 'New User',
});
expect(users.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'new@example.com',
avatar: '',
isEmailVerified: true,
}),
);
expect(users.linkGoogleAccount).toHaveBeenCalled();
expect(result.accessToken).toBe('new-access-token');
});
it('rejects disabled Google-linked accounts', async () => {
const { service } = createHarness({
users: {
findByGoogleId: jest.fn().mockResolvedValue({
id: 'user-1',
googleId: 'google-1',
isDisabled: true,
}),
},
});
await expect(
service.loginWithGoogle({
googleId: 'google-1',
email: 'user@example.com',
name: 'User',
}),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects Google ID login when the client or verified claims are missing', async () => {
const missingClient = createHarness({ config: { 'google.clientId': ' ' } });
await expect(
missingClient.service.loginWithGoogleIdToken({ idToken: 'token' }),
).rejects.toBeInstanceOf(BadRequestException);
const invalidClaims = createHarness();
(invalidClaims.service as any).googleOAuthClient = {
verifyIdToken: jest.fn().mockResolvedValue({
getPayload: () => ({ sub: 'google-1', email: 'user@example.com', email_verified: false }),
}),
};
await expect(
invalidClaims.service.loginWithGoogleIdToken({ idToken: 'token' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects wrong superadmin credentials and requires OTP when configured', async () => {
const wrong = createHarness();
await expect(
wrong.service.superAdminLogin({ email: 'root@example.com', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
const otpRequired = createHarness({ config: { 'superAdmin.totpSecret': 'JBSWY3DPEHPK3PXP' } });
await expect(
otpRequired.service.superAdminLogin({
email: 'root@example.com',
password: 'RootPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('issues dedicated superadmin tokens and persists only the refresh hash', async () => {
const { service, repository, jwt } = createHarness();
const result = await service.superAdminLogin({
email: 'ROOT@example.com',
password: 'RootPass123!',
});
expect(result.superAdmin.email).toBe('root@example.com');
expect(jwt.signAsync).toHaveBeenCalledWith(
expect.objectContaining({ tokenType: 'superadmin_access', permissions: expect.any(Array) }),
expect.any(Object),
);
expect(repository.createSuperAdminRefreshToken).toHaveBeenCalledWith(
'root@example.com',
expect.any(String),
hashHighEntropyValue('new-refresh-token', 'hash-secret'),
expect.any(Date),
);
});
it('detects missing, mismatched, and concurrently consumed superadmin refresh tokens', async () => {
const cases = [
{ record: null, consumed: true },
{ record: { tokenHash: hashHighEntropyValue('other', 'hash-secret') }, consumed: true },
{ record: { tokenHash: hashHighEntropyValue('admin-refresh', 'hash-secret') }, consumed: false },
];
for (const testCase of cases) {
const { service, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
email: 'root@example.com',
tokenType: 'superadmin_refresh',
jti: 'admin-jti',
}),
},
repository: {
findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.record),
consumeSuperAdminTokenByJti: jest.fn().mockResolvedValue(testCase.consumed),
},
});
await expect(
service.superAdminRefresh({ refreshToken: 'admin-refresh' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com');
}
});
it('rotates a valid superadmin token and supports targeted/global logout', async () => {
const raw = 'admin-refresh';
const rotating = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
email: 'root@example.com',
tokenType: 'superadmin_refresh',
jti: 'admin-jti',
}),
},
repository: {
findActiveSuperAdminTokenByJti: jest.fn().mockResolvedValue({
tokenHash: hashHighEntropyValue(raw, 'hash-secret'),
}),
},
});
await expect(rotating.service.superAdminRefresh({ refreshToken: raw })).resolves.toMatchObject({
superAdmin: { email: 'root@example.com' },
});
await rotating.service.superAdminLogout({ refreshToken: raw });
expect(rotating.repository.revokeSuperAdminTokenByJti).toHaveBeenCalledWith(
'root@example.com',
'admin-jti',
);
const global = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ email: 'root@example.com' }) },
});
await global.service.superAdminLogout({ refreshToken: raw });
expect(global.repository.revokeAllSuperAdminTokens).toHaveBeenCalledWith('root@example.com');
});
});
describe('password reset and session management', () => {
it('keeps forgot-password responses indistinguishable and omits production codes', async () => {
const missing = createHarness();
await expect(missing.service.forgotPassword({ email: 'NONE@EXAMPLE.COM' })).resolves.toEqual({
message: 'If this email exists, a reset code was sent',
});
const production = createHarness({
config: { nodeEnv: 'production' },
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
});
const result = await production.service.forgotPassword({ email: 'USER@EXAMPLE.COM' });
expect(result.debugCode).toBeUndefined();
expect(production.repository.invalidateActivePasswordResetCodes).toHaveBeenCalledWith('user-1');
expect(production.email.sendPasswordResetCode).toHaveBeenCalledWith(
'user@example.com',
expect.stringMatching(/^\d{6}$/),
10,
);
});
it('locks a reset code that already reached or reaches the attempt limit', async () => {
for (const attempts of [3, 2]) {
const codeHash = await hashValue('123456', 4);
const { service, repository } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({
id: 'reset-1',
attempts,
codeHash,
}),
},
});
await expect(
service.verifyResetCode({ email: 'user@example.com', code: '654321' }),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.markPasswordResetCodeUsed).toHaveBeenCalledWith('reset-1');
}
});
it('marks the code verified and issues a short-lived purpose-bound reset token', async () => {
const codeHash = await hashValue('123456', 4);
const { service, repository, jwt } = createHarness({
users: { findByEmail: jest.fn().mockResolvedValue({ id: 'user-1', isDisabled: false }) },
repository: {
findLatestActivePasswordResetCode: jest.fn().mockResolvedValue({
id: 'reset-1',
attempts: 0,
codeHash,
}),
},
});
await expect(
service.verifyResetCode({ email: 'USER@EXAMPLE.COM', code: '123456' }),
).resolves.toEqual({ resetToken: 'new-access-token', expiresIn: '15m' });
expect(repository.markPasswordResetCodeVerified).toHaveBeenCalledWith('reset-1');
expect(jwt.signAsync).toHaveBeenCalledWith(
{ sub: 'user-1', tokenType: 'password_reset', prcId: 'reset-1' },
expect.objectContaining({ secret: 'password-reset-secret' }),
);
});
it('rejects reset confirmation mismatch, invalid JWTs, wrong purpose and consumed codes', async () => {
const mismatch = createHarness();
await expect(
mismatch.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'DifferentPass123!',
}),
).rejects.toBeInstanceOf(BadRequestException);
const invalid = createHarness({ jwt: { verify: jest.fn(() => { throw new Error('expired'); }) } });
await expect(
invalid.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
const wrongPurpose = createHarness({
jwt: { verify: jest.fn().mockReturnValue({ sub: 'user-1', tokenType: 'access' }) },
});
await expect(
wrongPurpose.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
const consumed = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
sub: 'user-1',
tokenType: 'password_reset',
prcId: 'reset-1',
}),
},
});
await expect(
consumed.service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('consumes a reset code exactly once then revokes all existing sessions', async () => {
const { service, users, repository } = createHarness({
jwt: {
verify: jest.fn().mockReturnValue({
sub: 'user-1',
tokenType: 'password_reset',
prcId: 'reset-1',
}),
},
repository: {
consumeValidVerifiedPasswordResetCode: jest.fn().mockResolvedValue({ id: 'reset-1' }),
},
});
await expect(
service.resetPassword({
resetToken: 'token',
newPassword: 'StrongPass123!',
confirmPassword: 'StrongPass123!',
}),
).resolves.toEqual({ message: 'Password reset successfully' });
expect(users.updatePassword).toHaveBeenCalledWith('user-1', expect.any(String));
expect(repository.revokeAllUserTokens).toHaveBeenCalledWith('user-1');
});
it('lists and revokes sessions without exposing token hashes', async () => {
const createdAt = new Date();
const expiresAt = future();
const { service, repository } = createHarness({
repository: {
listUserSessions: jest.fn().mockResolvedValue([
{ jti: 'jti-1', tokenHash: 'secret', createdAt, expiresAt },
]),
listSuperAdminSessions: jest.fn().mockResolvedValue([
{ id: 'session-1', jti: 'jti-2', tokenHash: 'secret', createdAt, expiresAt },
]),
},
});
expect(await service.listUserSessions('user-1')).toEqual({
items: [{ jti: 'jti-1', createdAt, expiresAt }],
});
expect(await service.listSuperAdminSessions('root@example.com')).toEqual({
items: [{ id: 'session-1', jti: 'jti-2', createdAt, expiresAt }],
});
await service.revokeUserSession('user-1', 'jti-1');
expect(repository.revokeUserTokenByJti).toHaveBeenCalledWith('user-1', 'jti-1');
});
it('fails closed when a requested superadmin session no longer exists', async () => {
const { service } = createHarness({
repository: { revokeSuperAdminSessionById: jest.fn().mockResolvedValue(false) },
});
await expect(
service.revokeSuperAdminSession('root@example.com', 'missing'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('signup draft hardening', () => {
it('rejects consumed, mismatched and expired drafts', async () => {
const invalidDrafts = [
{ consumed: true, email: 'user@example.com', expiresAt: future(), codeExpiresAt: future() },
{ consumed: false, email: 'other@example.com', expiresAt: future(), codeExpiresAt: future() },
];
for (const draft of invalidDrafts) {
const { service } = createHarness({
repository: { findSignupDraftWithSecrets: jest.fn().mockResolvedValue(draft) },
});
await expect(
service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(BadRequestException);
}
const expired = createHarness({
repository: {
findSignupDraftWithSecrets: jest.fn().mockResolvedValue({
consumed: false,
email: 'user@example.com',
expiresAt: new Date(Date.now() - 1),
codeExpiresAt: future(),
}),
},
});
await expect(
expired.service.signupVerify({
signupId: 'draft-1',
email: 'user@example.com',
code: '123456',
}),
).rejects.toBeInstanceOf(GoneException);
});
it('returns 429 before checking a draft code at the attempt limit', async () => {
const { service } = createHarness({
repository: {
findSignupDraftWithSecrets: jest.fn().mockResolvedValue({
id: 'draft-1',
consumed: false,
email: 'user@example.com',
expiresAt: future(),
codeExpiresAt: future(),
attempts: 3,
}),
},
});
await expect(
service.signupVerify({ signupId: 'draft-1', email: 'user@example.com', code: '123456' }),
).rejects.toBeInstanceOf(HttpException);
});
it('uses bounded expiration parsing and token masking helpers', () => {
const { service } = createHarness();
expect((service as any).parseExpiresInToMs('10s')).toBe(10_000);
expect((service as any).parseExpiresInToMs('5m')).toBe(300_000);
expect((service as any).parseExpiresInToMs('2h')).toBe(7_200_000);
expect((service as any).parseExpiresInToMs('1d')).toBe(86_400_000);
expect((service as any).parseExpiresInToMs('invalid')).toBe(2_592_000_000);
expect((service as any).clampMinutes(Number.NaN, 5, 30)).toBe(5);
expect((service as any).clampMinutes(99, 5, 30)).toBe(30);
expect((service as any).maskToken('short')).toBe('[redacted]');
expect((service as any).maskToken('1234567890abcdefghijkl')).toBe('12345678...efghijkl');
});
});
});
+29 -17
View File
@@ -10,7 +10,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { randomBytes, randomInt, randomUUID } from 'crypto';
import { OAuth2Client } from 'google-auth-library';
import {
@@ -39,6 +39,7 @@ import { VerifyResetCodeDto } from './dto/verify-reset-code.dto';
import { AuthRepository } from './auth.repository';
import { AuthResult, TokenPair } from './types/token-pair.type';
import { DEFAULT_SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
import { verifyTotp } from '../../common/utils/totp.util';
type UploadedImageFile = {
mimetype?: string;
@@ -335,7 +336,11 @@ export class AuthService {
throw new ForbiddenException('Account is disabled');
}
await this.authRepository.revokeUserTokenByJti(decoded.sub, decoded.jti);
const consumed = await this.authRepository.consumeUserTokenByJti(decoded.sub, decoded.jti);
if (!consumed) {
await this.authRepository.markCompromisedAndRevokeAll(decoded.sub);
throw new UnauthorizedException('Refresh token reuse detected');
}
const authTokens = await this.generateAndStoreTokenPair(
decoded.sub,
safeUser.username,
@@ -440,11 +445,7 @@ export class AuthService {
});
payload = ticket.getPayload();
} catch (error) {
this.logger.warn(
`Google id token verification failed: ${this.getErrorMessage(error)}; token=${this.maskToken(
dto.idToken,
)}`,
);
this.logger.warn(`Google id token verification failed: ${this.getErrorMessage(error)}`);
throw new UnauthorizedException('Invalid Google id token');
}
@@ -467,16 +468,24 @@ export class AuthService {
}> {
const configuredEmail = this.configService.get<string>('superAdmin.email', { infer: true });
const configuredPassword = this.configService.get<string>('superAdmin.password', { infer: true });
const configuredPasswordHash = this.configService.get<string>('superAdmin.passwordHash', { infer: true });
const totpSecret = this.configService.get<string>('superAdmin.totpSecret', { infer: true }) ?? '';
const passwordMatches = configuredPasswordHash
? await compareHash(dto.password, configuredPasswordHash)
: !!configuredPassword && dto.password === configuredPassword;
if (
!configuredEmail ||
!configuredPassword ||
dto.email.toLowerCase() !== configuredEmail.toLowerCase() ||
dto.password !== configuredPassword
!passwordMatches
) {
throw new UnauthorizedException('Invalid superadmin credentials');
}
if (totpSecret && (!dto.otpCode || !verifyTotp(totpSecret, dto.otpCode))) {
throw new UnauthorizedException('Invalid superadmin verification code');
}
const tokens = await this.generateAndStoreSuperAdminTokenPair(configuredEmail);
return { ...tokens, superAdmin: { email: configuredEmail } };
}
@@ -516,7 +525,11 @@ export class AuthService {
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
await this.authRepository.revokeSuperAdminTokenByJti(decoded.email, decoded.jti);
const consumed = await this.authRepository.consumeSuperAdminTokenByJti(decoded.email, decoded.jti);
if (!consumed) {
await this.authRepository.revokeAllSuperAdminTokens(decoded.email);
throw new UnauthorizedException('Superadmin refresh token reuse detected');
}
const tokens = await this.generateAndStoreSuperAdminTokenPair(decoded.email);
return { ...tokens, superAdmin: { email: decoded.email } };
}
@@ -643,7 +656,7 @@ export class AuthService {
{ sub: user.id, tokenType: 'password_reset', prcId: codeRecord.id },
{
secret: this.configService.get<string>('passwordReset.tokenSecret', { infer: true }),
expiresIn: resetTokenExpiresIn,
expiresIn: resetTokenExpiresIn as JwtSignOptions['expiresIn'],
},
);
@@ -671,7 +684,7 @@ export class AuthService {
throw new UnauthorizedException('Invalid or expired reset token');
}
const codeRecord = await this.authRepository.findValidVerifiedPasswordResetCode(
const codeRecord = await this.authRepository.consumeValidVerifiedPasswordResetCode(
decoded.prcId,
decoded.sub,
);
@@ -688,7 +701,6 @@ export class AuthService {
const passwordHash = await hashValue(dto.newPassword, saltRounds);
await this.usersService.updatePassword(decoded.sub, passwordHash);
await this.authRepository.markPasswordResetCodeUsed(codeRecord.id);
await this.authRepository.markPasswordResetCodeUsedByUser(decoded.sub);
await this.authRepository.revokeAllUserTokens(decoded.sub);
await this.authRepository.removeExpiredAndRevoked(decoded.sub);
@@ -707,14 +719,14 @@ export class AuthService {
{ sub: userId, username, role, tokenType: 'access' },
{
secret: this.configService.get<string>('jwt.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('jwt.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
this.jwtService.signAsync(
{ sub: userId, username, role, tokenType: 'refresh', jti: refreshJti },
{
secret: this.configService.get<string>('jwt.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
]);
@@ -746,7 +758,7 @@ export class AuthService {
},
{
secret: this.configService.get<string>('superAdmin.accessSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.accessExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
this.jwtService.signAsync(
@@ -760,7 +772,7 @@ export class AuthService {
},
{
secret: this.configService.get<string>('superAdmin.refreshSecret', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }),
expiresIn: this.configService.get<string>('superAdmin.refreshExpiresIn', { infer: true }) as JwtSignOptions['expiresIn'],
},
),
]);
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SuperAdminLoginDto {
@ApiProperty({ example: 'admin@oudelaa.com' })
@@ -10,4 +10,10 @@ export class SuperAdminLoginDto {
@IsString()
@Length(8, 128)
password!: string;
@ApiPropertyOptional({ description: 'TOTP code; required when SUPERADMIN_TOTP_SECRET is set' })
@IsOptional()
@IsString()
@Matches(/^\d{6}$/)
otpCode?: string;
}
@@ -28,5 +28,5 @@ export class RefreshToken {
export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
RefreshTokenSchema.index({ userId: 1, revoked: 1 });
RefreshTokenSchema.index({ userId: 1, jti: 1 });
RefreshTokenSchema.index({ userId: 1, jti: 1 }, { unique: true });
RefreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
@@ -0,0 +1,120 @@
import { UnauthorizedException } from '@nestjs/common';
import { GoogleStrategy } from './google.strategy';
import { JwtRefreshStrategy } from './jwt-refresh.strategy';
import { JwtStrategy } from './jwt.strategy';
import { SuperAdminJwtStrategy } from './super-admin-jwt.strategy';
const config = (values: Record<string, string> = {}) => ({
get: jest.fn((key: string) => values[key] ?? 'test-secret'),
});
describe('authentication strategies', () => {
describe('JwtStrategy', () => {
it('accepts access tokens only for active users', async () => {
const repository = { findById: jest.fn().mockResolvedValue({ isDisabled: false }) };
const strategy = new JwtStrategy(
config({ 'jwt.accessSecret': 'access-secret' }) as any,
repository as any,
);
const payload = { sub: 'user-1', tokenType: 'access' } as any;
await expect(strategy.validate(payload)).resolves.toBe(payload);
expect(repository.findById).toHaveBeenCalledWith('user-1');
});
it('rejects wrong token purpose before database access', async () => {
const repository = { findById: jest.fn() };
const strategy = new JwtStrategy(config() as any, repository as any);
await expect(
strategy.validate({ sub: 'user-1', tokenType: 'refresh' } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(repository.findById).not.toHaveBeenCalled();
});
it.each([null, { isDisabled: true }])('rejects unavailable account %#', async (user) => {
const strategy = new JwtStrategy(
config() as any,
{ findById: jest.fn().mockResolvedValue(user) } as any,
);
await expect(
strategy.validate({ sub: 'user-1', tokenType: 'access' } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
describe('purpose-bound stateless strategies', () => {
it('accepts only user refresh tokens in JwtRefreshStrategy', () => {
const strategy = new JwtRefreshStrategy(config() as any);
const payload = { sub: 'user-1', tokenType: 'refresh' } as any;
expect(strategy.validate(payload)).toBe(payload);
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
UnauthorizedException,
);
});
it('accepts only superadmin access tokens', () => {
const strategy = new SuperAdminJwtStrategy(config() as any);
const payload = { sub: 'superadmin', tokenType: 'superadmin_access' } as any;
expect(strategy.validate(payload)).toBe(payload);
expect(() => strategy.validate({ tokenType: 'access' } as any)).toThrow(
UnauthorizedException,
);
});
});
describe('GoogleStrategy', () => {
it('normalizes profile data before authentication', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate(
'access',
'refresh',
{
id: 'google-1',
displayName: 'Google Artist',
emails: [{ value: 'ARTIST@EXAMPLE.COM', verified: true }],
photos: [{ value: 'https://example.com/avatar.jpg' }],
} as any,
done,
);
expect(done).toHaveBeenCalledWith(null, {
googleId: 'google-1',
email: 'artist@example.com',
name: 'Google Artist',
avatar: 'https://example.com/avatar.jpg',
});
});
it('rejects profiles without an email', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate('access', 'refresh', { id: 'google-1' } as any, done);
expect(done).toHaveBeenCalledWith(expect.any(UnauthorizedException));
});
it('provides a safe fallback display name', () => {
const strategy = new GoogleStrategy(config() as any);
const done = jest.fn();
strategy.validate(
'access',
'refresh',
{ id: 'google-1', emails: [{ value: 'artist@example.com' }] } as any,
done,
);
expect(done).toHaveBeenCalledWith(
null,
expect.objectContaining({ name: 'Google User', avatar: undefined }),
);
});
});
});
@@ -1,7 +1,6 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@@ -11,12 +10,11 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh'
super({
jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }),
passReqToCallback: true,
secretOrKey: configService.get<string>('jwt.refreshSecret', { infer: true }) ?? '',
});
}
validate(_: Request, payload: JwtPayload): JwtPayload {
validate(payload: JwtPayload): JwtPayload {
if (payload.tokenType !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
+8 -3
View File
@@ -3,22 +3,27 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
import { UsersRepository } from '../../users/users.repository';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
constructor(configService: ConfigService, private readonly usersRepository: UsersRepository) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }),
secretOrKey: configService.get<string>('jwt.accessSecret', { infer: true }) ?? '',
});
}
validate(payload: JwtPayload): JwtPayload {
async validate(payload: JwtPayload): Promise<JwtPayload> {
if (payload.tokenType !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.usersRepository.findById(payload.sub);
if (!user || user.isDisabled) {
throw new UnauthorizedException('Account is unavailable');
}
return payload;
}
}
@@ -10,7 +10,7 @@ export class SuperAdminJwtStrategy extends PassportStrategy(Strategy, 'superadmi
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }),
secretOrKey: configService.get<string>('superAdmin.accessSecret', { infer: true }) ?? '',
});
}
@@ -0,0 +1,36 @@
import { Server } from 'socket.io';
import { ChatRealtimeService } from './chat-realtime.service';
describe('ChatRealtimeService', () => {
it('silently skips delivery until a socket server is bound', () => {
const service = new ChatRealtimeService();
expect(() => service.emitNewMessage('conversation-1', { id: 'message-1' })).not.toThrow();
});
it('emits a new message only to the matching conversation room', () => {
const service = new ChatRealtimeService();
const emit = jest.fn();
const to = jest.fn().mockReturnValue({ emit });
const server = { to } as unknown as Server;
const message = { id: 'message-1', body: 'hello' };
service.bindServer(server);
service.emitNewMessage('conversation-42', message);
expect(to).toHaveBeenCalledWith('conversation:conversation-42');
expect(emit).toHaveBeenCalledWith('new_message', message);
});
it('does not hide socket delivery errors', () => {
const service = new ChatRealtimeService();
const server = {
to: jest.fn(() => {
throw new Error('socket unavailable');
}),
} as unknown as Server;
service.bindServer(server);
expect(() => service.emitNewMessage('conversation-1', {})).toThrow('socket unavailable');
});
});
+6 -1
View File
@@ -13,6 +13,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
@@ -73,7 +74,11 @@ export class ChatController {
@Post('messages/upload')
@Throttle(60, 60_000)
@UseInterceptors(FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }]))
@UseInterceptors(
FileFieldsInterceptor([{ name: 'mediaFile', maxCount: 1 }], {
limits: { fileSize: MEDIA_MAX_SIZE_BYTES.postsVideo, files: 1 },
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
+15 -1
View File
@@ -24,7 +24,16 @@ import { UsersService } from '../users/users.service';
type SocketWithUser = Socket & { data: { userId?: string } };
@WebSocketGateway({ cors: { origin: '*' }, namespace: 'chat' })
@WebSocketGateway({
cors: {
origin:
process.env.NODE_ENV === 'production'
? (process.env.CORS_ORIGINS ?? '').split(',').map((value) => value.trim()).filter(Boolean)
: true,
credentials: true,
},
namespace: 'chat',
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
@@ -59,6 +68,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
client.disconnect(true);
return;
}
const user = await this.usersService.findByIdOrFail(payload.sub);
if (user.isDisabled) {
client.disconnect(true);
return;
}
client.data.userId = payload.sub;
const connectionCount = this.incrementUserConnection(payload.sub);
if (connectionCount === 1) {
+424 -1
View File
@@ -1,4 +1,6 @@
import { Types } from 'mongoose';
import { ReactionType } from '../../common/enums/reaction-type.enum';
import { SortOrder } from '../../common/enums/sort-order.enum';
import { ChatService } from './chat.service';
describe('ChatService realtime message broadcasting', () => {
@@ -7,6 +9,7 @@ describe('ChatService realtime message broadcasting', () => {
const conversationId = new Types.ObjectId().toString();
let chatRepository: Record<string, jest.Mock>;
let usersRepository: Record<string, jest.Mock>;
let notificationsService: Record<string, jest.Mock>;
let storageService: Record<string, jest.Mock>;
let chatRealtimeService: Record<string, jest.Mock>;
@@ -28,6 +31,22 @@ describe('ChatService realtime message broadcasting', () => {
clearConversationUnreadForUser: jest.fn().mockResolvedValue(undefined),
markMessageDelivered: jest.fn().mockResolvedValue(undefined),
hideConversationForUser: jest.fn().mockResolvedValue(conversation),
findDirectConversation: jest.fn().mockResolvedValue(null),
createConversation: jest.fn(),
findConversationsForUser: jest.fn().mockResolvedValue([]),
countConversationsForUser: jest.fn().mockResolvedValue(0),
findMessages: jest.fn().mockResolvedValue([]),
countMessages: jest.fn().mockResolvedValue(0),
unsendMessage: jest.fn(),
setMessageReaction: jest.fn(),
deleteMessageForUser: jest.fn().mockResolvedValue(undefined),
createBlock: jest.fn().mockResolvedValue(undefined),
removeBlock: jest.fn().mockResolvedValue(undefined),
findBlock: jest.fn().mockResolvedValue(null),
findBlocksByBlocker: jest.fn().mockResolvedValue([]),
};
usersRepository = {
findById: jest.fn().mockResolvedValue({ isDisabled: false }),
};
notificationsService = {
createMessageNotification: jest.fn().mockResolvedValue(null),
@@ -42,7 +61,7 @@ describe('ChatService realtime message broadcasting', () => {
service = new ChatService(
chatRepository as any,
{} as any,
usersRepository as any,
notificationsService as any,
storageService as any,
chatRealtimeService as any,
@@ -197,4 +216,408 @@ describe('ChatService realtime message broadcasting', () => {
);
expect(chatRepository.hideConversationForUser).not.toHaveBeenCalled();
});
describe('conversation lifecycle', () => {
it('requires another participant and validates every participant id', async () => {
await expect(
service.createConversation(senderId, { participantIds: [senderId] }),
).rejects.toThrow('Conversation must include at least 2 participants');
await expect(
service.createConversation(senderId, { participantIds: ['not-an-object-id'] }),
).rejects.toThrow('Invalid participant id');
expect(usersRepository.findById).not.toHaveBeenCalled();
});
it('rejects disabled participants and an invalid direct-conversation shape', async () => {
usersRepository.findById.mockResolvedValueOnce({ isDisabled: false }).mockResolvedValueOnce({
isDisabled: true,
});
await expect(
service.createConversation(senderId, { participantIds: [recipientId] }),
).rejects.toThrow('One or more participants are invalid or disabled');
const thirdId = new Types.ObjectId().toString();
await expect(
service.createConversation(senderId, {
participantIds: [recipientId, thirdId],
isGroup: false,
}),
).rejects.toThrow('Direct conversation must contain exactly 2 participants');
});
it('returns an existing direct conversation and enforces chat blocks', async () => {
const existing = { id: conversationId };
chatRepository.findDirectConversation.mockResolvedValue(existing);
await expect(
service.createConversation(senderId, { participantIds: [recipientId] }),
).resolves.toBe(existing);
expect(chatRepository.createConversation).not.toHaveBeenCalled();
chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' });
await expect(
service.createConversation(senderId, { participantIds: [recipientId] }),
).rejects.toThrow('You cannot start chat with this user');
});
it('creates a deduplicated group conversation', async () => {
const thirdId = new Types.ObjectId().toString();
const created = { id: conversationId, isGroup: true };
chatRepository.createConversation.mockResolvedValue(created);
await expect(
service.createConversation(senderId, {
participantIds: [recipientId, recipientId, thirdId],
title: 'Band',
}),
).resolves.toBe(created);
expect(chatRepository.createConversation).toHaveBeenCalledWith({
participantIds: [senderId, recipientId, thirdId],
isGroup: true,
title: 'Band',
createdBy: senderId,
});
});
it('maps unread counters and cursor metadata in the conversation list', async () => {
const conversations = [
{
lastMessageAt: undefined,
toObject: () => ({ id: 'c1', unreadCountByUser: new Map([[senderId, '2']]) }),
},
{
lastMessageAt: new Date('2025-01-01T00:00:00.000Z'),
toObject: () => ({
id: 'c2',
unreadCountByUser: { [senderId]: 3, $__internal: 9, $isMongooseMap: true },
}),
},
{
lastMessageAt: undefined,
toObject: () => ({ id: 'c3', unreadCountByUser: 'invalid' }),
},
];
chatRepository.findConversationsForUser.mockResolvedValue(conversations);
chatRepository.countConversationsForUser.mockResolvedValue(5);
const result = await service.getMyConversations(senderId, {
page: 1,
limit: 3,
sortOrder: SortOrder.ASC,
});
expect(chatRepository.findConversationsForUser).toHaveBeenCalledWith(senderId, 0, 3, {
lastMessageAt: 1,
updatedAt: 1,
});
expect(result.items[0]).toMatchObject({ unreadCount: 2, lastMessageAt: null });
expect(result.items[1].unreadCountByUser).toEqual({ [senderId]: 3 });
expect(result.items[2].unreadCountByUser).toEqual({});
expect(result.nextCursor).toEqual(expect.any(String));
});
it('lists messages using a cursor and clears unread state', async () => {
const messages = [{ id: 'm1' }];
chatRepository.findMessages.mockResolvedValue(messages);
chatRepository.countMessages.mockResolvedValue(4);
const cursor = Buffer.from('2', 'utf8').toString('base64url');
const result = await service.getMessages(senderId, conversationId, {
page: 9,
limit: 1,
cursor,
sortOrder: SortOrder.ASC,
});
expect(chatRepository.findMessages).toHaveBeenCalledWith(
conversationId,
senderId,
2,
1,
{ createdAt: 1 },
);
expect(chatRepository.clearConversationUnreadForUser).toHaveBeenCalledWith(
conversationId,
senderId,
);
expect(result.items).toEqual(messages);
expect(result.nextCursor).toEqual(expect.any(String));
});
it('requires a conversation id and reports a failed hide update', async () => {
await expect(service.getMessages(senderId, ' ', {})).rejects.toThrow(
'conversationId is required',
);
chatRepository.hideConversationForUser.mockResolvedValue(null);
await expect(service.hideConversationForMe(senderId, conversationId)).rejects.toThrow(
'Conversation not found',
);
});
});
describe('message validation and mutations', () => {
it('requires text content and a URL for non-text messages', async () => {
await expect(
service.sendMessage(senderId, { conversationId, content: ' ', messageType: 'text' }),
).rejects.toThrow('Text message content is required');
await expect(
service.sendMessage(senderId, { conversationId, messageType: 'video' }),
).rejects.toThrow('mediaUrl is required for non-text messages');
});
it('prevents messaging any blocked participant', async () => {
chatRepository.findAnyBlockBetween.mockResolvedValue({ id: 'block-1' });
await expect(
service.sendMessage(senderId, { conversationId, content: 'hello' }),
).rejects.toThrow('Cannot send message because one of participants is blocked');
expect(chatRepository.createMessage).not.toHaveBeenCalled();
});
it('validates reply ids and accepts replies from the same conversation', async () => {
await expect(
service.sendMessage(senderId, {
conversationId,
content: 'reply',
replyToMessageId: 'invalid',
}),
).rejects.toThrow('Invalid replyToMessageId');
const replyId = new Types.ObjectId().toString();
chatRepository.findMessageById.mockResolvedValueOnce({
id: replyId,
conversationId: new Types.ObjectId(),
});
await expect(
service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }),
).rejects.toThrow('Reply message must belong to the same conversation');
const message = {
id: new Types.ObjectId().toString(),
conversationId: new Types.ObjectId(conversationId),
};
chatRepository.findMessageById.mockResolvedValueOnce({
id: replyId,
conversationId: new Types.ObjectId(conversationId),
});
chatRepository.createMessage.mockResolvedValue(message);
notificationsService.createMessageNotification.mockRejectedValue(new Error('push unavailable'));
await expect(
service.sendMessage(senderId, { conversationId, content: 'reply', replyToMessageId: replyId }),
).resolves.toBe(message);
expect(chatRepository.createMessage).toHaveBeenCalledWith(
expect.objectContaining({ replyToMessageId: replyId }),
);
});
it('requires an upload and cleans stored media when message creation fails', async () => {
await expect(service.sendMessageWithUpload(senderId, { conversationId })).rejects.toThrow(
'mediaFile is required',
);
chatRepository.createMessage.mockRejectedValue(new Error('database failed'));
await expect(
service.sendMessageWithUpload(
senderId,
{ conversationId },
{
mimetype: 'image/png',
size: 10,
buffer: Buffer.from('png'),
originalname: 'photo.png',
},
),
).rejects.toThrow('database failed');
expect(storageService.deleteFile).toHaveBeenCalledWith('/uploads/chat/media/message.jpg');
});
it('detects uploaded audio and rejects unsupported media', async () => {
const message = {
id: new Types.ObjectId().toString(),
conversationId: new Types.ObjectId(conversationId),
};
storageService.saveFile.mockResolvedValue('/uploads/chat/media/message.mp3');
chatRepository.createMessage.mockResolvedValue(message);
await service.sendMessageWithUpload(
senderId,
{ conversationId },
{
mimetype: 'audio/mpeg',
size: 10,
buffer: Buffer.from('audio'),
originalname: 'voice.mp3',
},
);
expect(storageService.saveFile).toHaveBeenCalledWith(
expect.objectContaining({ extension: '.mp3', contentType: 'audio/mpeg' }),
);
expect(chatRepository.createMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageType: 'audio' }),
);
await expect(
service.sendMessageWithUpload(
senderId,
{ conversationId },
{
mimetype: 'application/pdf',
size: 10,
buffer: Buffer.from('pdf'),
originalname: 'file.pdf',
},
),
).rejects.toThrow('mediaFile must be image, video, or audio');
});
it('validates seen-message identifiers and ownership', async () => {
await expect(service.markMessageSeen(senderId, 'invalid')).rejects.toThrow(
'Invalid message id',
);
const messageId = new Types.ObjectId().toString();
await expect(service.markMessageSeen(senderId, messageId, 'invalid')).rejects.toThrow(
'Invalid conversation id',
);
chatRepository.findMessageById.mockResolvedValueOnce(null);
await expect(service.markMessageSeen(senderId, messageId)).rejects.toThrow(
'Message not found',
);
chatRepository.findMessageById.mockResolvedValueOnce({
id: messageId,
conversationId: new Types.ObjectId(),
});
await expect(service.markMessageSeen(senderId, messageId, conversationId)).rejects.toThrow(
'Message must belong to the conversation',
);
});
it('validates delivered-message identifiers and conversation ownership', async () => {
await expect(
service.markMessageDelivered(recipientId, conversationId, 'invalid'),
).rejects.toThrow('Invalid message id');
const messageId = new Types.ObjectId().toString();
chatRepository.findMessageById.mockResolvedValue(null);
await expect(
service.markMessageDelivered(recipientId, conversationId, messageId),
).rejects.toThrow('Message must belong to the conversation');
});
it('unsends only an existing message owned by the current user', async () => {
const messageId = new Types.ObjectId().toString();
chatRepository.findMessageById.mockResolvedValueOnce(null);
await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found');
chatRepository.findMessageById.mockResolvedValueOnce({
id: messageId,
senderId: new Types.ObjectId(recipientId),
});
await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow(
'You can only unsend your own messages',
);
chatRepository.findMessageById.mockResolvedValue({
id: messageId,
senderId: new Types.ObjectId(senderId),
});
chatRepository.unsendMessage.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: messageId });
await expect(service.unsendMessage(senderId, messageId)).rejects.toThrow('Message not found');
await expect(service.unsendMessage(senderId, messageId)).resolves.toEqual({ id: messageId });
});
it('reacts to and locally deletes messages only after membership checks', async () => {
const messageId = new Types.ObjectId().toString();
chatRepository.findMessageById.mockResolvedValueOnce(null);
await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow(
'Message not found',
);
const message = {
id: messageId,
conversationId: new Types.ObjectId(conversationId),
};
chatRepository.findMessageById.mockResolvedValue(message);
chatRepository.setMessageReaction.mockResolvedValueOnce(null).mockResolvedValueOnce({
id: messageId,
reactions: [{ type: ReactionType.LOVE }],
});
await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).rejects.toThrow(
'Message not found',
);
await expect(service.reactToMessage(senderId, messageId, ReactionType.LOVE)).resolves.toMatchObject({
id: messageId,
});
await expect(service.deleteMessageForMe(senderId, messageId)).resolves.toEqual({ success: true });
expect(chatRepository.deleteMessageForUser).toHaveBeenCalledWith(messageId, senderId);
chatRepository.findMessageById.mockResolvedValue(null);
await expect(service.deleteMessageForMe(senderId, messageId)).rejects.toThrow(
'Message not found',
);
});
});
describe('chat block and membership rules', () => {
it('validates conversation ids, existence, and membership', async () => {
await expect(service.assertConversationMember(senderId, 'invalid')).rejects.toThrow(
'Invalid conversation id',
);
chatRepository.findConversationById.mockResolvedValueOnce(null);
await expect(service.assertConversationMember(senderId, conversationId)).rejects.toThrow(
'Conversation not found',
);
const outsiderId = new Types.ObjectId().toString();
await expect(service.assertConversationMember(outsiderId, conversationId)).rejects.toThrow(
'You are not a member of this conversation',
);
});
it('blocks and unblocks a valid existing target', async () => {
await expect(service.blockUser(senderId, recipientId)).resolves.toEqual({
blocked: true,
targetUserId: recipientId,
});
expect(chatRepository.createBlock).toHaveBeenCalledWith(senderId, recipientId);
await expect(service.unblockUser(senderId, recipientId)).resolves.toEqual({
blocked: false,
targetUserId: recipientId,
});
expect(chatRepository.removeBlock).toHaveBeenCalledWith(senderId, recipientId);
});
it('rejects invalid, self, and missing block targets', async () => {
await expect(service.blockUser(senderId, 'invalid')).rejects.toThrow('Invalid target user id');
await expect(service.blockUser(senderId, senderId)).rejects.toThrow('You cannot block yourself');
usersRepository.findById.mockResolvedValue(null);
await expect(service.blockUser(senderId, recipientId)).rejects.toThrow('Target user not found');
await expect(service.unblockUser(senderId, 'invalid')).rejects.toThrow(
'Invalid target user id',
);
});
it('returns both block directions and the current block list', async () => {
chatRepository.findBlock.mockResolvedValueOnce({ id: 'mine' }).mockResolvedValueOnce(null);
await expect(service.getBlockStatus(senderId, recipientId)).resolves.toEqual({
targetUserId: recipientId,
iBlocked: true,
blockedMe: false,
});
await expect(service.getBlockStatus(senderId, 'invalid')).rejects.toThrow(
'Invalid target user id',
);
const items = [{ id: 'block-1' }];
chatRepository.findBlocksByBlocker.mockResolvedValue(items);
await expect(service.getMyBlockedUsers(senderId)).resolves.toEqual({ items });
});
});
});
@@ -497,4 +497,236 @@ describe('CollaborationRequestsService', () => {
await expect(service.cancel(requesterId, request.id)).rejects.toBeInstanceOf(BadRequestException);
expect(request.save).not.toHaveBeenCalled();
});
it('validates post, owner, target, and block state for post collaboration requests', async () => {
const requesterId = new Types.ObjectId().toString();
const postId = new Types.ObjectId().toString();
const invalid = createService();
await expect(invalid.service.create(requesterId, 'invalid', {})).rejects.toThrow(
'Invalid collaboration request',
);
const missingPost = createService();
missingPost.postsRepository.findById.mockResolvedValue(null);
await expect(missingPost.service.create(requesterId, postId, {})).rejects.toThrow(
'Post not found',
);
const invalidOwner = createService();
invalidOwner.postsRepository.findById.mockResolvedValue({ authorId: {} });
await expect(invalidOwner.service.create(requesterId, postId, {})).rejects.toThrow(
'Post owner is invalid',
);
const targetId = new Types.ObjectId().toString();
const disabled = createService();
disabled.postsRepository.findById.mockResolvedValue({ authorId: targetId });
disabled.usersRepository.findById.mockResolvedValue({ isDisabled: true });
disabled.blocksRepository.findAnyBetween.mockResolvedValue(null);
await expect(disabled.service.create(requesterId, postId, {})).rejects.toThrow(
'Target user not found',
);
const blocked = createService();
blocked.postsRepository.findById.mockResolvedValue({ authorId: targetId });
blocked.usersRepository.findById.mockResolvedValue({ isDisabled: false });
blocked.blocksRepository.findAnyBetween.mockResolvedValue({ id: 'block-1' });
await expect(blocked.service.create(requesterId, postId, {})).rejects.toThrow(
'You cannot invite this user',
);
});
it('reports failed upserts for post and general requests', async () => {
const requesterId = new Types.ObjectId().toString();
const targetId = new Types.ObjectId().toString();
const postId = new Types.ObjectId().toString();
const postRequest = createService();
postRequest.postsRepository.findById.mockResolvedValue({ authorId: targetId });
postRequest.usersRepository.findById.mockResolvedValue({ isDisabled: false });
postRequest.blocksRepository.findAnyBetween.mockResolvedValue(null);
postRequest.model.findOne.mockReturnValue(chain(null));
postRequest.model.findOneAndUpdate.mockReturnValue(chain(null));
await expect(postRequest.service.create(requesterId, postId, {})).rejects.toThrow(
'Collaboration request not found',
);
const generalRequest = createService();
generalRequest.usersRepository.findById.mockResolvedValue({ isDisabled: false });
generalRequest.blocksRepository.findAnyBetween.mockResolvedValue(null);
generalRequest.model.findOne.mockReturnValue(chain(null));
generalRequest.model.findOneAndUpdate.mockReturnValue(chain(null));
await expect(
generalRequest.service.createGeneral(requesterId, targetId, {}),
).rejects.toThrow('Collaboration request not found');
await expect(
generalRequest.service.createGeneral(requesterId, 'invalid', {}),
).rejects.toThrow('Invalid collaboration request');
});
it('lists pending, received, and sent requests with default pagination', async () => {
const userId = new Types.ObjectId().toString();
const request = createRequestDoc();
const { service, model } = createService();
model.find.mockReturnValue(chain([request]));
model.countDocuments.mockReturnValue(chain(1));
await service.getMine(userId, {});
expect(model.find).toHaveBeenLastCalledWith({
targetUserId: new Types.ObjectId(userId),
status: 'pending',
});
await service.listSent(userId, {});
expect(model.find).toHaveBeenLastCalledWith({ requesterId: new Types.ObjectId(userId) });
});
it('cancels a pending request and remains successful if notification delivery fails', async () => {
const requesterId = new Types.ObjectId().toString();
const targetUserId = new Types.ObjectId().toString();
const request = createRequestDoc({
postId: null,
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
});
const { service, model, notificationsService } = createService();
await expect(service.cancel(requesterId, 'invalid')).rejects.toThrow(
'Invalid collaboration request id',
);
model.findById.mockReturnValueOnce(chain(null));
await expect(service.cancel(requesterId, request.id)).rejects.toThrow(
'Collaboration request not found',
);
model.findById.mockReturnValue(chain(request));
model.findOne.mockReturnValue(chain({ ...request, status: 'cancelled' }));
notificationsService.create.mockRejectedValue('push offline');
await expect(service.cancel(requesterId, request.id)).resolves.toMatchObject({
cancelled: true,
request: expect.objectContaining({ status: 'cancelled' }),
});
expect(request.status).toBe('cancelled');
expect(request.save).toHaveBeenCalled();
expect(notificationsService.create).toHaveBeenCalledWith(
expect.objectContaining({
type: 'collaboration_request_cancelled',
resourceType: 'user',
referenceId: requesterId,
}),
);
});
it('rejects a pending request and protects status transitions', async () => {
const requesterId = new Types.ObjectId().toString();
const targetUserId = new Types.ObjectId().toString();
const request = createRequestDoc({
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
});
const { service, model, notificationsService } = createService();
await expect(service.reject(targetUserId, 'invalid')).rejects.toThrow(
'Invalid collaboration request id',
);
model.findById.mockReturnValueOnce(chain(null));
await expect(service.reject(targetUserId, request.id)).rejects.toThrow(
'Collaboration request not found',
);
model.findById.mockReturnValueOnce(
chain(createRequestDoc({ targetUserId: new Types.ObjectId(), requesterId: request.requesterId })),
);
await expect(service.reject(targetUserId, request.id)).rejects.toThrow(
'Only the target user can update this collaboration request',
);
model.findById.mockReturnValueOnce(chain({ ...request, status: 'approved' }));
await expect(service.reject(targetUserId, request.id)).rejects.toThrow(
'Only pending collaboration requests can be updated',
);
model.findById.mockReturnValue(chain(request));
model.findOne.mockReturnValue(chain({ ...request, status: 'rejected' }));
await expect(service.reject(targetUserId, request.id)).resolves.toMatchObject({
rejected: true,
request: expect.objectContaining({ status: 'rejected' }),
});
expect(notificationsService.create).toHaveBeenCalledWith(
expect.objectContaining({ type: 'collaboration_request_rejected' }),
);
});
it('stores a validated audio attachment and updates an empty duplicate request safely', async () => {
const requesterId = new Types.ObjectId().toString();
const targetUserId = new Types.ObjectId().toString();
const existing = createRequestDoc({
postId: null,
requesterId: new Types.ObjectId(requesterId),
targetUserId: new Types.ObjectId(targetUserId),
});
const { service, model, usersRepository, blocksRepository, storageService } = createService();
usersRepository.findById.mockResolvedValue({ isDisabled: false });
blocksRepository.findAnyBetween.mockResolvedValue(null);
model.findOne
.mockReturnValueOnce(chain(null))
.mockReturnValueOnce(chain({ ...existing, attachmentUrl: '/audio.mp3' }));
model.findOneAndUpdate.mockReturnValue(chain(existing));
await service.createGeneral(
requesterId,
targetUserId,
{ message: ' audio ' },
{
buffer: Buffer.from('audio'),
originalname: 'sample.mp3',
mimetype: 'audio/mpeg',
size: 5,
},
);
expect(storageService.saveFile).toHaveBeenCalledWith(
expect.objectContaining({
folderSegments: ['collaboration', 'audio'],
extension: '.mp3',
fileNamePrefix: 'collaboration',
}),
);
const duplicate = createService();
duplicate.usersRepository.findById.mockResolvedValue({ isDisabled: false });
duplicate.blocksRepository.findAnyBetween.mockResolvedValue(null);
duplicate.model.findOne
.mockReturnValueOnce(chain(existing))
.mockReturnValueOnce(chain(existing));
duplicate.model.findById.mockReturnValue(chain(existing));
await duplicate.service.createGeneral(requesterId, targetUserId, {});
expect(duplicate.model.findById).toHaveBeenCalledWith(existing.id);
expect(duplicate.model.findByIdAndUpdate).not.toHaveBeenCalled();
});
it('rejects invisible request details and invalid audio data', async () => {
const userId = new Types.ObjectId().toString();
const requestId = new Types.ObjectId().toString();
const { service, model } = createService();
await expect(service.getById(userId, 'invalid')).rejects.toThrow(
'Invalid collaboration request id',
);
model.findOne.mockReturnValue(chain(null));
await expect(service.getById(userId, requestId)).rejects.toThrow(
'Collaboration request not found',
);
await expect(
(service as any).saveCollaborationAttachment({
buffer: Buffer.alloc(0),
originalname: 'empty.mp3',
mimetype: 'audio/mpeg',
size: 0,
}),
).rejects.toThrow('Invalid audio attachment');
});
});
@@ -8,6 +8,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -31,7 +32,11 @@ export class PostsCollaborationRequestsController {
@Post(':postId/collaboration-requests')
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('attachmentUrl'))
@UseInterceptors(
FileInterceptor('attachmentUrl', {
limits: { fileSize: MEDIA_MAX_SIZE_BYTES.collaborationAudio, files: 1 },
}),
)
async create(
@CurrentUser() user: JwtPayload,
@Param('postId') postId: string,
@@ -40,4 +45,4 @@ export class PostsCollaborationRequestsController {
) {
return this.collaborationRequestsService.create(user.sub, postId, dto, attachmentUrl);
}
}
}
@@ -8,6 +8,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -31,7 +32,11 @@ export class UsersCollaborationRequestsController {
@Post(':targetUserId/collaboration-requests')
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('attachmentUrl'))
@UseInterceptors(
FileInterceptor('attachmentUrl', {
limits: { fileSize: MEDIA_MAX_SIZE_BYTES.collaborationAudio, files: 1 },
}),
)
async createGeneral(
@CurrentUser() user: JwtPayload,
@Param('targetUserId') targetUserId: string,
@@ -45,4 +50,4 @@ export class UsersCollaborationRequestsController {
attachmentUrl,
);
}
}
}
+556 -1
View File
@@ -1,7 +1,561 @@
import { Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { SortOrder } from '../../common/enums/sort-order.enum';
import { CommentSortBy } from './dto/comment-query.dto';
import { CommentsService } from './comments.service';
describe('CommentsService', () => {
const userId = '507f1f77bcf86cd799439011';
const ownerId = '507f191e810c19729de860ea';
const postId = '507f1f77bcf86cd799439012';
const commentId = '507f191e810c19729de860eb';
const setup = (overrides: Record<string, Record<string, jest.Mock>> = {}) => {
const commentsRepository = {
findById: jest.fn().mockResolvedValue(null),
findByIdWithAuthor: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(async (value) => ({ id: commentId, ...value })),
setPinned: jest.fn().mockResolvedValue({ id: commentId }),
deleteById: jest.fn().mockResolvedValue(true),
updateById: jest.fn().mockResolvedValue(null),
updateModerationStatus: jest.fn().mockResolvedValue(null),
countByPost: jest.fn().mockResolvedValue(4),
findMany: jest.fn().mockResolvedValue([]),
findManyTop: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
findManyAdmin: jest.fn().mockResolvedValue([]),
countAdmin: jest.fn().mockResolvedValue(0),
countRepliesByParentIds: jest.fn().mockResolvedValue({}),
countLikesByCommentIds: jest.fn().mockResolvedValue({}),
findLikedCommentIds: jest.fn().mockResolvedValue([]),
findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({}),
...overrides.commentsRepository,
};
const postsRepository = {
findById: jest.fn().mockResolvedValue({
id: postId,
authorId: ownerId,
commentsDisabled: false,
commentsFollowersOnly: false,
commentFilterKeywords: [],
}),
setCommentsCount: jest.fn().mockResolvedValue(undefined),
...overrides.postsRepository,
};
const auditService = {
logSuperAdminAction: jest.fn().mockResolvedValue(undefined),
...overrides.auditService,
};
const feedVersionService = {
bumpGlobalVersion: jest.fn().mockResolvedValue(1),
bumpUserVersion: jest.fn().mockResolvedValue(1),
...overrides.feedVersionService,
};
const notificationsService = {
createCommentNotification: jest.fn().mockResolvedValue(undefined),
createMentionNotification: jest.fn().mockResolvedValue(undefined),
...overrides.notificationsService,
};
const usersRepository = {
findByUsernames: jest.fn().mockResolvedValue([]),
...overrides.usersRepository,
};
const followsRepository = {
findOne: jest.fn().mockResolvedValue(null),
...overrides.followsRepository,
};
const blocksRepository = {
findAnyBetween: jest.fn().mockResolvedValue(null),
...overrides.blocksRepository,
};
const service = new CommentsService(
commentsRepository as any,
postsRepository as any,
auditService as any,
feedVersionService as any,
notificationsService as any,
usersRepository as any,
followsRepository as any,
blocksRepository as any,
);
return {
service,
commentsRepository,
postsRepository,
auditService,
feedVersionService,
notificationsService,
usersRepository,
followsRepository,
blocksRepository,
};
};
it('creates a trimmed filtered comment and notifies unique post, parent and mention recipients', async () => {
const parentId = '507f191e810c19729de860ec';
const parentOwnerId = '507f191e810c19729de860ed';
const mentionedId = '507f191e810c19729de860ee';
const ctx = setup({
postsRepository: {
findById: jest.fn().mockResolvedValue({
authorId: ownerId,
commentsDisabled: false,
commentsFollowersOnly: false,
commentFilterKeywords: [' SPAM '],
}),
},
commentsRepository: {
findById: jest.fn().mockResolvedValue({
postId: { toString: () => postId },
authorId: { toString: () => parentOwnerId },
}),
},
usersRepository: {
findByUsernames: jest.fn().mockResolvedValue([
{ id: mentionedId, username: 'friend' },
{ id: userId, username: 'self' },
]),
},
});
await ctx.service.create(userId, {
postId,
parentCommentId: parentId,
content: ' This has spam @Friend @self ',
mentionUsernames: ['@FRIEND', ' friend '],
});
expect(ctx.commentsRepository.create).toHaveBeenCalledWith({
postId,
authorId: userId,
content: 'This has spam @Friend @self',
mentionUsernames: ['friend'],
parentCommentId: parentId,
hiddenByFilter: true,
hiddenReason: 'keyword_filter',
});
expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4);
expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalledTimes(2);
expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalledWith(
userId,
mentionedId,
postId,
expect.objectContaining({ resourceType: 'comment', deepLink: `/posts/${postId}` }),
);
});
it('validates post and parent comment relationships before creating', async () => {
const missingPost = setup({ postsRepository: { findById: jest.fn().mockResolvedValue(null) } });
await expect(missingPost.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
'Post not found',
);
const missingParent = setup();
await expect(
missingParent.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }),
).rejects.toThrow('Parent comment not found');
const wrongPost = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue({
postId: { toString: () => '507f191e810c19729de860ff' },
authorId: ownerId,
}),
},
});
await expect(
wrongPost.service.create(userId, { postId, content: 'reply', parentCommentId: commentId }),
).rejects.toThrow('Parent comment not found');
});
it('enforces disabled and followers-only comment settings', async () => {
const disabled = setup({
postsRepository: {
findById: jest.fn().mockResolvedValue({ authorId: ownerId, commentsDisabled: true }),
},
});
await expect(disabled.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
'Comments are disabled',
);
const followersOnly = setup({
postsRepository: {
findById: jest.fn().mockResolvedValue({
authorId: ownerId,
commentsDisabled: false,
commentsFollowersOnly: true,
}),
},
});
await expect(followersOnly.service.create(userId, { postId, content: 'hello' })).rejects.toThrow(
'Only followers can comment',
);
const follower = setup({
postsRepository: {
findById: jest.fn().mockResolvedValue({
authorId: ownerId,
commentsDisabled: false,
commentsFollowersOnly: true,
}),
},
followsRepository: { findOne: jest.fn().mockResolvedValue({ id: 'follow' }) },
});
await expect(follower.service.create(userId, { postId, content: 'hello' })).resolves.toBeDefined();
});
it('limits comment mentions to thirty users', async () => {
const ctx = setup();
const mentions = Array.from({ length: 31 }, (_, index) => `user${index}`).join(' @');
await expect(ctx.service.create(userId, { postId, content: `@${mentions}` })).rejects.toThrow(
'You can mention up to 30 users only',
);
expect(ctx.commentsRepository.create).not.toHaveBeenCalled();
});
it.each([
['pin', true, 'Only the post owner can pin comments'],
['unpin', false, 'Only the post owner can unpin comments'],
] as const)('%s validates comment, post, owner and update result', async (method, value, forbiddenMessage) => {
await expect((setup().service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found');
const comment = { postId: { toString: () => postId } };
const noPost = setup({
commentsRepository: { findById: jest.fn().mockResolvedValue(comment) },
postsRepository: { findById: jest.fn().mockResolvedValue(null) },
});
await expect((noPost.service as any)[method](ownerId, commentId)).rejects.toThrow('Post not found');
const forbidden = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
await expect((forbidden.service as any)[method](userId, commentId)).rejects.toThrow(forbiddenMessage);
const lost = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue(comment),
setPinned: jest.fn().mockResolvedValue(null),
},
});
await expect((lost.service as any)[method](ownerId, commentId)).rejects.toThrow('Comment not found');
const success = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
await expect((success.service as any)[method](ownerId, commentId)).resolves.toEqual({ id: commentId });
expect(success.commentsRepository.setPinned).toHaveBeenCalledWith(commentId, value);
});
it('creates replies through the standard creation workflow', async () => {
const ctx = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue({ postId: { toString: () => postId } }),
},
});
const create = jest.spyOn(ctx.service, 'create').mockResolvedValue({ id: 'reply' } as any);
await expect(
ctx.service.createReply(userId, commentId, { content: 'reply', mentionUsernames: ['friend'] }),
).resolves.toEqual({ id: 'reply' });
expect(create).toHaveBeenCalledWith(userId, {
postId,
content: 'reply',
mentionUsernames: ['friend'],
parentCommentId: commentId,
});
await expect(setup().service.createReply(userId, commentId, { content: 'reply' })).rejects.toThrow(
'Parent comment not found',
);
});
it('removes only the actor own comment and resynchronizes counters', async () => {
const own = {
authorId: { toString: () => userId },
postId: { toString: () => postId },
};
const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(own) } });
await expect(ctx.service.remove(userId, commentId)).resolves.toEqual({ success: true });
expect(ctx.commentsRepository.deleteById).toHaveBeenCalledWith(commentId, userId);
expect(ctx.postsRepository.setCommentsCount).toHaveBeenCalledWith(postId, 4);
await expect(setup().service.remove(userId, commentId)).rejects.toThrow('Comment not found');
const foreign = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue({ ...own, authorId: { toString: () => ownerId } }),
},
});
await expect(foreign.service.remove(userId, commentId)).rejects.toThrow(
'You can only delete your own comments',
);
});
it('validates update ownership, payload and repository races', async () => {
await expect(setup().service.update(userId, commentId, { content: 'new' })).rejects.toThrow(
'Comment not found',
);
const base = {
authorId: { toString: () => userId },
postId: { toString: () => postId },
content: 'old',
mentionUsernames: [],
};
const foreign = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue({ ...base, authorId: { toString: () => ownerId } }),
},
});
await expect(foreign.service.update(userId, commentId, { content: 'new' })).rejects.toThrow(
'You can only update your own comments',
);
const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(base) } });
await expect(ctx.service.update(userId, commentId, {})).rejects.toThrow('Nothing to update');
await expect(ctx.service.update(userId, commentId, { content: ' ' })).rejects.toThrow(
'Comment content cannot be empty',
);
await expect(ctx.service.update(userId, commentId, { content: 'valid' })).rejects.toThrow(
'Comment not found',
);
});
it('deletes comments as superadmin and records an audit trail', async () => {
const comment = { postId: { toString: () => postId } };
const ctx = setup({ commentsRepository: { findById: jest.fn().mockResolvedValue(comment) } });
await expect(ctx.service.removeBySuperAdmin('admin@example.com', commentId)).resolves.toEqual({
success: true,
message: 'Comment deleted by superadmin',
});
expect(ctx.feedVersionService.bumpGlobalVersion).toHaveBeenCalled();
expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith(
'admin@example.com',
'comment_delete',
'comment',
commentId,
{ postId },
);
await expect(setup().service.removeBySuperAdmin('admin', commentId)).rejects.toThrow(
'Comment not found',
);
});
it('lists top-level comments with reply previews and viewer capabilities', async () => {
const replyId = '507f191e810c19729de860ec';
const makeComment = (id: string, author: string, content: string, deleted = false) => ({
id,
authorId: { id: author, name: author === userId ? 'Me' : 'Owner' },
content,
isDeleted: deleted,
toObject: () => ({
id,
authorId: { id: author, name: author === userId ? 'Me' : 'Owner' },
content,
isDeleted: deleted,
}),
});
const root = makeComment(commentId, userId, 'root');
const reply = makeComment(replyId, ownerId, 'reply', true);
const ctx = setup({
commentsRepository: {
findManyTop: jest.fn().mockResolvedValue([root]),
count: jest.fn().mockResolvedValue(1),
countRepliesByParentIds: jest.fn().mockResolvedValue({ [commentId]: 3 }),
countLikesByCommentIds: jest
.fn()
.mockResolvedValueOnce({ [commentId]: 5 })
.mockResolvedValueOnce({ [replyId]: 2 }),
findLikedCommentIds: jest
.fn()
.mockResolvedValueOnce([commentId])
.mockResolvedValueOnce([replyId]),
findReplyPreviewsByParentIds: jest.fn().mockResolvedValue({ [commentId]: [reply] }),
},
});
const result = await ctx.service.findByPost(userId, postId, {
page: 1,
limit: 10,
sortBy: CommentSortBy.TOP,
sortOrder: SortOrder.ASC,
});
expect(ctx.commentsRepository.findManyTop).toHaveBeenCalledWith(
expect.objectContaining({ postId: expect.any(Types.ObjectId) }),
0,
10,
1,
);
expect(result.items[0]).toMatchObject({
content: 'root',
repliesCount: 3,
likesCount: 5,
likedByMe: true,
canEdit: true,
canDelete: true,
});
expect((result.items[0] as any).repliesPreview[0]).toMatchObject({
content: 'This comment was deleted',
canEdit: false,
likesCount: 2,
likedByMe: true,
replyToUser: { id: userId, name: 'Me' },
});
});
it('lists chronological comments and returns empty enrichment cheaply', async () => {
const ctx = setup();
const result = await ctx.service.findByPost(userId, postId, {
page: 2,
limit: 5,
sortOrder: SortOrder.DESC,
});
expect(ctx.commentsRepository.findMany).toHaveBeenCalledWith(
expect.objectContaining({ postId: expect.any(Types.ObjectId) }),
5,
5,
{ isPinned: -1, createdAt: -1 },
);
expect(result.items).toEqual([]);
expect(ctx.commentsRepository.countRepliesByParentIds).not.toHaveBeenCalled();
await expect(
ctx.service.findByPost(userId, 'bad', { sortOrder: SortOrder.DESC }),
).rejects.toThrow('Invalid post id');
});
it('lists replies with parent author context using both sort modes', async () => {
const reply = {
id: '507f191e810c19729de860ec',
authorId: ownerId,
content: 'reply',
};
const parentAuthor = { id: ownerId, name: 'Parent', username: 'parent' };
const ctx = setup({
commentsRepository: {
findMany: jest.fn().mockResolvedValue([reply]),
count: jest.fn().mockResolvedValue(1),
findByIdWithAuthor: jest.fn().mockResolvedValue({ authorId: parentAuthor }),
},
});
const result = await ctx.service.findReplies(userId, commentId, {
page: 1,
limit: 20,
sortOrder: SortOrder.DESC,
});
expect(result.items[0]).toMatchObject({
content: 'reply',
replyToUser: { id: ownerId, name: 'Parent', username: 'parent' },
});
const top = setup();
await top.service.findReplies(userId, commentId, {
sortBy: CommentSortBy.TOP,
sortOrder: SortOrder.ASC,
});
expect(top.commentsRepository.findManyTop).toHaveBeenCalledWith(
expect.objectContaining({ parentCommentId: expect.any(Types.ObjectId) }),
0,
20,
1,
);
await expect(ctx.service.findReplies(userId, 'bad', { sortOrder: SortOrder.DESC })).rejects.toThrow(
'Invalid parent comment id',
);
});
it('builds escaped admin filters and paginates platform comments', async () => {
const rows = [{ id: commentId }];
const ctx = setup({
commentsRepository: {
findManyAdmin: jest.fn().mockResolvedValue(rows),
countAdmin: jest.fn().mockResolvedValue(7),
},
});
const result = await ctx.service.findPlatformComments({
page: 2,
limit: 3,
sortOrder: SortOrder.ASC,
postId,
authorId: ownerId,
q: ' a+b ',
moderationStatus: ModerationStatus.FLAGGED,
});
expect(result).toMatchObject({ items: rows, total: 7, page: 2, limit: 3 });
expect(ctx.commentsRepository.findManyAdmin).toHaveBeenCalledWith(
expect.objectContaining({
postId: expect.any(Types.ObjectId),
authorId: expect.any(Types.ObjectId),
content: { $regex: 'a\\+b', $options: 'i' },
moderationStatus: ModerationStatus.FLAGGED,
}),
3,
3,
{ createdAt: 1 },
);
});
it('updates comment moderation status and audits previous and next state', async () => {
const original = { moderationStatus: ModerationStatus.FLAGGED };
const updated = { id: commentId, moderationStatus: ModerationStatus.HIDDEN };
const ctx = setup({
commentsRepository: {
findById: jest.fn().mockResolvedValue(original),
updateModerationStatus: jest.fn().mockResolvedValue(updated),
},
});
await expect(
ctx.service.updateModerationStatusBySuperAdmin('admin', commentId, {
status: ModerationStatus.HIDDEN,
reason: ' abuse ',
}),
).resolves.toBe(updated);
expect(ctx.commentsRepository.updateModerationStatus).toHaveBeenCalledWith(commentId, {
moderationStatus: ModerationStatus.HIDDEN,
moderationReason: 'abuse',
});
expect(ctx.auditService.logSuperAdminAction).toHaveBeenCalledWith(
'admin',
'comment_moderation_status_update',
'comment',
commentId,
{
previousStatus: ModerationStatus.FLAGGED,
nextStatus: ModerationStatus.HIDDEN,
reason: 'abuse',
},
);
await expect(
setup().service.updateModerationStatusBySuperAdmin('admin', commentId, {
status: ModerationStatus.ACTIVE,
}),
).rejects.toThrow('Comment not found');
const lost = setup({
commentsRepository: { findById: jest.fn().mockResolvedValue({ moderationStatus: undefined }) },
});
await expect(
lost.service.updateModerationStatusBySuperAdmin('admin', commentId, {
status: ModerationStatus.ACTIVE,
}),
).rejects.toThrow('Comment not found');
});
it('isolates comment and mention notification delivery failures', async () => {
const mentionedId = '507f191e810c19729de860ee';
const ctx = setup({
notificationsService: {
createCommentNotification: jest.fn().mockRejectedValue('offline'),
createMentionNotification: jest.fn().mockRejectedValue(new Error('offline')),
},
usersRepository: {
findByUsernames: jest.fn().mockResolvedValue([{ id: mentionedId, username: 'friend' }]),
},
});
await expect(ctx.service.create(userId, { postId, content: '@friend hello' })).resolves.toBeDefined();
expect(ctx.notificationsService.createCommentNotification).toHaveBeenCalled();
expect(ctx.notificationsService.createMentionNotification).toHaveBeenCalled();
});
it('updates own comment and notifies newly mentioned users', async () => {
const userId = new Types.ObjectId().toString();
const postId = new Types.ObjectId().toString();
@@ -33,6 +587,7 @@ describe('CommentsService', () => {
};
const feedVersionService = {
bumpGlobalVersion: jest.fn(),
bumpUserVersion: jest.fn(),
};
const notificationsService = {
createCommentNotification: jest.fn(),
@@ -68,7 +623,7 @@ describe('CommentsService', () => {
content: updatedContent,
mentionUsernames: ['new_mention'],
});
expect(feedVersionService.bumpGlobalVersion).toHaveBeenCalled();
expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId);
expect(notificationsService.createMentionNotification).toHaveBeenCalledWith(
userId,
'mentioned-user-id',
+5 -4
View File
@@ -3,6 +3,7 @@ import { Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { escapeRegex } from '../../common/utils/regex.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { AuditService } from '../audit/audit.service';
import { NotificationsService } from '../notifications/notifications.service';
@@ -82,7 +83,7 @@ export class CommentsService {
hiddenReason: hiddenByFilter ? 'keyword_filter' : '',
});
await this.syncCommentsCount(dto.postId);
await this.feedVersionService.bumpGlobalVersion();
await this.feedVersionService.bumpUserVersion(userId);
const postAuthorId = this.extractEntityId(post.authorId);
const previewText = content.slice(0, 160);
const commentNotificationRecipients = await this.dispatchCommentNotifications(
@@ -166,7 +167,7 @@ export class CommentsService {
await this.commentsRepository.deleteById(commentId, userId);
await this.syncCommentsCount(comment.postId.toString());
await this.feedVersionService.bumpGlobalVersion();
await this.feedVersionService.bumpUserVersion(userId);
return { success: true };
}
@@ -201,7 +202,7 @@ export class CommentsService {
throw new NotFoundException('Comment not found');
}
await this.feedVersionService.bumpGlobalVersion();
await this.feedVersionService.bumpUserVersion(userId);
const previousMentionSet = new Set(previousMentionUsernames);
const nextMentionedUsers = mentionResolution.mentionedUsers.filter(
@@ -324,7 +325,7 @@ export class CommentsService {
filter.authorId = new Types.ObjectId(query.authorId);
}
if (query.q?.trim()) {
filter.content = { $regex: query.q.trim(), $options: 'i' };
filter.content = { $regex: escapeRegex(query.q.trim()), $options: 'i' };
}
if (query.moderationStatus) {
filter.moderationStatus = query.moderationStatus;
+111
View File
@@ -0,0 +1,111 @@
import { BadRequestException } from '@nestjs/common';
import { DevicesService } from './devices.service';
describe('DevicesService', () => {
const createService = () => {
const devicesRepository = {
upsert: jest.fn(),
deactivate: jest.fn(),
};
return {
service: new DevicesService(devicesRepository as any),
devicesRepository,
};
};
it('trims registration data and returns the persisted device', async () => {
const { service, devicesRepository } = createService();
const device = { id: 'device-1', isActive: true };
devicesRepository.upsert.mockResolvedValue(device);
await expect(
service.register('user-1', {
fcmToken: ' token ',
platform: 'android',
deviceId: ' device-id ',
appVersion: ' 1.2.3 ',
locale: ' ar ',
}),
).resolves.toEqual({ message: 'Device registered successfully', device });
expect(devicesRepository.upsert).toHaveBeenCalledWith('user-1', {
fcmToken: 'token',
platform: 'android',
deviceId: 'device-id',
appVersion: '1.2.3',
locale: 'ar',
});
});
it('normalizes absent optional registration data', async () => {
const { service, devicesRepository } = createService();
devicesRepository.upsert.mockResolvedValue(null);
await service.register('user-1', { fcmToken: 'token', platform: 'ios' });
expect(devicesRepository.upsert).toHaveBeenCalledWith('user-1', {
fcmToken: 'token',
platform: 'ios',
deviceId: '',
appVersion: '',
locale: '',
});
});
it('rejects an empty FCM token without touching the repository', async () => {
const { service, devicesRepository } = createService();
await expect(
service.register('user-1', { fcmToken: ' ', platform: 'web' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(devicesRepository.upsert).not.toHaveBeenCalled();
});
it('unregisters by trimmed identifiers', async () => {
const { service, devicesRepository } = createService();
const device = { id: 'device-1', isActive: false };
devicesRepository.deactivate.mockResolvedValue(device);
await expect(
service.unregister('user-1', { deviceId: ' device-id ', fcmToken: ' token ' }),
).resolves.toEqual({ message: 'Device unregistered successfully', device });
expect(devicesRepository.deactivate).toHaveBeenCalledWith('user-1', {
deviceId: 'device-id',
fcmToken: 'token',
});
});
it('supports unregistering by token alone', async () => {
const { service, devicesRepository } = createService();
devicesRepository.deactivate.mockResolvedValue(null);
await service.unregister('user-1', { fcmToken: ' token ' });
expect(devicesRepository.deactivate).toHaveBeenCalledWith('user-1', {
deviceId: undefined,
fcmToken: 'token',
});
});
it('rejects unregister without a usable identifier', async () => {
const { service, devicesRepository } = createService();
await expect(
service.unregister('user-1', { deviceId: ' ', fcmToken: ' ' }),
).rejects.toThrow('deviceId or fcmToken is required');
expect(devicesRepository.deactivate).not.toHaveBeenCalled();
});
it('propagates repository failures', async () => {
const { service, devicesRepository } = createService();
devicesRepository.upsert.mockRejectedValue(new Error('database unavailable'));
devicesRepository.deactivate.mockRejectedValue(new Error('database unavailable'));
await expect(
service.register('user-1', { fcmToken: 'token', platform: 'android' }),
).rejects.toThrow('database unavailable');
await expect(service.unregister('user-1', { deviceId: 'id' })).rejects.toThrow(
'database unavailable',
);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { ServiceUnavailableException } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { EmailService } from './email.service';
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
describe('EmailService', () => {
const setup = (overrides: Record<string, unknown> = {}) => {
const values: Record<string, unknown> = {
nodeEnv: 'test',
'email.enabled': true,
'email.fromName': 'Oudelaa',
'email.fromEmail': 'noreply@oudelaa.test',
'email.smtpHost': 'smtp.oudelaa.test',
'email.smtpPort': 587,
'email.smtpSecure': false,
'email.smtpUser': 'smtp-user',
'email.smtpPass': 'smtp-pass',
...overrides,
};
const config = { get: jest.fn((key: string) => values[key]) };
const sendMail = jest.fn().mockResolvedValue({ messageId: 'message-1' });
(nodemailer.createTransport as jest.Mock).mockReturnValue({ sendMail });
return { service: new EmailService(config as any), config, sendMail };
};
beforeEach(() => jest.clearAllMocks());
it('sends verification and reset codes and reuses the SMTP transporter', async () => {
const { service, sendMail } = setup();
await service.sendVerificationCode('member@example.com', '123456', 10);
await service.sendPasswordResetCode('member@example.com', '654321', 15);
expect(nodemailer.createTransport).toHaveBeenCalledTimes(1);
expect(nodemailer.createTransport).toHaveBeenCalledWith({
host: 'smtp.oudelaa.test',
port: 587,
secure: false,
auth: { user: 'smtp-user', pass: 'smtp-pass' },
});
expect(sendMail).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
from: 'Oudelaa <noreply@oudelaa.test>',
to: 'member@example.com',
text: expect.stringContaining('123456'),
html: expect.stringContaining('123456'),
}),
);
expect(sendMail).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ text: expect.stringContaining('654321'), html: expect.stringContaining('654321') }),
);
});
it('silently skips disabled delivery outside production', async () => {
const { service } = setup({ 'email.enabled': false, nodeEnv: 'test' });
await expect(service.sendVerificationCode('member@example.com', '123456', 10)).resolves.toBeUndefined();
expect(nodemailer.createTransport).not.toHaveBeenCalled();
});
it('rejects disabled delivery in production', async () => {
const { service } = setup({ 'email.enabled': false, nodeEnv: 'production' });
await expect(service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow(
'Email delivery is disabled',
);
});
it('requires a configured sender and complete SMTP credentials', async () => {
const missingSender = setup({ 'email.fromEmail': '' }).service;
await expect(missingSender.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow(
'Email sender is not configured',
);
const missingSmtp = setup({ 'email.smtpHost': '', 'email.smtpUser': '', 'email.smtpPass': '' }).service;
await expect(missingSmtp.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow(
'SMTP settings are not configured',
);
});
it.each([
['email.smtpPort', undefined],
['email.smtpSecure', undefined],
['email.fromName', undefined],
])('uses a safe default when %s is absent', async (key, value) => {
const { service } = setup({ [key]: value });
await service.sendVerificationCode('member@example.com', '123456', 10);
expect(nodemailer.createTransport).toHaveBeenCalled();
});
it('returns diagnostic SMTP context in development and a generic error elsewhere', async () => {
const development = setup({ nodeEnv: 'development' });
development.sendMail.mockRejectedValueOnce(Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }));
await expect(development.service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toThrow(
'ECONNREFUSED: connection refused',
);
const production = setup({ nodeEnv: 'production' });
production.sendMail.mockRejectedValueOnce(new Error('secret provider detail'));
await expect(production.service.sendVerificationCode('member@example.com', '123456', 10)).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
await expect(
Promise.reject(new ServiceUnavailableException('Failed to send verification email')),
).rejects.toThrow('Failed to send verification email');
});
});
@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsEnum, IsInt, IsMongoId, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from 'class-validator';
import { EngagementEventType } from '../engagement-event-type.enum';
export class TrackEngagementDto {
@IsMongoId()
postId!: string;
@IsEnum(EngagementEventType)
type!: EngagementEventType;
@IsOptional()
@IsInt()
@Min(0)
@Max(7_200_000)
watchTimeMs?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
progressPercent?: number;
@IsOptional()
@IsString()
@MaxLength(100)
sessionId?: string;
}
export class TrackEngagementBatchDto {
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => TrackEngagementDto)
events!: TrackEngagementDto[];
}
@@ -0,0 +1,10 @@
export enum EngagementEventType {
IMPRESSION = 'impression',
OPEN = 'open',
WATCH = 'watch',
COMPLETE = 'complete',
REWATCH = 'rewatch',
PROFILE_OPEN = 'profile_open',
SHARE = 'share',
NOT_INTERESTED = 'not_interested',
}
@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '../../common/decorators/throttle.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { TrackEngagementBatchDto, TrackEngagementDto } from './dto/track-engagement.dto';
import { EngagementService } from './engagement.service';
@ApiTags('Engagement')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('engagement')
export class EngagementController {
constructor(private readonly engagementService: EngagementService) {}
@Post('events') @HttpCode(HttpStatus.ACCEPTED) @Throttle(240, 60_000)
track(@CurrentUser() user: JwtPayload, @Body() dto: TrackEngagementDto) {
return this.engagementService.track(user.sub, dto);
}
@Post('events/batch') @HttpCode(HttpStatus.ACCEPTED) @Throttle(30, 60_000)
trackBatch(@CurrentUser() user: JwtPayload, @Body() dto: TrackEngagementBatchDto) {
return this.engagementService.trackBatch(user.sub, dto.events);
}
@Post('posts/:postId/not-interested')
hide(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.engagementService.setNotInterested(user.sub, postId, true);
}
@Delete('posts/:postId/not-interested')
restore(@CurrentUser() user: JwtPayload, @Param('postId') postId: string) {
return this.engagementService.setNotInterested(user.sub, postId, false);
}
@Get('me/summary')
summary(@CurrentUser() user: JwtPayload) { return this.engagementService.getMySummary(user.sub); }
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { PostsModule } from '../posts/posts.module';
import { EngagementController } from './engagement.controller';
import { EngagementService } from './engagement.service';
import { EngagementEvent, EngagementEventSchema } from './schemas/engagement-event.schema';
import { PostPreference, PostPreferenceSchema } from './schemas/post-preference.schema';
@Module({
imports: [PostsModule, MongooseModule.forFeature([
{ name: EngagementEvent.name, schema: EngagementEventSchema },
{ name: PostPreference.name, schema: PostPreferenceSchema },
])],
controllers: [EngagementController], providers: [EngagementService], exports: [EngagementService],
})
export class EngagementModule {}
@@ -0,0 +1,169 @@
import { EngagementEventType } from './engagement-event-type.enum';
import { EngagementService } from './engagement.service';
describe('EngagementService', () => {
const userId = '507f1f77bcf86cd799439011';
const postId = '507f191e810c19729de860ea';
const setup = () => {
const eventModel = {
create: jest.fn().mockResolvedValue({}),
aggregate: jest.fn(),
};
const preferenceModel = {
updateOne: jest.fn().mockResolvedValue({ acknowledged: true }),
deleteOne: jest.fn().mockResolvedValue({ acknowledged: true }),
find: jest.fn(),
};
const postsService = {
findById: jest.fn().mockResolvedValue({ id: postId }),
};
const feedVersionService = {
bumpUserVersion: jest.fn().mockResolvedValue(2),
};
const service = new EngagementService(
eventModel as any,
preferenceModel as any,
postsService as any,
feedVersionService as any,
);
return { service, eventModel, preferenceModel, postsService, feedVersionService };
};
it('records a normalized engagement event after checking post visibility', async () => {
const { service, eventModel, postsService } = setup();
await expect(
service.track(userId, {
postId,
type: EngagementEventType.WATCH,
watchTimeMs: 9000,
progressPercent: 72,
sessionId: ' session-1 ',
}),
).resolves.toEqual({ accepted: true, postId, type: EngagementEventType.WATCH });
expect(postsService.findById).toHaveBeenCalledWith(postId, userId);
expect(eventModel.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: expect.objectContaining({}),
postId: expect.objectContaining({}),
type: EngagementEventType.WATCH,
watchTimeMs: 9000,
progressPercent: 72,
sessionId: 'session-1',
occurredAt: expect.any(Date),
}),
);
});
it('uses safe defaults for optional event measurements', async () => {
const { service, eventModel } = setup();
await service.track(userId, { postId, type: EngagementEventType.IMPRESSION });
expect(eventModel.create).toHaveBeenCalledWith(
expect.objectContaining({ watchTimeMs: 0, progressPercent: 0, sessionId: '' }),
);
});
it('routes not-interested events through the preference workflow', async () => {
const { service, eventModel, preferenceModel, feedVersionService } = setup();
await expect(
service.track(userId, { postId, type: EngagementEventType.NOT_INTERESTED }),
).resolves.toEqual({ postId, notInterested: true });
expect(preferenceModel.updateOne).toHaveBeenCalledWith(
expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }),
{ $set: { notInterested: true } },
{ upsert: true },
);
expect(eventModel.create).toHaveBeenCalledWith(
expect.objectContaining({ type: EngagementEventType.NOT_INTERESTED }),
);
expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId);
});
it('tracks a batch and reports every accepted item', async () => {
const { service } = setup();
const events = [
{ postId, type: EngagementEventType.OPEN },
{ postId, type: EngagementEventType.COMPLETE },
];
await expect(service.trackBatch(userId, events)).resolves.toMatchObject({
accepted: 2,
items: [
{ accepted: true, type: EngagementEventType.OPEN },
{ accepted: true, type: EngagementEventType.COMPLETE },
],
});
});
it('removes a not-interested preference without requiring post visibility', async () => {
const { service, preferenceModel, postsService, eventModel, feedVersionService } = setup();
await expect(service.setNotInterested(userId, postId, false)).resolves.toEqual({
postId,
notInterested: false,
});
expect(postsService.findById).not.toHaveBeenCalled();
expect(preferenceModel.deleteOne).toHaveBeenCalledWith(
expect.objectContaining({ userId: expect.anything(), postId: expect.anything() }),
);
expect(eventModel.create).not.toHaveBeenCalled();
expect(feedVersionService.bumpUserVersion).toHaveBeenCalledWith(userId);
});
it('returns stored not-interested post ids as strings', async () => {
const { service, preferenceModel } = setup();
const exec = jest.fn().mockResolvedValue([{ postId }]);
const lean = jest.fn(() => ({ exec }));
const select = jest.fn(() => ({ lean }));
preferenceModel.find.mockReturnValue({ select });
await expect(service.getNotInterestedPostIds(userId)).resolves.toEqual([postId]);
expect(preferenceModel.find).toHaveBeenCalledWith(
expect.objectContaining({ userId: expect.anything(), notInterested: true }),
);
expect(select).toHaveBeenCalledWith({ postId: 1 });
});
it('returns ranking aggregates keyed by post id', async () => {
const { service, eventModel } = setup();
const row = { _id: { toString: () => postId }, watchMs: 5000, completions: 1, rewatches: 2, profileOpens: 1 };
const exec = jest.fn().mockResolvedValue([row]);
eventModel.aggregate.mockReturnValue({ exec });
const result = await service.getRankingSignals(userId);
expect(result).toEqual(new Map([[postId, row]]));
expect(eventModel.aggregate).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ $match: expect.objectContaining({ userId: expect.anything() }) }),
{ $sort: { watchMs: -1 } },
{ $limit: 500 },
]),
);
});
it('builds a 30-day engagement summary grouped by type', async () => {
const { service, eventModel } = setup();
eventModel.aggregate.mockReturnValue({
exec: jest.fn().mockResolvedValue([
{ _id: EngagementEventType.WATCH, count: 3, watchTimeMs: 12000 },
{ _id: EngagementEventType.SHARE, count: 1, watchTimeMs: 0 },
]),
});
await expect(service.getMySummary(userId)).resolves.toEqual({
periodDays: 30,
byType: {
watch: { count: 3, watchTimeMs: 12000 },
share: { count: 1, watchTimeMs: 0 },
},
});
});
});
@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { PostsService } from '../posts/posts.service';
import { TrackEngagementDto } from './dto/track-engagement.dto';
import { EngagementEventType } from './engagement-event-type.enum';
import { EngagementEvent, EngagementEventDocument } from './schemas/engagement-event.schema';
import { PostPreference, PostPreferenceDocument } from './schemas/post-preference.schema';
@Injectable()
export class EngagementService {
constructor(
@InjectModel(EngagementEvent.name) private readonly eventModel: Model<EngagementEventDocument>,
@InjectModel(PostPreference.name) private readonly preferenceModel: Model<PostPreferenceDocument>,
private readonly postsService: PostsService,
private readonly feedVersionService: FeedVersionService,
) {}
async track(userId: string, dto: TrackEngagementDto) {
if (dto.type === EngagementEventType.NOT_INTERESTED) {
return this.setNotInterested(userId, dto.postId, true);
}
await this.postsService.findById(dto.postId, userId);
await this.eventModel.create({
userId: new Types.ObjectId(userId),
postId: new Types.ObjectId(dto.postId),
type: dto.type,
watchTimeMs: dto.watchTimeMs ?? 0,
progressPercent: dto.progressPercent ?? 0,
sessionId: dto.sessionId?.trim() ?? '',
occurredAt: new Date(),
});
return { accepted: true, postId: dto.postId, type: dto.type };
}
async trackBatch(userId: string, events: TrackEngagementDto[]) {
const results = [];
for (const event of events) results.push(await this.track(userId, event));
return { accepted: results.length, items: results };
}
async setNotInterested(userId: string, postId: string, value: boolean) {
if (value) {
await this.postsService.findById(postId, userId);
await this.preferenceModel.updateOne(
{ userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId) },
{ $set: { notInterested: true } },
{ upsert: true },
);
await this.eventModel.create({
userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId),
type: EngagementEventType.NOT_INTERESTED, occurredAt: new Date(),
});
} else {
await this.preferenceModel.deleteOne({
userId: new Types.ObjectId(userId), postId: new Types.ObjectId(postId),
});
}
await this.feedVersionService.bumpUserVersion(userId);
return { postId, notInterested: value };
}
async getNotInterestedPostIds(userId: string): Promise<string[]> {
const rows = await this.preferenceModel
.find({ userId: new Types.ObjectId(userId), notInterested: true })
.select({ postId: 1 }).lean().exec();
return rows.map((row) => row.postId.toString());
}
async getRankingSignals(userId: string) {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const rows = await this.eventModel.aggregate<{
_id: Types.ObjectId; watchMs: number; completions: number; rewatches: number; profileOpens: number;
}>([
{ $match: { userId: new Types.ObjectId(userId), occurredAt: { $gte: since } } },
{ $group: {
_id: '$postId', watchMs: { $sum: '$watchTimeMs' },
completions: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.COMPLETE] }, 1, 0] } },
rewatches: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.REWATCH] }, 1, 0] } },
profileOpens: { $sum: { $cond: [{ $eq: ['$type', EngagementEventType.PROFILE_OPEN] }, 1, 0] } },
} },
{ $sort: { watchMs: -1 } }, { $limit: 500 },
]).exec();
return new Map(rows.map((row) => [row._id.toString(), row]));
}
async getMySummary(userId: string) {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const byType = await this.eventModel.aggregate<{ _id: EngagementEventType; count: number; watchTimeMs: number }>([
{ $match: { userId: new Types.ObjectId(userId), occurredAt: { $gte: since } } },
{ $group: { _id: '$type', count: { $sum: 1 }, watchTimeMs: { $sum: '$watchTimeMs' } } },
]).exec();
return { periodDays: 30, byType: Object.fromEntries(byType.map((row) => [row._id, { count: row.count, watchTimeMs: row.watchTimeMs }])) };
}
}
@@ -0,0 +1,35 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { EngagementEventType } from '../engagement-event-type.enum';
export type EngagementEventDocument = HydratedDocument<EngagementEvent>;
@Schema({ timestamps: true, versionKey: false })
export class EngagementEvent {
@Prop({ type: Types.ObjectId, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: true, index: true })
postId!: Types.ObjectId;
@Prop({ enum: EngagementEventType, required: true, index: true })
type!: EngagementEventType;
@Prop({ min: 0, max: 7_200_000, default: 0 })
watchTimeMs!: number;
@Prop({ min: 0, max: 100, default: 0 })
progressPercent!: number;
@Prop({ default: '', maxlength: 100 })
sessionId!: string;
@Prop({ type: Date, default: Date.now })
occurredAt!: Date;
}
export const EngagementEventSchema = SchemaFactory.createForClass(EngagementEvent);
EngagementEventSchema.index({ userId: 1, occurredAt: -1 });
EngagementEventSchema.index({ userId: 1, postId: 1, occurredAt: -1 });
EngagementEventSchema.index({ postId: 1, type: 1, occurredAt: -1 });
EngagementEventSchema.index({ occurredAt: 1 }, { expireAfterSeconds: 180 * 24 * 60 * 60 });
@@ -0,0 +1,19 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type PostPreferenceDocument = HydratedDocument<PostPreference>;
@Schema({ timestamps: true, versionKey: false })
export class PostPreference {
@Prop({ type: Types.ObjectId, required: true, index: true })
userId!: Types.ObjectId;
@Prop({ type: Types.ObjectId, required: true, index: true })
postId!: Types.ObjectId;
@Prop({ default: true })
notInterested!: boolean;
}
export const PostPreferenceSchema = SchemaFactory.createForClass(PostPreference);
PostPreferenceSchema.index({ userId: 1, postId: 1 }, { unique: true });
+46 -4
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model, Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { Follow, FollowDocument } from '../follows/schemas/follow.schema';
import { Post, PostDocument } from '../posts/schemas/post.schema';
@@ -29,6 +30,7 @@ export class FeedRepository {
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
};
return this.postModel
@@ -36,21 +38,56 @@ export class FeedRepository {
.populate({
path: 'authorId',
select:
'name username stageName avatar isVerified isDisabled location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat',
'name username stageName avatar isVerified isDisabled isPrivate location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat followersCount',
})
.sort({ createdAt: -1 })
.limit(limit)
.exec();
}
async findPopularCandidatePosts(
filter: FilterQuery<PostDocument>,
limit: number,
): Promise<PostDocument[]> {
return this.postModel
.find({
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
})
.populate({
path: 'authorId',
select:
'name username stageName avatar isVerified isDisabled isPrivate location latitude longitude musicGenres musicRoles favoriteInstruments favoriteMaqamat followersCount',
})
.sort({
savesCount: -1,
shareCount: -1,
commentsCount: -1,
likesCount: -1,
createdAt: -1,
})
.limit(limit)
.exec();
}
async findTrendingPublicPosts(
filter: FilterQuery<PostDocument>,
skip: number,
limit: number,
): Promise<PostDocument[]> {
return this.postModel
.find({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
.populate({ path: 'authorId', select: 'name username stageName avatar isVerified isDisabled' })
.find({
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
})
.populate({
path: 'authorId',
select: 'name username stageName avatar isVerified isDisabled isPrivate',
})
.sort({
shareCount: -1,
likesCount: -1,
@@ -67,7 +104,12 @@ export class FeedRepository {
async count(filter: FilterQuery<PostDocument>): Promise<number> {
return this.postModel
.countDocuments({ ...filter, isDeleted: { $ne: true }, isArchived: { $ne: true } })
.countDocuments({
...filter,
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: ModerationStatus.HIDDEN },
})
.exec();
}
}
+434 -21
View File
@@ -16,7 +16,9 @@ const makePost = (input: Partial<Record<string, any>> = {}) => {
_id: new Types.ObjectId(authorId),
id: authorId,
username: `author_${authorId.slice(-4)}`,
isVerified: false,
isVerified: input.authorIsVerified ?? false,
isDisabled: input.authorIsDisabled ?? false,
followersCount: input.authorFollowersCount ?? 0,
},
content: input.content ?? `${postType} content`,
postType,
@@ -30,6 +32,7 @@ const makePost = (input: Partial<Record<string, any>> = {}) => {
shareCount: input.shareCount ?? 0,
viewCount: input.viewCount ?? 0,
playCount: input.playCount ?? 0,
hashtags: input.hashtags ?? [],
commentsDisabled: input.commentsDisabled ?? false,
createdAt: input.createdAt ?? new Date(),
};
@@ -47,6 +50,7 @@ const createService = () => {
const feedRepository = {
findFollowingIds: jest.fn().mockResolvedValue([]),
findCandidatePosts: jest.fn().mockResolvedValue([]),
findPopularCandidatePosts: jest.fn().mockResolvedValue([]),
findTrendingPublicPosts: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
};
@@ -58,13 +62,16 @@ const createService = () => {
favoriteMaqamat: [],
musicRoles: [],
}),
findPrivateUserIds: jest.fn().mockResolvedValue([]),
};
const cacheService = {
get: jest.fn(),
set: jest.fn(),
acquireFillLock: jest.fn().mockResolvedValue({ acquired: true, release: jest.fn().mockResolvedValue(true) }),
waitForValue: jest.fn(),
};
const configService = {
get: jest.fn((key: string) => {
get: jest.fn((key: string): unknown => {
if (key === 'feedCache.enabled') {
return false;
}
@@ -77,34 +84,59 @@ const createService = () => {
const savesRepository = {
findSavedPostIds: jest.fn().mockResolvedValue([]),
};
const emptyCursor = {
sort: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
project: jest.fn().mockReturnThis(),
toArray: jest.fn().mockResolvedValue([]),
};
const connection = {
collection: jest.fn(() => ({
find: jest.fn(() => ({
project: jest.fn().mockReturnThis(),
toArray: jest.fn().mockResolvedValue([]),
})),
find: jest.fn(() => emptyCursor),
aggregate: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([]) })),
})),
};
const feedVersionService = {
getGlobalVersion: jest.fn().mockResolvedValue(1),
getUserVersion: jest.fn().mockResolvedValue(1),
bumpGlobalVersion: jest.fn(),
};
const followsService = { getSuggestions: jest.fn().mockResolvedValue({ items: [] }) };
const marketplaceService = {
getPublicListings: jest.fn().mockResolvedValue({ items: [] }),
getPublicInstruments: jest.fn().mockResolvedValue({ items: [] }),
getPublicRepairShops: jest.fn().mockResolvedValue({ items: [] }),
};
const blocksService = { getInvisibleUserIds: jest.fn().mockResolvedValue([]) };
const service = new FeedService(
feedRepository as any,
usersRepository as any,
cacheService as any,
{ getGlobalVersion: jest.fn(), bumpGlobalVersion: jest.fn() } as any,
feedVersionService as any,
configService as any,
likesRepository as any,
savesRepository as any,
{ getSuggestions: jest.fn() } as any,
{
getPublicListings: jest.fn(),
getPublicInstruments: jest.fn(),
getPublicRepairShops: jest.fn(),
} as any,
{ getInvisibleUserIds: jest.fn().mockResolvedValue([]) } as any,
followsService as any,
marketplaceService as any,
blocksService as any,
connection as any,
);
return { service, feedRepository, likesRepository, savesRepository };
return {
service,
feedRepository,
usersRepository,
cacheService,
configService,
feedVersionService,
likesRepository,
savesRepository,
followsService,
marketplaceService,
blocksService,
connection,
};
};
describe('FeedService reels feed', () => {
@@ -152,12 +184,9 @@ describe('FeedService reels feed', () => {
limit: 10,
})) as any;
expect(feedRepository.findCandidatePosts).toHaveBeenCalledWith(
expect.objectContaining({
$and: expect.arrayContaining([expect.objectContaining({ postType: PostType.VIDEO })]),
}),
expect.any(Number),
);
const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0];
expect(JSON.stringify(candidateFilter)).toContain(`"postType":"${PostType.VIDEO}"`);
expect(JSON.stringify(candidateFilter)).toContain('"createdAt"');
expect(result.items).toHaveLength(1);
expect(result.items[0]).toEqual(
expect.objectContaining({
@@ -228,4 +257,388 @@ describe('FeedService reels feed', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0]).toEqual(expect.objectContaining({ postType: 'reel' }));
});
it('never returns posts from disabled authors', async () => {
const { service, feedRepository } = createService();
const visible = makePost({ authorIsDisabled: false });
const disabled = makePost({ authorIsDisabled: true, likesCount: 100_000 });
feedRepository.findCandidatePosts.mockResolvedValue([disabled, visible]);
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
limit: 10,
})) as any;
expect(result.items.map((item: any) => item.id)).toEqual([visible.id]);
});
it('uses recent viewer interactions to personalize ranking', async () => {
const { service, feedRepository } = createService();
const preferredAuthorId = new Types.ObjectId().toString();
const otherAuthorId = new Types.ObjectId().toString();
const preferred = makePost({ authorId: preferredAuthorId });
const other = makePost({ authorId: otherAuthorId });
feedRepository.findCandidatePosts.mockResolvedValue([other, preferred]);
jest.spyOn(service as any, 'getViewerRankingProfile').mockResolvedValue({
authorAffinity: { [preferredAuthorId]: 20 },
postTypeAffinity: {},
hashtagAffinity: {},
interactedPostIds: [],
});
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
limit: 10,
})) as any;
expect(result.items[0].id).toBe(preferred.id);
});
it('diversifies consecutive authors even when one author has several strong posts', async () => {
const { service, feedRepository } = createService();
const repeatedAuthorId = new Types.ObjectId().toString();
const alternateAuthorId = new Types.ObjectId().toString();
const posts = [
makePost({ authorId: repeatedAuthorId, likesCount: 20 }),
makePost({ authorId: repeatedAuthorId, likesCount: 18 }),
makePost({ authorId: alternateAuthorId, likesCount: 10 }),
];
feedRepository.findCandidatePosts.mockResolvedValue(posts);
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
limit: 10,
})) as any;
const firstAuthor = result.items[0].authorId._id.toString();
const secondAuthor = result.items[1].authorId._id.toString();
expect(firstAuthor).not.toBe(secondAuthor);
});
it('hard-filters posts already reported by the viewer', async () => {
const { service, feedRepository } = createService();
const reportedId = new Types.ObjectId();
feedRepository.findCandidatePosts.mockResolvedValue([]);
jest.spyOn(service as any, 'findReportedPostIds').mockResolvedValue([reportedId]);
await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
followingOnly: false,
limit: 10,
});
const candidateFilter = feedRepository.findCandidatePosts.mock.calls[0][0];
expect(JSON.stringify(candidateFilter)).toContain(reportedId.toString());
});
it('encodes a stable ranking timestamp in the opaque cursor', async () => {
const { service, feedRepository } = createService();
feedRepository.findCandidatePosts.mockResolvedValue([
makePost({ createdAt: new Date('2026-07-06T10:02:00.000Z') }),
makePost({ createdAt: new Date('2026-07-06T10:01:00.000Z') }),
]);
const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
limit: 1,
})) as any;
const payload = JSON.parse(Buffer.from(firstPage.nextCursor, 'base64url').toString('utf8'));
expect(payload).toMatchObject({ version: 1, offset: 1 });
expect(new Date(payload.rankedAt).toString()).not.toBe('Invalid Date');
expect(decodeOffsetCursor(firstPage.nextCursor)).toBe(1);
});
it('rejects a feed request when the current user no longer exists', async () => {
const { service, usersRepository, feedRepository } = createService();
usersRepository.findById.mockResolvedValue(null);
await expect(
service.getMyFeed(new Types.ObjectId().toString(), { includeSuggestions: false }),
).rejects.toThrow('Current user not found');
expect(feedRepository.findCandidatePosts).not.toHaveBeenCalled();
});
it('falls back from an empty following feed to public discovery for default requests', async () => {
const { service, feedRepository } = createService();
const discovered = makePost();
feedRepository.findCandidatePosts
.mockResolvedValueOnce([])
.mockResolvedValueOnce([discovered]);
const result = (await service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
limit: 10,
})) as any;
expect(feedRepository.findCandidatePosts).toHaveBeenCalledTimes(2);
expect(result.items.map((item: any) => item.id)).toEqual([discovered.id]);
const explicit = createService();
explicit.feedRepository.findCandidatePosts.mockResolvedValue([]);
await explicit.service.getMyFeed(new Types.ObjectId().toString(), {
followingOnly: true,
includeSuggestions: false,
});
expect(explicit.feedRepository.findCandidatePosts).toHaveBeenCalledTimes(1);
});
it('returns cached home feeds and coalesces concurrent cache fills', async () => {
const cached = { items: [{ id: 'cached' }], nextCursor: null };
const first = createService();
first.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
first.cacheService.get.mockResolvedValue(cached);
await expect(first.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
expect(first.feedRepository.findCandidatePosts).not.toHaveBeenCalled();
const coalesced = createService();
coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
coalesced.cacheService.get.mockResolvedValue(null);
coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false });
coalesced.cacheService.waitForValue.mockResolvedValue(cached);
await expect(coalesced.service.getMyFeed(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
expect(coalesced.cacheService.waitForValue).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), 2000);
});
it('stores freshly ranked home feeds and releases the cache fill lock', async () => {
const ctx = createService();
const release = jest.fn().mockResolvedValue(true);
ctx.configService.get.mockImplementation((key: string) => {
if (key === 'feedCache.enabled') return true;
if (key === 'feedCache.userFeedTtlSeconds') return 45;
return false;
});
ctx.cacheService.get.mockResolvedValue(null);
ctx.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release });
ctx.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);
const result = await ctx.service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: false,
});
expect(ctx.cacheService.set).toHaveBeenCalledWith(expect.stringContaining('feed:me:'), result, 45);
expect(release).toHaveBeenCalled();
});
it('mixes creator and marketplace cards into only the first requested home page', async () => {
const ctx = createService();
ctx.feedRepository.findCandidatePosts.mockResolvedValue([
makePost(),
makePost(),
makePost(),
makePost(),
]);
ctx.followsService.getSuggestions.mockResolvedValue({ items: [{ user: { id: 'creator' } }] });
ctx.marketplaceService.getPublicListings.mockResolvedValue({ items: [{ id: 'listing' }] });
ctx.marketplaceService.getPublicInstruments.mockResolvedValue({ items: [{ id: 'instrument' }] });
ctx.marketplaceService.getPublicRepairShops.mockResolvedValue({ items: [{ id: 'shop' }] });
const result = (await ctx.service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: true,
suggestionInterval: 2,
limit: 10,
})) as any;
expect(result.items.map((item: any) => item.feedItemType)).toEqual([
'post',
'post',
'suggested_users',
'post',
'post',
'featured_marketplace',
]);
expect(result.items[2].items[0]).toMatchObject({ following: false });
expect(result.items[5]).toMatchObject({
listings: [{ id: 'listing' }],
instruments: [{ id: 'instrument' }],
repairShops: [{ id: 'shop' }],
});
const later = createService();
later.feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);
await later.service.getMyFeed(new Types.ObjectId().toString(), {
includeSuggestions: true,
page: 2,
});
expect(later.followsService.getSuggestions).not.toHaveBeenCalled();
});
it('builds a cursor-based trending feed with visibility, block and reel filters', async () => {
const ctx = createService();
const invisibleId = new Types.ObjectId().toString();
const visible = makePost({
postType: PostType.VIDEO,
videoUrl: 'https://cdn/reel.mp4',
});
const disabled = makePost({ postType: PostType.VIDEO, authorIsDisabled: true });
ctx.blocksService.getInvisibleUserIds.mockResolvedValue([invisibleId]);
ctx.feedRepository.findFollowingIds.mockResolvedValue([new Types.ObjectId().toString()]);
ctx.feedRepository.findTrendingPublicPosts.mockResolvedValue([visible, disabled]);
ctx.feedRepository.count.mockResolvedValue(3);
ctx.likesRepository.findLikedPostIds.mockResolvedValue([visible.id]);
const result = (await ctx.service.getTrending(new Types.ObjectId().toString(), {
preferredPostType: 'reel',
page: 1,
limit: 2,
})) as any;
expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith(
{
visibility: PostVisibility.PUBLIC,
authorId: { $nin: [new Types.ObjectId(invisibleId)] },
postType: PostType.VIDEO,
},
0,
2,
);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ postType: 'reel', likedByMe: true });
expect(result.nextCursor).toBeTruthy();
});
it('excludes private authors from trending and explore queries', async () => {
const ctx = createService();
const privateAuthorId = new Types.ObjectId().toString();
ctx.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]);
await ctx.service.getTrending(new Types.ObjectId().toString(), { limit: 10 });
expect(ctx.feedRepository.findTrendingPublicPosts).toHaveBeenCalledWith(
expect.objectContaining({
authorId: { $nin: [new Types.ObjectId(privateAuthorId)] },
}),
0,
10,
);
});
it('allows approved private follows in home feed but excludes pending private authors', async () => {
const privateAuthorId = new Types.ObjectId().toString();
const viewerId = new Types.ObjectId().toString();
const approved = createService();
approved.feedRepository.findFollowingIds.mockResolvedValue([privateAuthorId]);
approved.usersRepository.findPrivateUserIds.mockResolvedValue([]);
await approved.service.getMyFeed(viewerId, {
followingOnly: false,
includeSuggestions: false,
});
const approvedFilter = approved.feedRepository.findCandidatePosts.mock.calls[0][0];
expect(JSON.stringify(approvedFilter)).toContain(privateAuthorId);
const pending = createService();
pending.feedRepository.findFollowingIds.mockResolvedValue([]);
pending.usersRepository.findPrivateUserIds.mockResolvedValue([privateAuthorId]);
await pending.service.getMyFeed(viewerId, {
followingOnly: false,
includeSuggestions: false,
});
const pendingFilter = pending.feedRepository.findCandidatePosts.mock.calls[0][0];
expect(JSON.stringify(pendingFilter)).toContain(privateAuthorId);
expect(JSON.stringify(pendingFilter)).toContain('$nin');
});
it('makes explore a public trending request and disables suggestion cards', async () => {
const { service } = createService();
const trending = jest.spyOn(service, 'getTrending').mockResolvedValue({ items: [] } as any);
await service.getExplore(new Types.ObjectId().toString(), {
followingOnly: true,
includeSuggestions: true,
});
expect(trending).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ followingOnly: false, includeSuggestions: false }),
);
});
it('returns cached and coalesced trending feeds and caches fresh results', async () => {
const cached = { items: [], nextCursor: null };
const hit = createService();
hit.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
hit.cacheService.get.mockResolvedValue(cached);
await expect(hit.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
const coalesced = createService();
coalesced.configService.get.mockImplementation((key: string) => key === 'feedCache.enabled');
coalesced.cacheService.get.mockResolvedValue(null);
coalesced.cacheService.acquireFillLock.mockResolvedValue({ acquired: false });
coalesced.cacheService.waitForValue.mockResolvedValue(cached);
await expect(coalesced.service.getTrending(new Types.ObjectId().toString(), {})).resolves.toBe(cached);
const fresh = createService();
const release = jest.fn().mockResolvedValue(true);
fresh.configService.get.mockImplementation((key: string) => {
if (key === 'feedCache.enabled') return true;
if (key === 'feedCache.trendingTtlSeconds') return 12;
return false;
});
fresh.cacheService.get.mockResolvedValue(null);
fresh.cacheService.acquireFillLock.mockResolvedValue({ acquired: true, release });
await fresh.service.getTrending(new Types.ObjectId().toString(), {});
expect(fresh.cacheService.set).toHaveBeenCalledWith(
expect.stringContaining('feed:trending:'),
expect.any(Object),
12,
);
expect(release).toHaveBeenCalled();
});
it('weights watch quality, proximity and preferences in ranking', () => {
const { service } = createService();
const viewerId = new Types.ObjectId().toString();
const authorId = new Types.ObjectId().toString();
const base = {
currentUser: {
latitude: 24.7136,
longitude: 46.6753,
musicGenres: ['Oud'],
favoriteInstruments: [],
favoriteMaqamat: [],
musicRoles: [],
},
currentUserId: viewerId,
followingIds: [authorId],
post: makePost({ authorId, hashtags: ['oud'], postType: PostType.VIDEO }),
preferredPostType: 'reel',
radiusKm: 30,
rankedAt: new Date(),
};
(base.post as any).authorId.latitude = 24.72;
(base.post as any).authorId.longitude = 46.68;
const neutral = (service as any).scorePost({
...base,
rankingProfile: {
authorAffinity: {},
postTypeAffinity: {},
hashtagAffinity: {},
interactedPostIds: [],
engagementSignals: {},
},
});
const personalized = (service as any).scorePost({
...base,
rankingProfile: {
authorAffinity: { [authorId]: 10 },
postTypeAffinity: { video: 8 },
hashtagAffinity: { oud: 6 },
interactedPostIds: [],
engagementSignals: {
[base.post.id]: { watchMs: 60_000, completions: 2, rewatches: 1, profileOpens: 1 },
},
},
});
expect(personalized).toBeGreaterThan(neutral);
expect((service as any).computeDistanceKm(null, 0, 0, 0)).toBeNull();
expect((service as any).computeDistanceKm(24.7136, 46.6753, 24.7136, 46.6753)).toBe(0);
});
it('falls back safely for malformed opaque feed cursors', async () => {
const { service, feedRepository } = createService();
feedRepository.findCandidatePosts.mockResolvedValue([makePost()]);
await expect(
service.getMyFeed(new Types.ObjectId().toString(), {
cursor: 'not-a-valid-cursor',
includeSuggestions: false,
}),
).resolves.toBeDefined();
expect(feedRepository.findCandidatePosts).toHaveBeenCalled();
});
});
+528 -68
View File
@@ -59,6 +59,19 @@ type FeedCardItem =
repairShops: Array<Record<string, unknown>>;
};
type ViewerRankingProfile = {
authorAffinity: Record<string, number>;
postTypeAffinity: Record<string, number>;
hashtagAffinity: Record<string, number>;
interactedPostIds: string[];
engagementSignals: Record<string, { watchMs: number; completions: number; rewatches: number; profileOpens: number }>;
};
type RankedPost = {
post: Record<string, any> & { toObject(): unknown };
score: number;
};
@Injectable()
export class FeedService {
private readonly logger = new Logger(FeedService.name);
@@ -82,11 +95,17 @@ export class FeedService {
const followingOnly = query.followingOnly ?? true;
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
const [globalVersion, userVersion] = cacheEnabled
? await Promise.all([
this.feedVersionService.getGlobalVersion(),
this.feedVersionService.getUserVersion(currentUserId),
])
: [0, 0];
const includeSuggestions = this.shouldIncludeSuggestions(query);
const cacheKey = this.buildCacheKey('me', {
currentUserId,
globalVersion,
userVersion,
page: query.page ?? 1,
limit: query.limit ?? 20,
cursor: query.cursor ?? '',
@@ -96,6 +115,7 @@ export class FeedService {
includeSuggestions,
suggestionInterval: query.suggestionInterval ?? 4,
});
let cacheFillLock: Awaited<ReturnType<AppCacheService['acquireFillLock']>> | null = null;
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
@@ -107,50 +127,85 @@ export class FeedService {
});
return cached;
}
}
const currentUser = await this.usersRepository.findById(currentUserId);
if (!currentUser) {
throw new NotFoundException('Current user not found');
cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10);
if (!cacheFillLock.acquired) {
const coalesced = await this.cacheService.waitForValue<Record<string, unknown>>(
cacheKey,
2_000,
);
if (coalesced) {
this.logFeedTiming(timing, {
cacheHit: true,
cacheCoalesced: true,
itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined,
responseBytes: this.measureResponseBytes(coalesced),
});
return coalesced;
}
}
}
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const cursorState = this.decodeFeedCursor(query.cursor);
const cursorOffset = cursorState.offset;
const rankedAt = cursorState.rankedAt;
const page = query.page ?? 1;
const radiusKm = query.radiusKm ?? 30;
const skip = cursorOffset ?? (page - 1) * limit;
const requestedPostType = this.resolveRequestedPostType(query.preferredPostType);
const [followingIds, invisibleUserIds] = await Promise.all([
const [currentUser, followingIds, invisibleUserIds, reportedPostIds, notInterestedPostIds, rankingProfile] = await Promise.all([
this.usersRepository.findById(currentUserId),
this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId),
this.findReportedPostIds(currentUserId),
this.findNotInterestedPostIds(currentUserId),
this.getViewerRankingProfile(currentUserId),
]);
if (!currentUser) {
throw new NotFoundException('Current user not found');
}
const unauthorizedPrivateAuthorIds = await this.usersRepository.findPrivateUserIds([
currentUserId,
...followingIds,
]);
const hiddenAuthorIds = Array.from(
new Set([...invisibleUserIds, ...unauthorizedPrivateAuthorIds]),
);
const relationLookupMs = this.markTiming(timing);
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds);
let filter = this.buildVisiblePostsFilter(
currentUserId,
followingIds,
followingOnly,
hiddenAuthorIds,
);
if (requestedPostType) {
filter = { $and: [filter, { postType: requestedPostType }] };
}
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]);
const candidateLimit = Math.min(1000, Math.max(limit * 20, 400));
let candidates = await this.findRankableCandidates(filter, candidateLimit);
const firstCandidateLookupMs = this.markTiming(timing);
// Keep the default home feed focused on followed accounts, but avoid an empty screen
// for new users or when followed accounts have not posted yet.
if (
candidates.length === 0 &&
typeof query.followingOnly === 'undefined' &&
followingOnly
) {
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds);
if (candidates.length === 0 && typeof query.followingOnly === 'undefined' && followingOnly) {
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, hiddenAuthorIds);
if (requestedPostType) {
filter = { $and: [filter, { postType: requestedPostType }] };
}
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]);
candidates = await this.findRankableCandidates(filter, candidateLimit);
}
const fallbackCandidateLookupMs = this.markTiming(timing);
const activeAuthorCandidates = candidates.filter((post) => {
const author = post.authorId as unknown as { isDisabled?: boolean } | null;
return !!author && author.isDisabled !== true;
});
const filteredCandidates = requestedPostType
? candidates.filter((post) => post.postType === requestedPostType)
: candidates;
? activeAuthorCandidates.filter((post) => post.postType === requestedPostType)
: activeAuthorCandidates;
const scored = filteredCandidates
.map((post) => ({
@@ -162,6 +217,8 @@ export class FeedService {
post,
preferredPostType: query.preferredPostType,
radiusKm,
rankingProfile,
rankedAt,
}),
}))
.sort(
@@ -172,12 +229,17 @@ export class FeedService {
);
const scoringMs = this.markTiming(timing);
const total = scored.length;
const pagedPosts = scored.slice(skip, skip + limit).map((entry) => ({
const diversified = this.diversifyRankedPosts(scored);
const total = diversified.length;
const pagedPosts = diversified.slice(skip, skip + limit).map((entry) => ({
...(entry.post.toObject() as unknown as Record<string, unknown>),
feedScore: Number(entry.score.toFixed(3)),
}));
const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds);
const decoratedPosts = await this.decoratePostsForViewer(
currentUserId,
pagedPosts,
followingIds,
);
const normalizedPosts = this.normalizePreferredPostTypeForResponse(
decoratedPosts,
query.preferredPostType,
@@ -188,7 +250,7 @@ export class FeedService {
: normalizedPosts;
const cardsMs = this.markTiming(timing);
const nextOffset = skip + pagedPosts.length;
const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null;
const nextCursor = nextOffset < total ? this.encodeFeedCursor(nextOffset, rankedAt) : null;
const result = buildPaginatedResponse(items, {
page,
@@ -199,6 +261,8 @@ export class FeedService {
nextCursor,
mode: 'cursor',
});
result.pagination.hasNextPage = nextCursor !== null;
result.pagination.nextPage = nextCursor !== null ? page + 1 : null;
if (cacheEnabled) {
await this.cacheService.set(
@@ -207,6 +271,9 @@ export class FeedService {
this.configService.get<number>('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15,
);
}
if (cacheFillLock?.acquired) {
await cacheFillLock.release().catch(() => false);
}
this.logFeedTiming(timing, {
cacheHit: false,
@@ -235,6 +302,273 @@ export class FeedService {
return result;
}
private async findRankableCandidates(
filter: Record<string, unknown>,
limit: number,
): Promise<Array<Record<string, any> & { toObject(): unknown }>> {
const recentLimit = Math.ceil(limit * 0.65);
const popularLimit = limit - recentLimit;
const [recent, popular] = await Promise.all([
this.feedRepository.findCandidatePosts(filter, recentLimit),
this.feedRepository.findPopularCandidatePosts(filter, popularLimit),
]);
const unique = new Map<string, Record<string, any> & { toObject(): unknown }>();
for (const post of [...recent, ...popular]) {
const id = this.extractEntityId((post as any)._id ?? (post as any).id);
if (id && !unique.has(id)) {
unique.set(id, post as unknown as Record<string, any> & { toObject(): unknown });
}
}
return Array.from(unique.values());
}
private addRankingWindowFilter(
filter: Record<string, unknown>,
rankedAt: Date,
reportedPostIds: Types.ObjectId[],
): Record<string, unknown> {
const clauses: Record<string, unknown>[] = [filter, { createdAt: { $lte: rankedAt } }];
if (reportedPostIds.length) {
clauses.push({ _id: { $nin: reportedPostIds } });
}
return { $and: clauses };
}
private async findReportedPostIds(currentUserId: string): Promise<Types.ObjectId[]> {
if (!Types.ObjectId.isValid(currentUserId)) {
return [];
}
const rows = await this.connection
.collection('reports')
.find({
reporterId: new Types.ObjectId(currentUserId),
targetType: 'post',
status: { $ne: 'rejected' },
})
.project({ targetId: 1 })
.toArray();
return rows
.map((row) => row.targetId)
.filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
}
private async findNotInterestedPostIds(currentUserId: string): Promise<Types.ObjectId[]> {
const rows = await this.connection
.collection<{ postId?: Types.ObjectId }>('postpreferences')
.find({ userId: new Types.ObjectId(currentUserId), notInterested: true })
.project({ postId: 1 })
.toArray();
return rows.map((row) => row.postId).filter((id): id is Types.ObjectId => id instanceof Types.ObjectId);
}
private async getViewerRankingProfile(currentUserId: string): Promise<ViewerRankingProfile> {
const empty: ViewerRankingProfile = {
authorAffinity: {},
postTypeAffinity: {},
hashtagAffinity: {},
interactedPostIds: [],
engagementSignals: {},
};
if (!Types.ObjectId.isValid(currentUserId)) {
return empty;
}
const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const cacheKey = `feed:ranking-profile:${currentUserId}`;
if (cacheEnabled) {
const cached = await this.cacheService.get<ViewerRankingProfile>(cacheKey);
if (cached) {
return cached;
}
}
const userId = new Types.ObjectId(currentUserId);
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const [likes, saves, shares, comments, engagementRows] = await Promise.all([
this.connection
.collection('likes')
.find({ userId, targetType: 'post', createdAt: { $gte: cutoff } })
.sort({ createdAt: -1 })
.limit(250)
.project({ targetId: 1 })
.toArray(),
this.connection
.collection('saves')
.find({ userId, createdAt: { $gte: cutoff } })
.sort({ createdAt: -1 })
.limit(250)
.project({ postId: 1 })
.toArray(),
this.connection
.collection('postshares')
.find({ userId, isDeleted: false, createdAt: { $gte: cutoff } })
.sort({ createdAt: -1 })
.limit(250)
.project({ postId: 1 })
.toArray(),
this.connection
.collection('comments')
.find({ authorId: userId, isDeleted: { $ne: true }, createdAt: { $gte: cutoff } })
.sort({ createdAt: -1 })
.limit(250)
.project({ postId: 1 })
.toArray(),
this.connection.collection('engagementevents').aggregate<{
_id: Types.ObjectId; watchMs: number; completions: number; rewatches: number; profileOpens: number;
}>([
{ $match: { userId, occurredAt: { $gte: cutoff } } },
{ $group: {
_id: '$postId', watchMs: { $sum: '$watchTimeMs' },
completions: { $sum: { $cond: [{ $eq: ['$type', 'complete'] }, 1, 0] } },
rewatches: { $sum: { $cond: [{ $eq: ['$type', 'rewatch'] }, 1, 0] } },
profileOpens: { $sum: { $cond: [{ $eq: ['$type', 'profile_open'] }, 1, 0] } },
} }, { $limit: 500 },
]).toArray(),
]);
const weights = new Map<string, number>();
const addWeight = (value: unknown, weight: number) => {
if (value instanceof Types.ObjectId) {
const id = value.toString();
weights.set(id, (weights.get(id) ?? 0) + weight);
}
};
likes.forEach((row) => addWeight(row.targetId, 1.5));
saves.forEach((row) => addWeight(row.postId, 4));
shares.forEach((row) => addWeight(row.postId, 5));
comments.forEach((row) => addWeight(row.postId, 3));
engagementRows.forEach((row) =>
addWeight(row._id, Math.min(12, row.watchMs / 60_000) + row.completions * 4 + row.rewatches * 5 + row.profileOpens * 2),
);
if (!weights.size) {
return empty;
}
const postIds = Array.from(weights.keys()).map((id) => new Types.ObjectId(id));
const posts = await this.connection
.collection('posts')
.find({ _id: { $in: postIds }, isDeleted: { $ne: true } })
.project({ authorId: 1, postType: 1, hashtags: 1 })
.toArray();
const profile: ViewerRankingProfile = {
authorAffinity: {},
postTypeAffinity: {},
hashtagAffinity: {},
interactedPostIds: Array.from(weights.keys()),
engagementSignals: Object.fromEntries(engagementRows.map((row) => [row._id.toString(), row])),
};
for (const post of posts) {
const postWeight = weights.get(post._id.toString()) ?? 0;
const authorId = post.authorId?.toString?.() ?? '';
const postType = typeof post.postType === 'string' ? post.postType : '';
if (authorId) {
profile.authorAffinity[authorId] = (profile.authorAffinity[authorId] ?? 0) + postWeight;
}
if (postType) {
profile.postTypeAffinity[postType] = (profile.postTypeAffinity[postType] ?? 0) + postWeight;
}
for (const hashtag of Array.isArray(post.hashtags) ? post.hashtags : []) {
const normalized = String(hashtag).trim().toLowerCase();
if (normalized) {
profile.hashtagAffinity[normalized] =
(profile.hashtagAffinity[normalized] ?? 0) + postWeight;
}
}
}
if (cacheEnabled) {
await this.cacheService.set(
cacheKey,
profile,
this.configService.get<number>('feedCache.rankingProfileTtlSeconds', {
infer: true,
}) ?? 30,
);
}
return profile;
}
private diversifyRankedPosts(ranked: RankedPost[]): RankedPost[] {
const remaining = [...ranked];
const result: RankedPost[] = [];
const authorCounts = new Map<string, number>();
const typeCounts = new Map<string, number>();
const hashtagCounts = new Map<string, number>();
while (remaining.length) {
let bestIndex = 0;
let bestAdjustedScore = Number.NEGATIVE_INFINITY;
for (let index = 0; index < remaining.length; index += 1) {
const entry = remaining[index];
const authorId = this.extractEntityId(entry.post.authorId);
const postType = String(entry.post.postType ?? '');
const hashtags = Array.isArray(entry.post.hashtags)
? entry.post.hashtags.map((tag: unknown) => String(tag).toLowerCase())
: [];
const previousAuthorId = result.length
? this.extractEntityId(result[result.length - 1].post.authorId)
: '';
const authorPenalty = (authorCounts.get(authorId) ?? 0) * 12;
const consecutivePenalty = authorId && authorId === previousAuthorId ? 22 : 0;
const typePenalty = Math.max(0, (typeCounts.get(postType) ?? 0) - 1) * 2.5;
const hashtagPenalty = Math.min(
8,
hashtags.reduce((sum, tag) => sum + (hashtagCounts.get(tag) ?? 0) * 0.75, 0),
);
const adjusted =
entry.score - authorPenalty - consecutivePenalty - typePenalty - hashtagPenalty;
if (adjusted > bestAdjustedScore) {
bestAdjustedScore = adjusted;
bestIndex = index;
}
}
const [selected] = remaining.splice(bestIndex, 1);
const authorId = this.extractEntityId(selected.post.authorId);
const postType = String(selected.post.postType ?? '');
authorCounts.set(authorId, (authorCounts.get(authorId) ?? 0) + 1);
typeCounts.set(postType, (typeCounts.get(postType) ?? 0) + 1);
for (const hashtag of Array.isArray(selected.post.hashtags) ? selected.post.hashtags : []) {
const normalized = String(hashtag).toLowerCase();
hashtagCounts.set(normalized, (hashtagCounts.get(normalized) ?? 0) + 1);
}
result.push({ ...selected, score: bestAdjustedScore });
}
return result;
}
private decodeFeedCursor(cursor?: string): { offset: number | null; rankedAt: Date } {
const fallback = { offset: decodeOffsetCursor(cursor), rankedAt: new Date() };
if (!cursor) {
return fallback;
}
try {
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
offset?: unknown;
rankedAt?: unknown;
};
const rankedAt = new Date(String(payload.rankedAt ?? ''));
if (
!Number.isInteger(payload.offset) ||
Number(payload.offset) < 0 ||
Number.isNaN(rankedAt.getTime())
) {
return fallback;
}
return { offset: Number(payload.offset), rankedAt };
} catch {
return fallback;
}
}
private encodeFeedCursor(offset: number, rankedAt: Date): string {
return Buffer.from(
JSON.stringify({ version: 1, offset, rankedAt: rankedAt.toISOString() }),
'utf8',
).toString('base64url');
}
async getExplore(currentUserId: string, query: FeedQueryDto) {
return this.getTrending(currentUserId, {
...query,
@@ -264,6 +598,7 @@ export class FeedService {
cursor: query.cursor ?? '',
preferredPostType: query.preferredPostType ?? '',
});
let cacheFillLock: Awaited<ReturnType<AppCacheService['acquireFillLock']>> | null = null;
if (cacheEnabled) {
const cached = await this.cacheService.get<Record<string, unknown>>(cacheKey);
if (cached) {
@@ -275,20 +610,38 @@ export class FeedService {
});
return cached;
}
cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10);
if (!cacheFillLock.acquired) {
const coalesced = await this.cacheService.waitForValue<Record<string, unknown>>(
cacheKey,
2_000,
);
if (coalesced) {
this.logFeedTiming(timing, {
cacheHit: true,
cacheCoalesced: true,
itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined,
responseBytes: this.measureResponseBytes(coalesced),
});
return coalesced;
}
}
}
const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1;
const skip = cursorOffset ?? (page - 1) * limit;
const [followingIds, invisibleUserIds] = await Promise.all([
const [followingIds, invisibleUserIds, privateAuthorIds] = await Promise.all([
this.feedRepository.findFollowingIds(currentUserId),
this.blocksService.getInvisibleUserIds(currentUserId),
this.usersRepository.findPrivateUserIds(),
]);
const relationLookupMs = this.markTiming(timing);
const trendingFilter: Record<string, unknown> = { visibility: PostVisibility.PUBLIC };
if (invisibleUserIds.length) {
trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) };
const excludedAuthorIds = Array.from(new Set([...invisibleUserIds, ...privateAuthorIds]));
if (excludedAuthorIds.length) {
trendingFilter.authorId = { $nin: excludedAuthorIds.map((id) => new Types.ObjectId(id)) };
}
const requestedPostType = this.resolveRequestedPostType(query.preferredPostType);
if (requestedPostType) {
@@ -300,9 +653,13 @@ export class FeedService {
this.feedRepository.count(trendingFilter),
]);
const postLookupMs = this.markTiming(timing);
const visibleRows = rows.filter((post) => {
const author = post.authorId as unknown as { isDisabled?: boolean } | null;
return !!author && author.isDisabled !== true;
});
const decoratedPosts = await this.decoratePostsForViewer(
currentUserId,
rows.map((item) => item.toObject() as unknown as Record<string, unknown>),
visibleRows.map((item) => item.toObject() as unknown as Record<string, unknown>),
followingIds,
);
const normalizedPosts = this.normalizePreferredPostTypeForResponse(
@@ -330,6 +687,9 @@ export class FeedService {
this.configService.get<number>('feedCache.trendingTtlSeconds', { infer: true }) ?? 30,
);
}
if (cacheFillLock?.acquired) {
await cacheFillLock.release().catch(() => false);
}
this.logFeedTiming(timing, {
cacheHit: false,
@@ -356,9 +716,12 @@ export class FeedService {
items: Array<Record<string, unknown>>,
followingIds: string[],
): Promise<FeedPostItem[]> {
const postIds = items
.map((item) => this.extractEntityId(item._id ?? item.id))
.filter(Boolean);
const safeItems = await this.redactInaccessibleOriginalReferences(
currentUserId,
items,
followingIds,
);
const postIds = safeItems.map((item) => this.extractEntityId(item._id ?? item.id)).filter(Boolean);
const followingSet = new Set(followingIds);
const [likedPostIds, savedPostIds, sharedPostIds] = await Promise.all([
this.likesRepository.findLikedPostIds(currentUserId, postIds),
@@ -369,7 +732,7 @@ export class FeedService {
const savedSet = new Set(savedPostIds);
const sharedSet = new Set(sharedPostIds);
return items.map((item) => {
return safeItems.map((item) => {
const postId = this.extractEntityId(item._id ?? item.id);
const authorId = this.extractEntityId(item.authorId);
const likesCount = Number(item.likesCount ?? 0);
@@ -402,6 +765,67 @@ export class FeedService {
});
}
private async redactInaccessibleOriginalReferences(
currentUserId: string,
items: Array<Record<string, unknown>>,
followingIds: string[],
): Promise<Array<Record<string, unknown>>> {
const fields = ['repostOfPostId', 'quoteOfPostId'] as const;
const sourceIds = Array.from(
new Set(
items
.flatMap((item) => fields.map((field) => this.extractEntityId(item[field])))
.filter((id) => Types.ObjectId.isValid(id)),
),
);
if (!sourceIds.length) {
return items;
}
const [sources, unauthorizedPrivateAuthorIds, invisibleUserIds] = await Promise.all([
this.connection
.collection('posts')
.find({
_id: { $in: sourceIds.map((id) => new Types.ObjectId(id)) },
isDeleted: { $ne: true },
isArchived: { $ne: true },
moderationStatus: { $ne: 'hidden' },
})
.project({ authorId: 1, visibility: 1 })
.toArray(),
this.usersRepository.findPrivateUserIds([currentUserId, ...followingIds]),
this.blocksService.getInvisibleUserIds(currentUserId),
]);
const sourceById = new Map(sources.map((source) => [source._id.toString(), source]));
const followingSet = new Set(followingIds);
const excludedAuthorSet = new Set([
...unauthorizedPrivateAuthorIds,
...invisibleUserIds,
]);
return items.map((item) => {
const safe = { ...item };
for (const field of fields) {
const sourceId = this.extractEntityId(item[field]);
if (!sourceId) continue;
const source = sourceById.get(sourceId);
const authorId = this.extractEntityId(source?.authorId);
const allowed =
!!source &&
!!authorId &&
!excludedAuthorSet.has(authorId) &&
(authorId === currentUserId ||
source.visibility === PostVisibility.PUBLIC ||
(source.visibility === PostVisibility.FOLLOWERS &&
followingSet.has(authorId)));
if (!allowed) {
safe[field] = null;
}
}
return safe;
});
}
private async findSharedPostIds(currentUserId: string, postIds: string[]): Promise<string[]> {
if (!Types.ObjectId.isValid(currentUserId) || postIds.length === 0) {
return [];
@@ -524,24 +948,24 @@ export class FeedService {
const visibilityFilter = followingOnly
? {
$or: [
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
},
],
}
$or: [
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] },
},
],
}
: {
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: PostVisibility.FOLLOWERS,
},
],
};
$or: [
{ visibility: PostVisibility.PUBLIC },
{ authorId: currentUserObjectId },
{
authorId: { $in: followingObjectIds },
visibility: PostVisibility.FOLLOWERS,
},
],
};
if (!invisibleUserIds.length) {
return visibilityFilter;
@@ -562,28 +986,58 @@ export class FeedService {
post: Record<string, any>;
preferredPostType?: FeedPostTypeFilter;
radiusKm: number;
rankingProfile: ViewerRankingProfile;
rankedAt: Date;
}): number {
const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input;
const {
currentUser,
currentUserId,
followingIds,
post,
preferredPostType,
radiusKm,
rankingProfile,
rankedAt,
} = input;
const author = post.authorId;
const authorId = this.extractEntityId(author);
const isOwnPost = authorId === currentUserId;
const isFollowing = followingIds.includes(authorId);
const ageMs = Date.now() - new Date(post.createdAt).getTime();
const ageMs = Math.max(0, rankedAt.getTime() - new Date(post.createdAt).getTime());
const ageHours = ageMs / (1000 * 60 * 60);
const freshness = Math.max(0, 36 - ageHours);
const freshness = 32 * Math.exp(-ageHours / (24 * 3));
const veryRecentBoost = ageHours <= 6 ? 5 : 0;
const engagement =
Number(post.likesCount ?? 0) * 3 +
Number(post.commentsCount ?? 0) * 4 +
Number(post.savesCount ?? 0) * 5 +
Number(post.shareCount ?? 0) * 6 +
Number(post.viewCount ?? 0) * 0.15 +
Number(post.playCount ?? 0) * 0.25;
Math.log1p(Math.max(0, Number(post.likesCount ?? 0))) * 2.4 +
Math.log1p(Math.max(0, Number(post.commentsCount ?? 0))) * 3.2 +
Math.log1p(Math.max(0, Number(post.savesCount ?? 0))) * 4.2 +
Math.log1p(Math.max(0, Number(post.shareCount ?? 0))) * 4.8 +
Math.log1p(Math.max(0, Number(post.viewCount ?? 0))) * 0.7 +
Math.log1p(Math.max(0, Number(post.playCount ?? 0))) * 1.1;
const normalizedHashtags = (post.hashtags ?? [])
.map((value: string) => value.trim().toLowerCase())
.filter(Boolean);
const hashtagMatches = this.intersectionCount(
this.buildPreferenceTokens(currentUser),
(post.hashtags ?? []).map((value: string) => value.toLowerCase()),
normalizedHashtags,
);
const behaviorHashtagAffinity = normalizedHashtags.reduce(
(sum: number, hashtag: string) => sum + (rankingProfile.hashtagAffinity[hashtag] ?? 0),
0,
);
const authorAffinity = rankingProfile.authorAffinity[authorId] ?? 0;
const postTypeAffinity = rankingProfile.postTypeAffinity[String(post.postType ?? '')] ?? 0;
const interactedBefore = rankingProfile.interactedPostIds.includes(
this.extractEntityId(post._id ?? post.id),
);
const behavior = (rankingProfile.engagementSignals ?? {})[this.extractEntityId(post._id ?? post.id)];
const watchQualityBoost = behavior
? Math.min(18, Math.log1p(Math.max(0, behavior.watchMs) / 1000) * 2) +
Math.min(12, behavior.completions * 4) + Math.min(12, behavior.rewatches * 6) +
Math.min(6, behavior.profileOpens * 2)
: 0;
const distanceKm = this.computeDistanceKm(
currentUser.latitude,
@@ -592,18 +1046,26 @@ export class FeedService {
author?.longitude ?? null,
);
const nearbyBoost =
typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0;
typeof distanceKm === 'number' && distanceKm <= radiusKm
? Math.max(0, 8 - (distanceKm / radiusKm) * 8)
: 0;
let score = 0;
score += engagement;
score += freshness;
score += isOwnPost ? 10 : 0;
score += isFollowing ? 40 : 0;
score += post.postType === preferredPostType ? 18 : 0;
score += hashtagMatches * 9;
score += veryRecentBoost;
score += isOwnPost ? 3 : 0;
score += isFollowing ? 24 : 0;
score += post.postType === preferredPostType ? 12 : 0;
score += hashtagMatches * 7;
score += Math.min(24, Math.log1p(authorAffinity) * 7);
score += Math.min(14, Math.log1p(postTypeAffinity) * 4);
score += Math.min(16, Math.log1p(behaviorHashtagAffinity) * 4);
score += watchQualityBoost;
score += nearbyBoost;
score += author?.isVerified ? 8 : 0;
score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200));
score += author?.isVerified ? 2.5 : 0;
score += Math.min(6, Math.log1p(Math.max(0, Number(author?.followersCount ?? 0))));
score -= interactedBefore ? 10 : 0;
return score;
}
@@ -668,9 +1130,7 @@ export class FeedService {
private shouldIncludeSuggestions(query: FeedQueryDto): boolean {
return (
query.includeSuggestions === true &&
!(query.cursor ?? '').trim() &&
(query.page ?? 1) === 1
query.includeSuggestions === true && !(query.cursor ?? '').trim() && (query.page ?? 1) === 1
);
}
@@ -33,6 +33,11 @@ export class FollowsUsersController {
return this.followsService.unfollowUser(user.sub, targetUserId);
}
@Delete(':followerId/followers')
async removeFollower(@CurrentUser() user: JwtPayload, @Param('followerId') followerId: string) {
return this.followsService.removeFollower(user.sub, followerId);
}
@Get(':userId/follow-status')
async followStatus(@CurrentUser() user: JwtPayload, @Param('userId') targetUserId: string) {
return this.followsService.getFollowStatus(user.sub, targetUserId);

Some files were not shown because too many files have changed in this diff Show More