Initial commit - Backend de template-saas migrado desde monorepo

Migración desde workspace-v2/projects/template-saas/apps/backend
Este repositorio es parte del estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:07:11 -06:00
parent 6a750c0408
commit dfe6a715f0
294 changed files with 59867 additions and 0 deletions

39
.dockerignore Normal file
View File

@ -0,0 +1,39 @@
# Dependencies
node_modules
npm-debug.log
# Build output
dist
# Test files
coverage
*.spec.ts
__tests__
# Development files
.env
.env.local
.env.*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
docs
# Docker
Dockerfile
.dockerignore
docker-compose*.yml

56
.env.example Normal file
View File

@ -0,0 +1,56 @@
# Template SaaS Backend Configuration
# Copy this file to .env and adjust values
# Server
NODE_ENV=development
PORT=3001
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=template_saas_dev
DB_USER=gamilit_user
DB_PASSWORD=GO0jAOgw8Yzankwt
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# CORS
CORS_ORIGIN=http://localhost:3000
# Stripe Integration (optional - leave empty to disable)
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# OAuth 2.0 Integration (optional - leave empty to disable)
# Generate encryption key with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
OAUTH_TOKEN_ENCRYPTION_KEY=
OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/oauth/callback
# Google OAuth (https://console.cloud.google.com/apis/credentials)
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_GOOGLE_CALLBACK_URL=http://localhost:3001/api/auth/oauth/google/callback
# Microsoft OAuth (https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
OAUTH_MICROSOFT_CLIENT_ID=
OAUTH_MICROSOFT_CLIENT_SECRET=
OAUTH_MICROSOFT_CALLBACK_URL=http://localhost:3001/api/auth/oauth/microsoft/callback
# GitHub OAuth (https://github.com/settings/developers)
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=
OAUTH_GITHUB_CALLBACK_URL=http://localhost:3001/api/auth/oauth/github/callback
# Apple OAuth (https://developer.apple.com/account/resources/identifiers)
# 1. Create an App ID with Sign in with Apple capability
# 2. Create a Services ID for your web application
# 3. Create a Sign in with Apple key and download the .p8 file
OAUTH_APPLE_CLIENT_ID= # Services ID (e.g., com.example.app.web)
OAUTH_APPLE_TEAM_ID= # Your Apple Developer Team ID
OAUTH_APPLE_KEY_ID= # Key ID from the .p8 key
OAUTH_APPLE_PRIVATE_KEY= # Contents of .p8 file (replace newlines with \n)
OAUTH_APPLE_CALLBACK_URL=http://localhost:3001/api/auth/oauth/apple/form-callback

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store

54
Dockerfile Normal file
View File

@ -0,0 +1,54 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Set ownership to non-root user
RUN chown -R nestjs:nodejs /app
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3001
# Start the application
CMD ["node", "-r", "tsconfig-paths/register", "dist/main.js"]

16
__mocks__/@nestjs/websockets.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
// Type declarations for @nestjs/websockets mock
declare module '@nestjs/websockets' {
export function WebSocketGateway(options?: any): ClassDecorator;
export function WebSocketServer(): PropertyDecorator;
export function SubscribeMessage(message: string): MethodDecorator;
export function MessageBody(): ParameterDecorator;
export function ConnectedSocket(): ParameterDecorator;
export interface OnGatewayConnection<T = any> {
handleConnection(client: T, ...args: any[]): any;
}
export interface OnGatewayDisconnect<T = any> {
handleDisconnect(client: T): any;
}
}

View File

@ -0,0 +1,31 @@
// Mock for @nestjs/websockets
export const WebSocketGateway = (options?: any) => {
return (target: any) => target;
};
export const WebSocketServer = () => {
return (target: any, propertyKey: string) => {};
};
export const SubscribeMessage = (message: string) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
return descriptor;
};
};
export const MessageBody = () => {
return (target: any, propertyKey: string, parameterIndex: number) => {};
};
export const ConnectedSocket = () => {
return (target: any, propertyKey: string, parameterIndex: number) => {};
};
export interface OnGatewayConnection {
handleConnection(client: any, ...args: any[]): any;
}
export interface OnGatewayDisconnect {
handleDisconnect(client: any): any;
}

34
__mocks__/socket.io.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
// Type declarations for socket.io mock
declare module 'socket.io' {
export class Server {
to(room: string): this;
in(room: string): this;
emit(event: string, ...args: any[]): boolean;
}
export interface Socket {
id: string;
handshake: {
auth: Record<string, any>;
query: Record<string, any>;
headers: Record<string, any>;
time: string;
address: string;
xdomain: boolean;
secure: boolean;
issued: number;
url: string;
};
rooms: Set<string>;
data: any;
connected: boolean;
join(room: string | string[]): void;
leave(room: string): void;
emit(event: string, ...args: any[]): boolean;
on(event: string, listener: Function): this;
once(event: string, listener: Function): this;
disconnect(close?: boolean): this;
to(room: string): this;
broadcast: any;
}
}

33
__mocks__/socket.io.ts Normal file
View File

@ -0,0 +1,33 @@
// Mock for socket.io
export class Server {
to = jest.fn().mockReturnThis();
emit = jest.fn();
in = jest.fn().mockReturnThis();
}
export interface Socket {
id: string;
handshake: {
auth: Record<string, any>;
query: Record<string, any>;
headers: Record<string, any>;
time: string;
address: string;
xdomain: boolean;
secure: boolean;
issued: number;
url: string;
};
rooms: Set<string>;
data: any;
connected: boolean;
join: (room: string | string[]) => void;
leave: (room: string) => void;
emit: (event: string, ...args: any[]) => boolean;
on: (event: string, listener: Function) => this;
once: (event: string, listener: Function) => this;
disconnect: (close?: boolean) => this;
to: (room: string) => this;
broadcast: any;
}

41
__mocks__/web-push.d.ts vendored Normal file
View File

@ -0,0 +1,41 @@
// Type declarations for web-push mock
declare module 'web-push' {
export function setVapidDetails(
subject: string,
publicKey: string,
privateKey: string
): void;
export function sendNotification(
subscription: PushSubscription,
payload?: string | Buffer | null,
options?: RequestOptions
): Promise<SendResult>;
export interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface RequestOptions {
headers?: Record<string, string>;
TTL?: number;
vapidDetails?: {
subject: string;
publicKey: string;
privateKey: string;
};
timeout?: number;
proxy?: string;
agent?: any;
}
export interface SendResult {
statusCode: number;
body: string;
headers: Record<string, string>;
}
}

18
__mocks__/web-push.ts Normal file
View File

@ -0,0 +1,18 @@
// Mock for web-push
export const setVapidDetails = jest.fn();
export const sendNotification = jest.fn();
export interface PushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface SendResult {
statusCode: number;
body: string;
headers: Record<string, string>;
}

71
eslint.config.js Normal file
View File

@ -0,0 +1,71 @@
import globals from 'globals';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
export default [
// Source files - stricter rules
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
sourceType: 'module',
},
globals: {
...globals.node,
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
ignoreRestSiblings: true,
}],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-require-imports': 'off',
'no-console': 'off',
'prefer-const': 'error',
},
},
// Test files - relaxed rules
{
files: ['__tests__/**/*.ts', 'src/**/*.spec.ts', 'src/**/*.test.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
sourceType: 'module',
},
globals: {
...globals.node,
...globals.jest,
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-console': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.js', '*.mjs'],
},
];

35
jest.config.cjs Normal file
View File

@ -0,0 +1,35 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': ['ts-jest', { isolatedModules: true }],
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@config/(.*)$': '<rootDir>/config/$1',
'^@modules/(.*)$': '<rootDir>/modules/$1',
'^@shared/(.*)$': '<rootDir>/shared/$1',
'^uuid$': 'uuid',
'^@nestjs/websockets$': '<rootDir>/../__mocks__/@nestjs/websockets.ts',
'^socket\\.io$': '<rootDir>/../__mocks__/socket.io.ts',
'^web-push$': '<rootDir>/../__mocks__/web-push.ts',
},
transformIgnorePatterns: [
'node_modules/(?!(uuid)/)',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/__tests__/',
'.module.ts',
'.entity.ts',
'.dto.ts',
'main.ts',
'index.ts',
],
setupFilesAfterEnv: [],
testTimeout: 10000,
};

11790
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "@template-saas/backend",
"version": "1.0.0",
"type": "module",
"description": "Template SaaS Backend - Multi-tenant Platform",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"start": "node -r tsconfig-paths/register dist/main.js",
"start:dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/main.ts",
"start:prod": "NODE_ENV=production node -r tsconfig-paths/register dist/main.js",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"lint": "eslint \"{src,__tests__}/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.964.0",
"@aws-sdk/s3-request-presigner": "^3.964.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.8",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.8",
"@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.8",
"@nestjs/swagger": "^11.2.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.0.0",
"@nestjs/typeorm": "^11.0.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.66.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.18.2",
"helmet": "^8.1.0",
"ioredis": "^5.9.0",
"joi": "^18.0.1",
"jsonwebtoken": "^9.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.11.3",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"speakeasy": "^2.0.0",
"stripe": "^17.5.0",
"typeorm": "^0.3.22",
"uuid": "^13.0.0"
},
"devDependencies": {
"@nestjs/testing": "^11.1.8",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.2",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"eslint": "^9.17.0",
"globals": "^17.0.0",
"jest": "^29.7.0",
"prettier": "^3.2.4",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^3.15.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}

93
src/app.module.ts Normal file
View File

@ -0,0 +1,93 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { TerminusModule } from '@nestjs/terminus';
import { BullModule } from '@nestjs/bullmq';
// Config
import { envConfig, validationSchema } from '@config/env.config';
import { databaseConfig } from '@config/database.config';
// Modules
import { AuthModule } from '@modules/auth/auth.module';
import { TenantsModule } from '@modules/tenants/tenants.module';
import { UsersModule } from '@modules/users/users.module';
import { RbacModule } from '@modules/rbac/rbac.module';
import { NotificationsModule } from '@modules/notifications/notifications.module';
import { BillingModule } from '@modules/billing/billing.module';
import { AuditModule } from '@modules/audit/audit.module';
import { FeatureFlagsModule } from '@modules/feature-flags/feature-flags.module';
import { HealthModule } from '@modules/health/health.module';
import { SuperadminModule } from '@modules/superadmin/superadmin.module';
import { AIModule } from '@modules/ai/ai.module';
import { StorageModule } from '@modules/storage/storage.module';
import { WebhooksModule } from '@modules/webhooks/webhooks.module';
import { EmailModule } from '@modules/email/email.module';
import { OnboardingModule } from '@modules/onboarding/onboarding.module';
import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module';
import { AnalyticsModule } from '@modules/analytics/analytics.module';
import { ReportsModule } from '@modules/reports/reports.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
load: [envConfig],
validationSchema,
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => databaseConfig(configService),
inject: [ConfigService],
}),
// Rate limiting
ThrottlerModule.forRoot([
{
ttl: 60000, // 1 minute
limit: 100, // 100 requests per minute
},
]),
// Health checks
TerminusModule,
// BullMQ for background jobs (webhooks, etc.)
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get('REDIS_PORT', 6379),
password: configService.get('REDIS_PASSWORD', undefined),
},
}),
inject: [ConfigService],
}),
// Feature modules
AuthModule,
TenantsModule,
UsersModule,
RbacModule,
NotificationsModule,
BillingModule,
AuditModule,
FeatureFlagsModule,
HealthModule,
SuperadminModule,
AIModule,
StorageModule,
WebhooksModule,
EmailModule,
OnboardingModule,
WhatsAppModule,
AnalyticsModule,
ReportsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,17 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig = (configService: ConfigService): TypeOrmModuleOptions => ({
type: 'postgres',
host: configService.get<string>('database.host'),
port: configService.get<number>('database.port'),
database: configService.get<string>('database.name'),
username: configService.get<string>('database.user'),
password: configService.get<string>('database.password'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: false, // NEVER true in production - use migrations
logging: configService.get<string>('nodeEnv') === 'development',
ssl: configService.get<string>('nodeEnv') === 'production'
? { rejectUnauthorized: false }
: false,
});

164
src/config/env.config.ts Normal file
View File

@ -0,0 +1,164 @@
import * as Joi from 'joi';
export const envConfig = () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3001', 10),
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'template_saas_dev',
user: process.env.DB_USER || 'template_saas_user',
password: process.env.DB_PASSWORD || 'template_saas_dev_2026',
},
jwt: {
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
},
ai: {
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
defaultModel: process.env.AI_DEFAULT_MODEL || 'anthropic/claude-3-haiku',
fallbackModel: process.env.AI_FALLBACK_MODEL || 'openai/gpt-3.5-turbo',
timeoutMs: parseInt(process.env.AI_TIMEOUT_MS || '30000', 10),
},
email: {
provider: process.env.EMAIL_PROVIDER || 'sendgrid', // 'sendgrid' | 'ses' | 'smtp'
from: process.env.EMAIL_FROM || 'noreply@example.com',
fromName: process.env.EMAIL_FROM_NAME || 'Template SaaS',
replyTo: process.env.EMAIL_REPLY_TO || '',
// SendGrid
sendgridApiKey: process.env.SENDGRID_API_KEY || '',
// AWS SES
sesRegion: process.env.AWS_SES_REGION || 'us-east-1',
sesAccessKeyId: process.env.AWS_SES_ACCESS_KEY_ID || '',
sesSecretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY || '',
// SMTP (fallback)
smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10),
smtpUser: process.env.SMTP_USER || '',
smtpPassword: process.env.SMTP_PASSWORD || '',
smtpSecure: process.env.SMTP_SECURE === 'true',
},
whatsapp: {
apiVersion: process.env.WHATSAPP_API_VERSION || 'v17.0',
verifyToken: process.env.WHATSAPP_VERIFY_TOKEN || '',
appSecret: process.env.WHATSAPP_APP_SECRET || '',
},
oauth: {
tokenEncryptionKey: process.env.OAUTH_TOKEN_ENCRYPTION_KEY || '',
frontendCallbackUrl: process.env.OAUTH_FRONTEND_CALLBACK_URL || 'http://localhost:5173/auth/oauth/callback',
google: {
clientId: process.env.OAUTH_GOOGLE_CLIENT_ID || '',
clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET || '',
callbackUrl: process.env.OAUTH_GOOGLE_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/google/callback',
},
microsoft: {
clientId: process.env.OAUTH_MICROSOFT_CLIENT_ID || '',
clientSecret: process.env.OAUTH_MICROSOFT_CLIENT_SECRET || '',
callbackUrl: process.env.OAUTH_MICROSOFT_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/microsoft/callback',
},
github: {
clientId: process.env.OAUTH_GITHUB_CLIENT_ID || '',
clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET || '',
callbackUrl: process.env.OAUTH_GITHUB_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/github/callback',
},
apple: {
clientId: process.env.OAUTH_APPLE_CLIENT_ID || '', // Services ID from Apple Developer
teamId: process.env.OAUTH_APPLE_TEAM_ID || '', // Apple Developer Team ID
keyId: process.env.OAUTH_APPLE_KEY_ID || '', // Key ID from Apple Developer
privateKey: process.env.OAUTH_APPLE_PRIVATE_KEY || '', // Contents of .p8 key file
callbackUrl: process.env.OAUTH_APPLE_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/apple/callback',
},
},
});
export const validationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(3001),
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().default('template_saas_dev'),
DB_USER: Joi.string().default('template_saas_user'),
DB_PASSWORD: Joi.string().default('template_saas_dev_2026'),
JWT_SECRET: Joi.string().default('dev-jwt-secret-change-in-production'),
JWT_EXPIRES_IN: Joi.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: Joi.string().default('7d'),
CORS_ORIGIN: Joi.string().default('http://localhost:3000'),
// Stripe (optional - integration disabled if not set)
STRIPE_SECRET_KEY: Joi.string().allow('').default(''),
STRIPE_WEBHOOK_SECRET: Joi.string().allow('').default(''),
STRIPE_PUBLISHABLE_KEY: Joi.string().allow('').default(''),
// AI (optional - integration disabled if not set)
OPENROUTER_API_KEY: Joi.string().allow('').default(''),
AI_DEFAULT_MODEL: Joi.string().default('anthropic/claude-3-haiku'),
AI_FALLBACK_MODEL: Joi.string().default('openai/gpt-3.5-turbo'),
AI_TIMEOUT_MS: Joi.number().default(30000),
// Email (optional - integration disabled if not set)
EMAIL_PROVIDER: Joi.string().valid('sendgrid', 'ses', 'smtp').default('sendgrid'),
EMAIL_FROM: Joi.string().email().default('noreply@example.com'),
EMAIL_FROM_NAME: Joi.string().default('Template SaaS'),
EMAIL_REPLY_TO: Joi.string().allow('').default(''),
// SendGrid
SENDGRID_API_KEY: Joi.string().allow('').default(''),
// AWS SES
AWS_SES_REGION: Joi.string().default('us-east-1'),
AWS_SES_ACCESS_KEY_ID: Joi.string().allow('').default(''),
AWS_SES_SECRET_ACCESS_KEY: Joi.string().allow('').default(''),
// SMTP
SMTP_HOST: Joi.string().allow('').default(''),
SMTP_PORT: Joi.number().default(587),
SMTP_USER: Joi.string().allow('').default(''),
SMTP_PASSWORD: Joi.string().allow('').default(''),
SMTP_SECURE: Joi.boolean().default(false),
// WhatsApp (optional)
WHATSAPP_API_VERSION: Joi.string().default('v17.0'),
WHATSAPP_VERIFY_TOKEN: Joi.string().allow('').default(''),
WHATSAPP_APP_SECRET: Joi.string().allow('').default(''),
// OAuth 2.0 (optional - integration disabled if not set)
OAUTH_TOKEN_ENCRYPTION_KEY: Joi.string().allow('').default(''),
OAUTH_FRONTEND_CALLBACK_URL: Joi.string().default('http://localhost:5173/auth/oauth/callback'),
// Google OAuth
OAUTH_GOOGLE_CLIENT_ID: Joi.string().allow('').default(''),
OAUTH_GOOGLE_CLIENT_SECRET: Joi.string().allow('').default(''),
OAUTH_GOOGLE_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/google/callback'),
// Microsoft OAuth
OAUTH_MICROSOFT_CLIENT_ID: Joi.string().allow('').default(''),
OAUTH_MICROSOFT_CLIENT_SECRET: Joi.string().allow('').default(''),
OAUTH_MICROSOFT_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/microsoft/callback'),
// GitHub OAuth
OAUTH_GITHUB_CLIENT_ID: Joi.string().allow('').default(''),
OAUTH_GITHUB_CLIENT_SECRET: Joi.string().allow('').default(''),
OAUTH_GITHUB_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/github/callback'),
// Apple OAuth
OAUTH_APPLE_CLIENT_ID: Joi.string().allow('').default(''),
OAUTH_APPLE_TEAM_ID: Joi.string().allow('').default(''),
OAUTH_APPLE_KEY_ID: Joi.string().allow('').default(''),
OAUTH_APPLE_PRIVATE_KEY: Joi.string().allow('').default(''),
OAUTH_APPLE_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/apple/callback'),
});

2
src/config/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './env.config';
export * from './database.config';

74
src/main.ts Normal file
View File

@ -0,0 +1,74 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // Required for Stripe webhook signature verification
});
const configService = app.get(ConfigService);
// Security
app.use(helmet());
app.use(compression());
// CORS
app.enableCors({
origin: configService.get<string>('CORS_ORIGIN') || 'http://localhost:3000',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api/v1');
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Swagger
if (configService.get<string>('NODE_ENV') !== 'production') {
const config = new DocumentBuilder()
.setTitle('Template SaaS API')
.setDescription('Multi-tenant SaaS Platform API')
.setVersion('1.0')
.addBearerAuth()
.addTag('auth', 'Authentication endpoints')
.addTag('tenants', 'Tenant management')
.addTag('users', 'User management')
.addTag('billing', 'Billing and subscriptions')
.addTag('stripe', 'Stripe integration endpoints')
.addTag('stripe-webhooks', 'Stripe webhook handlers')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
// Start server
const port = configService.get<number>('PORT') || 3001;
await app.listen(port);
console.log(`
Template SaaS Backend
Server running on: http://localhost:${port} ║
API Docs: http://localhost:${port}/api/docs ║
Environment: ${configService.get<string>('NODE_ENV') || 'development'}
`);
}
bootstrap();

View File

@ -0,0 +1,756 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, HttpStatus } from '@nestjs/common';
import { AIController } from '../ai.controller';
import { AIService } from '../services/ai.service';
import { AIProvider } from '../entities/ai-config.entity';
import { UsageStatus } from '../entities/ai-usage.entity';
import {
ChatRequestDto,
ChatResponseDto,
UpdateAIConfigDto,
AIConfigResponseDto,
UsageStatsDto,
AIModelDto,
} from '../dto';
describe('AIController', () => {
let controller: AIController;
let aiService: jest.Mocked<AIService>;
// ==================== Mock Data ====================
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440002',
tenant_id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
role: 'admin',
};
const mockConfig: AIConfigResponseDto = {
id: 'config-001',
tenant_id: mockUser.tenant_id,
provider: AIProvider.OPENROUTER,
default_model: 'anthropic/claude-3-haiku',
fallback_model: 'anthropic/claude-3-sonnet',
temperature: 0.7,
max_tokens: 2048,
system_prompt: 'You are a helpful assistant.',
is_enabled: true,
allow_custom_prompts: true,
log_conversations: false,
created_at: new Date('2024-01-01T00:00:00Z'),
updated_at: new Date('2024-01-01T00:00:00Z'),
};
const mockChatResponse: ChatResponseDto = {
id: 'gen-001',
model: 'anthropic/claude-3-haiku',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello! How can I help you today?' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
created: Date.now(),
};
const mockModels: AIModelDto[] = [
{
id: 'anthropic/claude-3-haiku',
name: 'Claude 3 Haiku',
provider: 'anthropic',
context_length: 200000,
pricing: { prompt: 0.25, completion: 1.25 },
},
{
id: 'anthropic/claude-3-sonnet',
name: 'Claude 3 Sonnet',
provider: 'anthropic',
context_length: 200000,
pricing: { prompt: 3.0, completion: 15.0 },
},
{
id: 'openai/gpt-4-turbo',
name: 'GPT-4 Turbo',
provider: 'openai',
context_length: 128000,
pricing: { prompt: 10.0, completion: 30.0 },
},
];
const mockUsageStats: UsageStatsDto = {
request_count: 25,
total_input_tokens: 5000,
total_output_tokens: 2500,
total_tokens: 7500,
total_cost: 0.125,
avg_latency_ms: 450,
};
const mockUsageHistory = {
data: [
{
id: 'usage-001',
tenant_id: mockUser.tenant_id,
user_id: mockUser.id,
provider: AIProvider.OPENROUTER,
model: 'anthropic/claude-3-haiku',
status: UsageStatus.COMPLETED,
input_tokens: 100,
output_tokens: 50,
cost_input: 0.000025,
cost_output: 0.0000625,
latency_ms: 500,
created_at: new Date(),
},
{
id: 'usage-002',
tenant_id: mockUser.tenant_id,
user_id: mockUser.id,
provider: AIProvider.OPENROUTER,
model: 'anthropic/claude-3-haiku',
status: UsageStatus.COMPLETED,
input_tokens: 200,
output_tokens: 100,
cost_input: 0.00005,
cost_output: 0.000125,
latency_ms: 600,
created_at: new Date(),
},
],
total: 2,
};
// ==================== Setup ====================
beforeEach(async () => {
const mockAIService = {
chat: jest.fn(),
getModels: jest.fn(),
getConfig: jest.fn(),
updateConfig: jest.fn(),
getUsageHistory: jest.fn(),
getCurrentMonthUsage: jest.fn(),
isServiceReady: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AIController],
providers: [{ provide: AIService, useValue: mockAIService }],
}).compile();
controller = module.get<AIController>(AIController);
aiService = module.get(AIService);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
// ==================== Controller Instantiation ====================
describe('constructor', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
// ==================== Chat Endpoint Tests ====================
describe('chat', () => {
const chatDto: ChatRequestDto = {
messages: [{ role: 'user', content: 'Hello, how are you?' }],
};
it('should successfully return chat response', async () => {
aiService.chat.mockResolvedValue(mockChatResponse);
const result = await controller.chat(mockUser, chatDto);
expect(result).toEqual(mockChatResponse);
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, chatDto);
expect(aiService.chat).toHaveBeenCalledTimes(1);
});
it('should pass model override from dto', async () => {
const dtoWithModel: ChatRequestDto = {
...chatDto,
model: 'openai/gpt-4-turbo',
};
aiService.chat.mockResolvedValue({
...mockChatResponse,
model: 'openai/gpt-4-turbo',
});
const result = await controller.chat(mockUser, dtoWithModel);
expect(result.model).toBe('openai/gpt-4-turbo');
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, dtoWithModel);
});
it('should pass temperature from dto', async () => {
const dtoWithTemp: ChatRequestDto = {
...chatDto,
temperature: 0.9,
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, dtoWithTemp);
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, dtoWithTemp);
});
it('should pass max_tokens from dto', async () => {
const dtoWithMaxTokens: ChatRequestDto = {
...chatDto,
max_tokens: 4096,
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, dtoWithMaxTokens);
expect(aiService.chat).toHaveBeenCalledWith(
mockUser.tenant_id,
mockUser.id,
dtoWithMaxTokens,
);
});
it('should handle multiple messages in conversation', async () => {
const conversationDto: ChatRequestDto = {
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
{ role: 'user', content: 'How are you?' },
],
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, conversationDto);
expect(aiService.chat).toHaveBeenCalledWith(
mockUser.tenant_id,
mockUser.id,
conversationDto,
);
});
it('should throw BadRequestException when AI is disabled', async () => {
aiService.chat.mockRejectedValue(
new BadRequestException('AI features are disabled for this tenant'),
);
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(BadRequestException);
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(
'AI features are disabled for this tenant',
);
});
it('should throw BadRequestException when service not configured', async () => {
aiService.chat.mockRejectedValue(new BadRequestException('AI service is not configured'));
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(BadRequestException);
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(
'AI service is not configured',
);
});
it('should propagate generic errors from service', async () => {
aiService.chat.mockRejectedValue(new Error('OpenRouter API error'));
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow('OpenRouter API error');
});
it('should handle rate limit errors', async () => {
aiService.chat.mockRejectedValue(new BadRequestException('Rate limit exceeded'));
await expect(controller.chat(mockUser, chatDto)).rejects.toThrow('Rate limit exceeded');
});
});
// ==================== Models Endpoint Tests ====================
describe('getModels', () => {
it('should return list of available models', async () => {
aiService.getModels.mockResolvedValue(mockModels);
const result = await controller.getModels();
expect(result).toEqual(mockModels);
expect(result).toHaveLength(3);
expect(aiService.getModels).toHaveBeenCalledTimes(1);
});
it('should return empty array when no models available', async () => {
aiService.getModels.mockResolvedValue([]);
const result = await controller.getModels();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should include model pricing information', async () => {
aiService.getModels.mockResolvedValue(mockModels);
const result = await controller.getModels();
expect(result[0].pricing).toBeDefined();
expect(result[0].pricing.prompt).toBe(0.25);
expect(result[0].pricing.completion).toBe(1.25);
});
it('should include context length for each model', async () => {
aiService.getModels.mockResolvedValue(mockModels);
const result = await controller.getModels();
expect(result[0].context_length).toBe(200000);
expect(result[2].context_length).toBe(128000);
});
it('should propagate errors from service', async () => {
aiService.getModels.mockRejectedValue(new Error('Failed to fetch models'));
await expect(controller.getModels()).rejects.toThrow('Failed to fetch models');
});
});
// ==================== Configuration Endpoint Tests ====================
describe('getConfig', () => {
it('should return tenant configuration', async () => {
aiService.getConfig.mockResolvedValue(mockConfig as any);
const result = await controller.getConfig(mockUser);
expect(result).toEqual(mockConfig);
expect(aiService.getConfig).toHaveBeenCalledWith(mockUser.tenant_id);
expect(aiService.getConfig).toHaveBeenCalledTimes(1);
});
it('should return default configuration for new tenant', async () => {
const defaultConfig = {
...mockConfig,
system_prompt: null,
};
aiService.getConfig.mockResolvedValue(defaultConfig as any);
const result = await controller.getConfig(mockUser);
expect(result.provider).toBe(AIProvider.OPENROUTER);
expect(result.default_model).toBe('anthropic/claude-3-haiku');
expect(result.is_enabled).toBe(true);
});
it('should include all config fields', async () => {
aiService.getConfig.mockResolvedValue(mockConfig as any);
const result = await controller.getConfig(mockUser);
expect(result.id).toBeDefined();
expect(result.tenant_id).toBe(mockUser.tenant_id);
expect(result.provider).toBeDefined();
expect(result.default_model).toBeDefined();
expect(result.temperature).toBeDefined();
expect(result.max_tokens).toBeDefined();
expect(result.is_enabled).toBeDefined();
expect(result.allow_custom_prompts).toBeDefined();
expect(result.log_conversations).toBeDefined();
});
it('should propagate errors from service', async () => {
aiService.getConfig.mockRejectedValue(new Error('Database connection error'));
await expect(controller.getConfig(mockUser)).rejects.toThrow('Database connection error');
});
});
describe('updateConfig', () => {
it('should update temperature', async () => {
const updateDto: UpdateAIConfigDto = { temperature: 0.9 };
const updatedConfig = { ...mockConfig, temperature: 0.9 };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.temperature).toBe(0.9);
expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, updateDto);
});
it('should update max_tokens', async () => {
const updateDto: UpdateAIConfigDto = { max_tokens: 4096 };
const updatedConfig = { ...mockConfig, max_tokens: 4096 };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.max_tokens).toBe(4096);
expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, updateDto);
});
it('should update default_model', async () => {
const updateDto: UpdateAIConfigDto = { default_model: 'openai/gpt-4-turbo' };
const updatedConfig = { ...mockConfig, default_model: 'openai/gpt-4-turbo' };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.default_model).toBe('openai/gpt-4-turbo');
});
it('should update system_prompt', async () => {
const updateDto: UpdateAIConfigDto = { system_prompt: 'You are a coding assistant.' };
const updatedConfig = { ...mockConfig, system_prompt: 'You are a coding assistant.' };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.system_prompt).toBe('You are a coding assistant.');
});
it('should update is_enabled to false', async () => {
const updateDto: UpdateAIConfigDto = { is_enabled: false };
const updatedConfig = { ...mockConfig, is_enabled: false };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.is_enabled).toBe(false);
});
it('should update is_enabled to true', async () => {
const updateDto: UpdateAIConfigDto = { is_enabled: true };
const updatedConfig = { ...mockConfig, is_enabled: true };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.is_enabled).toBe(true);
});
it('should update allow_custom_prompts', async () => {
const updateDto: UpdateAIConfigDto = { allow_custom_prompts: false };
const updatedConfig = { ...mockConfig, allow_custom_prompts: false };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.allow_custom_prompts).toBe(false);
});
it('should update log_conversations', async () => {
const updateDto: UpdateAIConfigDto = { log_conversations: true };
const updatedConfig = { ...mockConfig, log_conversations: true };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.log_conversations).toBe(true);
});
it('should update multiple fields at once', async () => {
const updateDto: UpdateAIConfigDto = {
temperature: 0.5,
max_tokens: 1024,
default_model: 'openai/gpt-3.5-turbo',
};
const updatedConfig = {
...mockConfig,
temperature: 0.5,
max_tokens: 1024,
default_model: 'openai/gpt-3.5-turbo',
};
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.temperature).toBe(0.5);
expect(result.max_tokens).toBe(1024);
expect(result.default_model).toBe('openai/gpt-3.5-turbo');
});
it('should update provider', async () => {
const updateDto: UpdateAIConfigDto = { provider: AIProvider.OPENAI };
const updatedConfig = { ...mockConfig, provider: AIProvider.OPENAI };
aiService.updateConfig.mockResolvedValue(updatedConfig as any);
const result = await controller.updateConfig(mockUser, updateDto);
expect(result.provider).toBe(AIProvider.OPENAI);
});
it('should propagate errors from service', async () => {
const updateDto: UpdateAIConfigDto = { temperature: 0.9 };
aiService.updateConfig.mockRejectedValue(new Error('Failed to update config'));
await expect(controller.updateConfig(mockUser, updateDto)).rejects.toThrow(
'Failed to update config',
);
});
});
// ==================== Usage Endpoint Tests ====================
describe('getUsage', () => {
it('should return usage history with default pagination', async () => {
aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any);
const result = await controller.getUsage(mockUser);
expect(result).toEqual(mockUsageHistory);
expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1, 20);
});
it('should return usage history with custom page', async () => {
aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any);
await controller.getUsage(mockUser, 2);
expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 2, 20);
});
it('should return usage history with custom limit', async () => {
aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any);
await controller.getUsage(mockUser, 1, 50);
expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1, 50);
});
it('should return usage history with custom page and limit', async () => {
aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any);
await controller.getUsage(mockUser, 3, 10);
expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 3, 10);
});
it('should return empty data for new tenant', async () => {
aiService.getUsageHistory.mockResolvedValue({ data: [], total: 0 });
const result = await controller.getUsage(mockUser);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
it('should propagate errors from service', async () => {
aiService.getUsageHistory.mockRejectedValue(new Error('Database error'));
await expect(controller.getUsage(mockUser)).rejects.toThrow('Database error');
});
});
describe('getCurrentUsage', () => {
it('should return current month usage statistics', async () => {
aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats);
const result = await controller.getCurrentUsage(mockUser);
expect(result).toEqual(mockUsageStats);
expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith(mockUser.tenant_id);
expect(aiService.getCurrentMonthUsage).toHaveBeenCalledTimes(1);
});
it('should return zero values for new tenant', async () => {
const zeroStats: UsageStatsDto = {
request_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_tokens: 0,
total_cost: 0,
avg_latency_ms: 0,
};
aiService.getCurrentMonthUsage.mockResolvedValue(zeroStats);
const result = await controller.getCurrentUsage(mockUser);
expect(result.request_count).toBe(0);
expect(result.total_tokens).toBe(0);
expect(result.total_cost).toBe(0);
});
it('should include all statistics fields', async () => {
aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats);
const result = await controller.getCurrentUsage(mockUser);
expect(result.request_count).toBeDefined();
expect(result.total_input_tokens).toBeDefined();
expect(result.total_output_tokens).toBeDefined();
expect(result.total_tokens).toBeDefined();
expect(result.total_cost).toBeDefined();
expect(result.avg_latency_ms).toBeDefined();
});
it('should propagate errors from service', async () => {
aiService.getCurrentMonthUsage.mockRejectedValue(new Error('Failed to calculate usage'));
await expect(controller.getCurrentUsage(mockUser)).rejects.toThrow(
'Failed to calculate usage',
);
});
});
// ==================== Health Endpoint Tests ====================
describe('health', () => {
it('should return ready status when service is configured', async () => {
aiService.isServiceReady.mockReturnValue(true);
const result = await controller.health();
expect(result.status).toBe('ready');
expect(result.timestamp).toBeDefined();
expect(aiService.isServiceReady).toHaveBeenCalledTimes(1);
});
it('should return not_configured status when service is not ready', async () => {
aiService.isServiceReady.mockReturnValue(false);
const result = await controller.health();
expect(result.status).toBe('not_configured');
expect(result.timestamp).toBeDefined();
});
it('should include ISO timestamp', async () => {
aiService.isServiceReady.mockReturnValue(true);
const result = await controller.health();
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('should return valid timestamp format', async () => {
aiService.isServiceReady.mockReturnValue(true);
const result = await controller.health();
const timestamp = new Date(result.timestamp);
expect(timestamp).toBeInstanceOf(Date);
expect(isNaN(timestamp.getTime())).toBe(false);
});
});
// ==================== Edge Cases and Error Handling ====================
describe('Edge Cases', () => {
it('should handle empty messages array in chat', async () => {
const emptyDto: ChatRequestDto = { messages: [] };
aiService.chat.mockRejectedValue(new BadRequestException('Messages cannot be empty'));
await expect(controller.chat(mockUser, emptyDto)).rejects.toThrow(BadRequestException);
});
it('should handle very long message content', async () => {
const longContent = 'a'.repeat(100000);
const longDto: ChatRequestDto = {
messages: [{ role: 'user', content: longContent }],
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, longDto);
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, longDto);
});
it('should handle special characters in message content', async () => {
const specialDto: ChatRequestDto = {
messages: [{ role: 'user', content: '!@#$%^&*()_+{}|:"<>?\n\t\r' }],
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, specialDto);
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, specialDto);
});
it('should handle unicode characters in message content', async () => {
const unicodeDto: ChatRequestDto = {
messages: [{ role: 'user', content: 'Hello! Hola! Bonjour! Guten Tag!' }],
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(mockUser, unicodeDto);
expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, unicodeDto);
});
it('should handle large page numbers in getUsage', async () => {
aiService.getUsageHistory.mockResolvedValue({ data: [], total: 100 });
const result = await controller.getUsage(mockUser, 1000, 20);
expect(result.data).toHaveLength(0);
expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1000, 20);
});
it('should handle config update with empty object', async () => {
const emptyDto: UpdateAIConfigDto = {};
aiService.updateConfig.mockResolvedValue(mockConfig as any);
const result = await controller.updateConfig(mockUser, emptyDto);
expect(result).toEqual(mockConfig);
expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, emptyDto);
});
});
// ==================== Different User Contexts ====================
describe('User Context Handling', () => {
it('should use correct tenant_id from user context', async () => {
const differentUser = {
...mockUser,
tenant_id: '550e8400-e29b-41d4-a716-446655440099',
};
aiService.getConfig.mockResolvedValue(mockConfig as any);
await controller.getConfig(differentUser);
expect(aiService.getConfig).toHaveBeenCalledWith(differentUser.tenant_id);
});
it('should use correct user_id in chat', async () => {
const differentUser = {
...mockUser,
id: '550e8400-e29b-41d4-a716-446655440088',
};
const chatDto: ChatRequestDto = {
messages: [{ role: 'user', content: 'Hello' }],
};
aiService.chat.mockResolvedValue(mockChatResponse);
await controller.chat(differentUser, chatDto);
expect(aiService.chat).toHaveBeenCalledWith(
differentUser.tenant_id,
differentUser.id,
chatDto,
);
});
it('should isolate usage between tenants', async () => {
const tenant1 = { ...mockUser, tenant_id: 'tenant-1' };
const tenant2 = { ...mockUser, tenant_id: 'tenant-2' };
aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats);
await controller.getCurrentUsage(tenant1);
await controller.getCurrentUsage(tenant2);
expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith('tenant-1');
expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith('tenant-2');
});
});
});

View File

@ -0,0 +1,336 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { AIService } from '../services/ai.service';
import { AIConfig, AIProvider } from '../entities/ai-config.entity';
import { AIUsage, UsageStatus } from '../entities/ai-usage.entity';
import { OpenRouterClient } from '../clients/openrouter.client';
describe('AIService', () => {
let service: AIService;
let configRepo: jest.Mocked<Repository<AIConfig>>;
let usageRepo: jest.Mocked<Repository<AIUsage>>;
let openRouterClient: jest.Mocked<OpenRouterClient>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
const mockConfig = {
id: 'config-001',
tenant_id: mockTenantId,
provider: AIProvider.OPENROUTER,
default_model: 'anthropic/claude-3-haiku',
temperature: 0.7,
max_tokens: 2048,
is_enabled: true,
system_prompt: 'You are a helpful assistant.',
allow_custom_prompts: true,
log_conversations: false,
settings: {},
created_at: new Date(),
updated_at: new Date(),
} as AIConfig;
const mockUsage: Partial<AIUsage> = {
id: 'usage-001',
tenant_id: mockTenantId,
user_id: mockUserId,
provider: AIProvider.OPENROUTER,
model: 'anthropic/claude-3-haiku',
status: UsageStatus.COMPLETED,
input_tokens: 100,
output_tokens: 50,
cost_input: 0.000025,
cost_output: 0.0000625,
latency_ms: 500,
};
const mockChatResponse = {
id: 'gen-001',
model: 'anthropic/claude-3-haiku',
choices: [
{
index: 0,
message: { role: 'assistant' as const, content: 'Hello! How can I help you?' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
},
created: Date.now(),
};
beforeEach(async () => {
const mockConfigRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockUsageRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
findAndCount: jest.fn(),
createQueryBuilder: jest.fn(),
};
const mockOpenRouterClient = {
isReady: jest.fn(),
chatCompletion: jest.fn(),
getModels: jest.fn(),
calculateCost: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AIService,
{ provide: getRepositoryToken(AIConfig), useValue: mockConfigRepo },
{ provide: getRepositoryToken(AIUsage), useValue: mockUsageRepo },
{ provide: OpenRouterClient, useValue: mockOpenRouterClient },
],
}).compile();
service = module.get<AIService>(AIService);
configRepo = module.get(getRepositoryToken(AIConfig));
usageRepo = module.get(getRepositoryToken(AIUsage));
openRouterClient = module.get(OpenRouterClient);
});
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
// ==================== Configuration Tests ====================
describe('getConfig', () => {
it('should return existing config', async () => {
configRepo.findOne.mockResolvedValue(mockConfig);
const result = await service.getConfig(mockTenantId);
expect(result).toEqual(mockConfig);
expect(configRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: mockTenantId },
});
});
it('should create default config if not exists', async () => {
configRepo.findOne.mockResolvedValue(null);
configRepo.create.mockReturnValue(mockConfig);
configRepo.save.mockResolvedValue(mockConfig);
const result = await service.getConfig(mockTenantId);
expect(result).toEqual(mockConfig);
expect(configRepo.create).toHaveBeenCalled();
expect(configRepo.save).toHaveBeenCalled();
});
});
describe('updateConfig', () => {
it('should update config successfully', async () => {
configRepo.findOne.mockResolvedValue(mockConfig);
configRepo.save.mockResolvedValue({
...mockConfig,
temperature: 0.9,
max_tokens: 4096,
} as AIConfig);
const result = await service.updateConfig(mockTenantId, {
temperature: 0.9,
max_tokens: 4096,
});
expect(result.temperature).toBe(0.9);
expect(result.max_tokens).toBe(4096);
expect(configRepo.save).toHaveBeenCalled();
});
it('should update system prompt', async () => {
configRepo.findOne.mockResolvedValue(mockConfig);
configRepo.save.mockResolvedValue({
...mockConfig,
system_prompt: 'New prompt',
} as AIConfig);
const result = await service.updateConfig(mockTenantId, {
system_prompt: 'New prompt',
});
expect(result.system_prompt).toBe('New prompt');
});
it('should disable AI features', async () => {
configRepo.findOne.mockResolvedValue(mockConfig);
configRepo.save.mockResolvedValue({
...mockConfig,
is_enabled: false,
} as AIConfig);
const result = await service.updateConfig(mockTenantId, {
is_enabled: false,
});
expect(result.is_enabled).toBe(false);
});
});
// ==================== Chat Tests ====================
describe('chat', () => {
const chatDto = {
messages: [{ role: 'user' as const, content: 'Hello' }],
};
it('should throw when AI is disabled for tenant', async () => {
configRepo.findOne.mockResolvedValue({
...mockConfig,
is_enabled: false,
});
await expect(service.chat(mockTenantId, mockUserId, chatDto)).rejects.toThrow(
BadRequestException,
);
});
it('should throw when service not configured', async () => {
configRepo.findOne.mockResolvedValue(mockConfig);
openRouterClient.isReady.mockReturnValue(false);
await expect(service.chat(mockTenantId, mockUserId, chatDto)).rejects.toThrow(
BadRequestException,
);
});
});
// ==================== Models Tests ====================
describe('getModels', () => {
it('should return available models', async () => {
const models = [
{
id: 'anthropic/claude-3-haiku',
name: 'Claude 3 Haiku',
provider: 'anthropic',
context_length: 200000,
pricing: { prompt: 0.25, completion: 1.25 },
},
];
openRouterClient.getModels.mockResolvedValue(models);
const result = await service.getModels();
expect(result).toEqual(models);
expect(openRouterClient.getModels).toHaveBeenCalled();
});
});
// ==================== Usage Stats Tests ====================
describe('getCurrentMonthUsage', () => {
it('should return usage statistics', async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({
request_count: '10',
total_input_tokens: '1000',
total_output_tokens: '500',
total_tokens: '1500',
total_cost: '0.05',
avg_latency_ms: '450',
}),
};
usageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const result = await service.getCurrentMonthUsage(mockTenantId);
expect(result.request_count).toBe(10);
expect(result.total_input_tokens).toBe(1000);
expect(result.total_output_tokens).toBe(500);
expect(result.total_tokens).toBe(1500);
expect(result.total_cost).toBe(0.05);
expect(result.avg_latency_ms).toBe(450);
});
it('should return zero values for new tenant', async () => {
const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({
request_count: '0',
total_input_tokens: '0',
total_output_tokens: '0',
total_tokens: '0',
total_cost: '0',
avg_latency_ms: '0',
}),
};
usageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const result = await service.getCurrentMonthUsage(mockTenantId);
expect(result.request_count).toBe(0);
expect(result.total_cost).toBe(0);
});
});
describe('getUsageHistory', () => {
it('should return paginated usage history', async () => {
usageRepo.findAndCount.mockResolvedValue([[mockUsage as AIUsage], 1]);
const result = await service.getUsageHistory(mockTenantId, 1, 20);
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(usageRepo.findAndCount).toHaveBeenCalledWith({
where: { tenant_id: mockTenantId },
order: { created_at: 'DESC' },
skip: 0,
take: 20,
});
});
it('should handle pagination correctly', async () => {
usageRepo.findAndCount.mockResolvedValue([[], 100]);
const result = await service.getUsageHistory(mockTenantId, 5, 10);
expect(usageRepo.findAndCount).toHaveBeenCalledWith(
expect.objectContaining({
skip: 40,
take: 10,
}),
);
expect(result.total).toBe(100);
});
});
// ==================== Health Check Tests ====================
describe('isServiceReady', () => {
it('should return true when client is ready', () => {
openRouterClient.isReady.mockReturnValue(true);
expect(service.isServiceReady()).toBe(true);
});
it('should return false when client is not ready', () => {
openRouterClient.isReady.mockReturnValue(false);
expect(service.isServiceReady()).toBe(false);
});
});
});

View File

@ -0,0 +1,120 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AIService } from './services';
import {
ChatRequestDto,
ChatResponseDto,
UpdateAIConfigDto,
AIConfigResponseDto,
UsageStatsDto,
AIModelDto,
} from './dto';
interface RequestUser {
id: string;
tenant_id: string;
email: string;
role: string;
}
@ApiTags('ai')
@Controller('ai')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class AIController {
constructor(private readonly aiService: AIService) {}
// ==================== Chat ====================
@Post('chat')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send chat completion request' })
@ApiResponse({ status: 200, description: 'Chat response', type: ChatResponseDto })
@ApiResponse({ status: 400, description: 'Bad request or AI disabled' })
async chat(
@CurrentUser() user: RequestUser,
@Body() dto: ChatRequestDto,
): Promise<ChatResponseDto> {
return this.aiService.chat(user.tenant_id, user.id, dto);
}
// ==================== Models ====================
@Get('models')
@ApiOperation({ summary: 'List available AI models' })
@ApiResponse({ status: 200, description: 'List of models', type: [AIModelDto] })
async getModels(): Promise<AIModelDto[]> {
return this.aiService.getModels();
}
// ==================== Configuration ====================
@Get('config')
@ApiOperation({ summary: 'Get AI configuration for tenant' })
@ApiResponse({ status: 200, description: 'AI configuration', type: AIConfigResponseDto })
async getConfig(@CurrentUser() user: RequestUser): Promise<AIConfigResponseDto> {
const config = await this.aiService.getConfig(user.tenant_id);
return config as AIConfigResponseDto;
}
@Patch('config')
@ApiOperation({ summary: 'Update AI configuration' })
@ApiResponse({ status: 200, description: 'Updated configuration', type: AIConfigResponseDto })
async updateConfig(
@CurrentUser() user: RequestUser,
@Body() dto: UpdateAIConfigDto,
): Promise<AIConfigResponseDto> {
const config = await this.aiService.updateConfig(user.tenant_id, dto);
return config as AIConfigResponseDto;
}
// ==================== Usage ====================
@Get('usage')
@ApiOperation({ summary: 'Get usage history' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getUsage(
@CurrentUser() user: RequestUser,
@Query('page') page = 1,
@Query('limit') limit = 20,
) {
return this.aiService.getUsageHistory(user.tenant_id, page, limit);
}
@Get('usage/current')
@ApiOperation({ summary: 'Get current month usage stats' })
@ApiResponse({ status: 200, description: 'Usage statistics', type: UsageStatsDto })
async getCurrentUsage(@CurrentUser() user: RequestUser): Promise<UsageStatsDto> {
return this.aiService.getCurrentMonthUsage(user.tenant_id);
}
// ==================== Health ====================
@Get('health')
@ApiOperation({ summary: 'Check AI service health' })
async health() {
return {
status: this.aiService.isServiceReady() ? 'ready' : 'not_configured',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { AIController } from './ai.controller';
import { AIService } from './services';
import { OpenRouterClient } from './clients';
import { AIConfig, AIUsage } from './entities';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([AIConfig, AIUsage]),
],
controllers: [AIController],
providers: [AIService, OpenRouterClient],
exports: [AIService],
})
export class AIModule {}

View File

@ -0,0 +1 @@
export { OpenRouterClient } from './openrouter.client';

View File

@ -0,0 +1,234 @@
import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChatRequestDto, ChatResponseDto, AIModelDto } from '../dto';
interface OpenRouterRequest {
model: string;
messages: { role: string; content: string }[];
temperature?: number;
max_tokens?: number;
top_p?: number;
stream?: boolean;
}
interface OpenRouterResponse {
id: string;
model: string;
choices: {
index: number;
message: { role: string; content: string };
finish_reason: string;
}[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
created: number;
}
interface OpenRouterModel {
id: string;
name: string;
description?: string;
context_length: number;
pricing: {
prompt: string;
completion: string;
};
}
@Injectable()
export class OpenRouterClient implements OnModuleInit {
private readonly logger = new Logger(OpenRouterClient.name);
private apiKey: string;
private readonly baseUrl = 'https://openrouter.ai/api/v1';
private readonly timeout: number;
private isConfigured = false;
constructor(private readonly configService: ConfigService) {
this.timeout = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
}
onModuleInit() {
this.apiKey = this.configService.get<string>('OPENROUTER_API_KEY', '');
if (!this.apiKey) {
this.logger.warn('OpenRouter API key not configured. AI features will be disabled.');
return;
}
this.isConfigured = true;
this.logger.log('OpenRouter client initialized');
}
isReady(): boolean {
return this.isConfigured;
}
private ensureConfigured(): void {
if (!this.isConfigured) {
throw new BadRequestException('AI service is not configured. Please set OPENROUTER_API_KEY.');
}
}
async chatCompletion(
dto: ChatRequestDto,
defaultModel: string,
defaultTemperature: number,
defaultMaxTokens: number,
): Promise<ChatResponseDto> {
this.ensureConfigured();
const requestBody: OpenRouterRequest = {
model: dto.model || defaultModel,
messages: dto.messages,
temperature: dto.temperature ?? defaultTemperature,
max_tokens: dto.max_tokens ?? defaultMaxTokens,
top_p: dto.top_p ?? 1.0,
stream: false, // For now, no streaming
};
const startTime = Date.now();
try {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
'HTTP-Referer': this.configService.get<string>('APP_URL', 'http://localhost:3001'),
'X-Title': 'Template SaaS',
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
const errorBody = await response.text();
this.logger.error(`OpenRouter API error: ${response.status} - ${errorBody}`);
throw new BadRequestException(`AI request failed: ${response.statusText}`);
}
const data: OpenRouterResponse = await response.json();
const latencyMs = Date.now() - startTime;
this.logger.debug(`Chat completion completed in ${latencyMs}ms, tokens: ${data.usage?.total_tokens}`);
return {
id: data.id,
model: data.model,
choices: data.choices.map((c) => ({
index: c.index,
message: {
role: c.message.role as 'system' | 'user' | 'assistant',
content: c.message.content,
},
finish_reason: c.finish_reason,
})),
usage: {
prompt_tokens: data.usage?.prompt_tokens || 0,
completion_tokens: data.usage?.completion_tokens || 0,
total_tokens: data.usage?.total_tokens || 0,
},
created: data.created,
};
} catch (error) {
if (error.name === 'AbortError') {
throw new BadRequestException('AI request timed out');
}
throw error;
}
}
async getModels(): Promise<AIModelDto[]> {
this.ensureConfigured();
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
throw new BadRequestException('Failed to fetch models');
}
const data = await response.json();
const models: OpenRouterModel[] = data.data || [];
// Filter to popular models
const popularModels = [
'anthropic/claude-3-haiku',
'anthropic/claude-3-sonnet',
'anthropic/claude-3-opus',
'openai/gpt-4-turbo',
'openai/gpt-4',
'openai/gpt-3.5-turbo',
'google/gemini-pro',
'meta-llama/llama-3-70b-instruct',
];
return models
.filter((m) => popularModels.some((p) => m.id.includes(p.split('/')[1])))
.slice(0, 20)
.map((m) => ({
id: m.id,
name: m.name,
provider: m.id.split('/')[0],
context_length: m.context_length,
pricing: {
prompt: parseFloat(m.pricing.prompt) * 1000000, // Per million tokens
completion: parseFloat(m.pricing.completion) * 1000000,
},
}));
} catch (error) {
this.logger.error('Failed to fetch models:', error);
// Return default models if API fails
return [
{
id: 'anthropic/claude-3-haiku',
name: 'Claude 3 Haiku',
provider: 'anthropic',
context_length: 200000,
pricing: { prompt: 0.25, completion: 1.25 },
},
{
id: 'openai/gpt-3.5-turbo',
name: 'GPT-3.5 Turbo',
provider: 'openai',
context_length: 16385,
pricing: { prompt: 0.5, completion: 1.5 },
},
];
}
}
// Calculate cost for a request
calculateCost(
model: string,
inputTokens: number,
outputTokens: number,
): { input: number; output: number; total: number } {
// Approximate pricing per million tokens (in USD)
const pricing: Record<string, { input: number; output: number }> = {
'anthropic/claude-3-haiku': { input: 0.25, output: 1.25 },
'anthropic/claude-3-sonnet': { input: 3.0, output: 15.0 },
'anthropic/claude-3-opus': { input: 15.0, output: 75.0 },
'openai/gpt-4-turbo': { input: 10.0, output: 30.0 },
'openai/gpt-4': { input: 30.0, output: 60.0 },
'openai/gpt-3.5-turbo': { input: 0.5, output: 1.5 },
default: { input: 1.0, output: 2.0 },
};
const modelPricing = pricing[model] || pricing.default;
const inputCost = (inputTokens / 1_000_000) * modelPricing.input;
const outputCost = (outputTokens / 1_000_000) * modelPricing.output;
return {
input: inputCost,
output: outputCost,
total: inputCost + outputCost,
};
}
}

View File

@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsArray,
IsOptional,
IsNumber,
Min,
Max,
ValidateNested,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ChatMessageDto {
@ApiProperty({ description: 'Message role', enum: ['system', 'user', 'assistant'] })
@IsString()
@IsIn(['system', 'user', 'assistant'])
role: 'system' | 'user' | 'assistant';
@ApiProperty({ description: 'Message content' })
@IsString()
content: string;
}
export class ChatRequestDto {
@ApiProperty({ description: 'Array of chat messages', type: [ChatMessageDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages: ChatMessageDto[];
@ApiPropertyOptional({ description: 'Model to use (e.g., anthropic/claude-3-haiku)' })
@IsOptional()
@IsString()
model?: string;
@ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@ApiPropertyOptional({ description: 'Maximum tokens to generate', default: 2048 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(32000)
max_tokens?: number;
@ApiPropertyOptional({ description: 'Top P sampling (0-1)', default: 1.0 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
top_p?: number;
@ApiPropertyOptional({ description: 'Stream response', default: false })
@IsOptional()
stream?: boolean;
}
export class ChatChoiceDto {
@ApiProperty()
index: number;
@ApiProperty()
message: ChatMessageDto;
@ApiProperty()
finish_reason: string;
}
export class UsageDto {
@ApiProperty()
prompt_tokens: number;
@ApiProperty()
completion_tokens: number;
@ApiProperty()
total_tokens: number;
}
export class ChatResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
model: string;
@ApiProperty({ type: [ChatChoiceDto] })
choices: ChatChoiceDto[];
@ApiProperty()
usage: UsageDto;
@ApiProperty()
created: number;
}

View File

@ -0,0 +1,149 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsNumber,
IsBoolean,
Min,
Max,
IsEnum,
IsObject,
} from 'class-validator';
import { AIProvider } from '../entities';
export class UpdateAIConfigDto {
@ApiPropertyOptional({ description: 'AI provider', enum: AIProvider })
@IsOptional()
@IsEnum(AIProvider)
provider?: AIProvider;
@ApiPropertyOptional({ description: 'Default model to use' })
@IsOptional()
@IsString()
default_model?: string;
@ApiPropertyOptional({ description: 'Fallback model if default fails' })
@IsOptional()
@IsString()
fallback_model?: string;
@ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@ApiPropertyOptional({ description: 'Maximum tokens', default: 2048 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(32000)
max_tokens?: number;
@ApiPropertyOptional({ description: 'Default system prompt' })
@IsOptional()
@IsString()
system_prompt?: string;
@ApiPropertyOptional({ description: 'Enable AI features' })
@IsOptional()
@IsBoolean()
is_enabled?: boolean;
@ApiPropertyOptional({ description: 'Allow custom prompts' })
@IsOptional()
@IsBoolean()
allow_custom_prompts?: boolean;
@ApiPropertyOptional({ description: 'Log conversations' })
@IsOptional()
@IsBoolean()
log_conversations?: boolean;
@ApiPropertyOptional({ description: 'Additional settings' })
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}
export class AIConfigResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
tenant_id: string;
@ApiProperty({ enum: AIProvider })
provider: AIProvider;
@ApiProperty()
default_model: string;
@ApiPropertyOptional()
fallback_model?: string;
@ApiProperty()
temperature: number;
@ApiProperty()
max_tokens: number;
@ApiPropertyOptional()
system_prompt?: string;
@ApiProperty()
is_enabled: boolean;
@ApiProperty()
allow_custom_prompts: boolean;
@ApiProperty()
log_conversations: boolean;
@ApiProperty()
created_at: Date;
@ApiProperty()
updated_at: Date;
}
export class UsageStatsDto {
@ApiProperty()
request_count: number;
@ApiProperty()
total_input_tokens: number;
@ApiProperty()
total_output_tokens: number;
@ApiProperty()
total_tokens: number;
@ApiProperty()
total_cost: number;
@ApiProperty()
avg_latency_ms: number;
}
export class AIModelDto {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
provider: string;
@ApiProperty()
context_length: number;
@ApiProperty()
pricing: {
prompt: number;
completion: number;
};
}

View File

@ -0,0 +1,14 @@
export {
ChatMessageDto,
ChatRequestDto,
ChatChoiceDto,
UsageDto,
ChatResponseDto,
} from './chat.dto';
export {
UpdateAIConfigDto,
AIConfigResponseDto,
UsageStatsDto,
AIModelDto,
} from './config.dto';

View File

@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum AIProvider {
OPENROUTER = 'openrouter',
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
GOOGLE = 'google',
}
@Entity({ name: 'configs', schema: 'ai' })
export class AIConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'enum', enum: AIProvider, default: AIProvider.OPENROUTER })
provider: AIProvider;
@Column({ type: 'varchar', length: 100, default: 'anthropic/claude-3-haiku' })
default_model: string;
@Column({ type: 'varchar', length: 100, nullable: true })
fallback_model: string;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 0.7 })
temperature: number;
@Column({ type: 'int', default: 2048 })
max_tokens: number;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 1.0, nullable: true })
top_p: number;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true })
frequency_penalty: number;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true })
presence_penalty: number;
@Column({ type: 'text', nullable: true })
system_prompt: string;
@Column({ type: 'int', nullable: true })
rate_limit_requests_per_minute: number;
@Column({ type: 'int', nullable: true })
rate_limit_tokens_per_minute: number;
@Column({ type: 'int', nullable: true })
rate_limit_tokens_per_month: number;
@Column({ type: 'boolean', default: true })
is_enabled: boolean;
@Column({ type: 'boolean', default: true })
allow_custom_prompts: boolean;
@Column({ type: 'boolean', default: false })
log_conversations: boolean;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
}

View File

@ -0,0 +1,90 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
import { AIProvider } from './ai-config.entity';
export enum AIModelType {
CHAT = 'chat',
COMPLETION = 'completion',
EMBEDDING = 'embedding',
IMAGE = 'image',
}
export enum UsageStatus {
PENDING = 'pending',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
}
@Entity({ name: 'usage', schema: 'ai' })
export class AIUsage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({ type: 'enum', enum: AIProvider })
provider: AIProvider;
@Column({ type: 'varchar', length: 100 })
model: string;
@Column({ type: 'enum', enum: AIModelType, default: AIModelType.CHAT })
model_type: AIModelType;
@Column({ type: 'enum', enum: UsageStatus, default: UsageStatus.PENDING })
status: UsageStatus;
@Column({ type: 'int', default: 0 })
input_tokens: number;
@Column({ type: 'int', default: 0 })
output_tokens: number;
// total_tokens is computed in DB, but we can add it for convenience
get total_tokens(): number {
return this.input_tokens + this.output_tokens;
}
@Column({ type: 'numeric', precision: 12, scale: 6, default: 0 })
cost_input: number;
@Column({ type: 'numeric', precision: 12, scale: 6, default: 0 })
cost_output: number;
get cost_total(): number {
return Number(this.cost_input) + Number(this.cost_output);
}
@Column({ type: 'int', nullable: true })
latency_ms: number;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
started_at: Date;
@Column({ type: 'timestamptz', nullable: true })
completed_at: Date;
@Column({ type: 'varchar', length: 100, nullable: true })
request_id: string;
@Column({ type: 'varchar', length: 50, nullable: true })
endpoint: string;
@Column({ type: 'text', nullable: true })
error_message: string;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
}

View File

@ -0,0 +1,2 @@
export { AIConfig, AIProvider } from './ai-config.entity';
export { AIUsage, AIModelType, UsageStatus } from './ai-usage.entity';

6
src/modules/ai/index.ts Normal file
View File

@ -0,0 +1,6 @@
export { AIModule } from './ai.module';
export { AIController } from './ai.controller';
export { AIService } from './services';
export { OpenRouterClient } from './clients';
export * from './entities';
export * from './dto';

View File

@ -0,0 +1,193 @@
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThanOrEqual } from 'typeorm';
import { AIConfig, AIUsage, UsageStatus, AIProvider } from '../entities';
import { OpenRouterClient } from '../clients';
import {
ChatRequestDto,
ChatResponseDto,
UpdateAIConfigDto,
UsageStatsDto,
AIModelDto,
} from '../dto';
@Injectable()
export class AIService {
private readonly logger = new Logger(AIService.name);
constructor(
@InjectRepository(AIConfig)
private readonly configRepository: Repository<AIConfig>,
@InjectRepository(AIUsage)
private readonly usageRepository: Repository<AIUsage>,
private readonly openRouterClient: OpenRouterClient,
) {}
// ==================== Configuration ====================
async getConfig(tenantId: string): Promise<AIConfig> {
let config = await this.configRepository.findOne({
where: { tenant_id: tenantId },
});
// Create default config if not exists
if (!config) {
config = this.configRepository.create({
tenant_id: tenantId,
provider: AIProvider.OPENROUTER,
default_model: 'anthropic/claude-3-haiku',
temperature: 0.7,
max_tokens: 2048,
is_enabled: true,
});
await this.configRepository.save(config);
}
return config;
}
async updateConfig(tenantId: string, dto: UpdateAIConfigDto): Promise<AIConfig> {
const config = await this.getConfig(tenantId);
// Update fields
Object.assign(config, dto);
config.updated_at = new Date();
return this.configRepository.save(config);
}
// ==================== Chat Completion ====================
async chat(
tenantId: string,
userId: string,
dto: ChatRequestDto,
): Promise<ChatResponseDto> {
const config = await this.getConfig(tenantId);
if (!config.is_enabled) {
throw new BadRequestException('AI features are disabled for this tenant');
}
if (!this.openRouterClient.isReady()) {
throw new BadRequestException('AI service is not configured');
}
// Create usage record
const usage = this.usageRepository.create({
tenant_id: tenantId,
user_id: userId,
provider: config.provider,
model: dto.model || config.default_model,
status: UsageStatus.PENDING,
started_at: new Date(),
});
await this.usageRepository.save(usage);
try {
// Apply system prompt if configured and not provided
let messages = [...dto.messages];
if (config.system_prompt && !messages.some((m) => m.role === 'system')) {
messages = [{ role: 'system', content: config.system_prompt }, ...messages];
}
const startTime = Date.now();
const response = await this.openRouterClient.chatCompletion(
{ ...dto, messages },
config.default_model,
config.temperature,
config.max_tokens,
);
const latencyMs = Date.now() - startTime;
// Calculate costs
const costs = this.openRouterClient.calculateCost(
response.model,
response.usage.prompt_tokens,
response.usage.completion_tokens,
);
// Update usage record
usage.status = UsageStatus.COMPLETED;
usage.model = response.model;
usage.input_tokens = response.usage.prompt_tokens;
usage.output_tokens = response.usage.completion_tokens;
usage.cost_input = costs.input;
usage.cost_output = costs.output;
usage.latency_ms = latencyMs;
usage.completed_at = new Date();
usage.request_id = response.id;
usage.endpoint = 'chat';
await this.usageRepository.save(usage);
return response;
} catch (error) {
// Record failure
usage.status = UsageStatus.FAILED;
usage.error_message = error.message;
usage.completed_at = new Date();
await this.usageRepository.save(usage);
throw error;
}
}
// ==================== Models ====================
async getModels(): Promise<AIModelDto[]> {
return this.openRouterClient.getModels();
}
// ==================== Usage Stats ====================
async getCurrentMonthUsage(tenantId: string): Promise<UsageStatsDto> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const result = await this.usageRepository
.createQueryBuilder('usage')
.select('COUNT(*)', 'request_count')
.addSelect('COALESCE(SUM(usage.input_tokens), 0)', 'total_input_tokens')
.addSelect('COALESCE(SUM(usage.output_tokens), 0)', 'total_output_tokens')
.addSelect('COALESCE(SUM(usage.input_tokens + usage.output_tokens), 0)', 'total_tokens')
.addSelect('COALESCE(SUM(usage.cost_input + usage.cost_output), 0)', 'total_cost')
.addSelect('COALESCE(AVG(usage.latency_ms), 0)', 'avg_latency_ms')
.where('usage.tenant_id = :tenantId', { tenantId })
.andWhere('usage.status = :status', { status: UsageStatus.COMPLETED })
.andWhere('usage.created_at >= :startOfMonth', { startOfMonth })
.getRawOne();
return {
request_count: parseInt(result.request_count, 10),
total_input_tokens: parseInt(result.total_input_tokens, 10),
total_output_tokens: parseInt(result.total_output_tokens, 10),
total_tokens: parseInt(result.total_tokens, 10),
total_cost: parseFloat(result.total_cost),
avg_latency_ms: parseFloat(result.avg_latency_ms),
};
}
async getUsageHistory(
tenantId: string,
page = 1,
limit = 20,
): Promise<{ data: AIUsage[]; total: number }> {
const [data, total] = await this.usageRepository.findAndCount({
where: { tenant_id: tenantId },
order: { created_at: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
// ==================== Health Check ====================
isServiceReady(): boolean {
return this.openRouterClient.isReady();
}
}

View File

@ -0,0 +1 @@
export { AIService } from './ai.service';

View File

@ -0,0 +1,111 @@
import {
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
} from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service';
import {
AnalyticsQueryDto,
UserMetricsDto,
BillingMetricsDto,
UsageMetricsDto,
AnalyticsSummaryDto,
TrendDataDto,
} from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequestUser } from '../auth/strategies/jwt.strategy';
@ApiTags('analytics')
@Controller('analytics')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('users')
@ApiOperation({
summary: 'Get user metrics',
description: 'Returns user-related metrics including total users, active users, growth rate, and retention rate for the specified period.',
})
@ApiOkResponse({
description: 'User metrics retrieved successfully',
type: UserMetricsDto,
})
async getUserMetrics(
@CurrentUser() user: RequestUser,
@Query() query: AnalyticsQueryDto,
): Promise<UserMetricsDto> {
return this.analyticsService.getUserMetrics(user.tenant_id, query.period || '30d');
}
@Get('billing')
@ApiOperation({
summary: 'Get billing metrics',
description: 'Returns billing-related metrics including subscription status, revenue, invoices, and revenue trends for the specified period.',
})
@ApiOkResponse({
description: 'Billing metrics retrieved successfully',
type: BillingMetricsDto,
})
async getBillingMetrics(
@CurrentUser() user: RequestUser,
@Query() query: AnalyticsQueryDto,
): Promise<BillingMetricsDto> {
return this.analyticsService.getBillingMetrics(user.tenant_id, query.period || '30d');
}
@Get('usage')
@ApiOperation({
summary: 'Get usage metrics',
description: 'Returns usage-related metrics including total actions, active users count, actions by type, peak usage hours, and more for the specified period.',
})
@ApiOkResponse({
description: 'Usage metrics retrieved successfully',
type: UsageMetricsDto,
})
async getUsageMetrics(
@CurrentUser() user: RequestUser,
@Query() query: AnalyticsQueryDto,
): Promise<UsageMetricsDto> {
return this.analyticsService.getUsageMetrics(user.tenant_id, query.period || '30d');
}
@Get('summary')
@ApiOperation({
summary: 'Get analytics summary',
description: 'Returns a summary of key performance indicators (KPIs) including total users, MRR, actions this month, and growth metrics.',
})
@ApiOkResponse({
description: 'Analytics summary retrieved successfully',
type: AnalyticsSummaryDto,
})
async getSummary(
@CurrentUser() user: RequestUser,
): Promise<AnalyticsSummaryDto> {
return this.analyticsService.getSummary(user.tenant_id);
}
@Get('trends')
@ApiOperation({
summary: 'Get trend data',
description: 'Returns historical trend data for new users, actions, logins, and revenue over the specified period.',
})
@ApiOkResponse({
description: 'Trend data retrieved successfully',
type: [TrendDataDto],
})
async getTrends(
@CurrentUser() user: RequestUser,
@Query() query: AnalyticsQueryDto,
): Promise<TrendDataDto[]> {
return this.analyticsService.getTrends(user.tenant_id, query.period || '30d');
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { User } from '../auth/entities/user.entity';
import { Subscription } from '../billing/entities/subscription.entity';
import { Invoice } from '../billing/entities/invoice.entity';
import { AuditLog } from '../audit/entities/audit-log.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User, Subscription, Invoice, AuditLog]),
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@ -0,0 +1,513 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, MoreThanOrEqual } from 'typeorm';
import { User } from '../auth/entities/user.entity';
import { Subscription, SubscriptionStatus } from '../billing/entities/subscription.entity';
import { Invoice, InvoiceStatus } from '../billing/entities/invoice.entity';
import { AuditLog, AuditAction } from '../audit/entities/audit-log.entity';
import {
AnalyticsPeriod,
UserMetricsDto,
BillingMetricsDto,
UsageMetricsDto,
AnalyticsSummaryDto,
TrendDataDto,
} from './dto';
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Subscription)
private readonly subscriptionRepo: Repository<Subscription>,
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>,
@InjectRepository(AuditLog)
private readonly auditLogRepo: Repository<AuditLog>,
) {}
/**
* Get the date range for a given period
*/
private getDateRange(period: AnalyticsPeriod): { startDate: Date; endDate: Date } {
const endDate = new Date();
const startDate = new Date();
switch (period) {
case '7d':
startDate.setDate(startDate.getDate() - 7);
break;
case '30d':
startDate.setDate(startDate.getDate() - 30);
break;
case '90d':
startDate.setDate(startDate.getDate() - 90);
break;
case '1y':
startDate.setFullYear(startDate.getFullYear() - 1);
break;
}
return { startDate, endDate };
}
/**
* Get previous period date range for comparison
*/
private getPreviousPeriodRange(period: AnalyticsPeriod): { startDate: Date; endDate: Date } {
const { startDate: currentStart, endDate: currentEnd } = this.getDateRange(period);
const duration = currentEnd.getTime() - currentStart.getTime();
return {
startDate: new Date(currentStart.getTime() - duration),
endDate: new Date(currentStart.getTime()),
};
}
/**
* Get user metrics for a tenant
*/
async getUserMetrics(tenantId: string, period: AnalyticsPeriod): Promise<UserMetricsDto> {
const { startDate, endDate } = this.getDateRange(period);
const { startDate: prevStart, endDate: prevEnd } = this.getPreviousPeriodRange(period);
// Total users
const totalUsers = await this.userRepo.count({
where: { tenant_id: tenantId },
});
// Users by status
const activeUsers = await this.userRepo.count({
where: { tenant_id: tenantId, status: 'active' },
});
const inactiveUsers = await this.userRepo.count({
where: { tenant_id: tenantId, status: 'inactive' },
});
const pendingVerification = await this.userRepo.count({
where: { tenant_id: tenantId, status: 'pending_verification' },
});
const suspendedUsers = await this.userRepo.count({
where: { tenant_id: tenantId, status: 'suspended' },
});
// New users in period
const newUsers = await this.userRepo.count({
where: {
tenant_id: tenantId,
created_at: Between(startDate, endDate),
},
});
// New users in previous period (for growth rate)
const prevNewUsers = await this.userRepo.count({
where: {
tenant_id: tenantId,
created_at: Between(prevStart, prevEnd),
},
});
// Users who logged in during the period
const usersLoggedIn = await this.userRepo.count({
where: {
tenant_id: tenantId,
last_login_at: Between(startDate, endDate),
},
});
// Calculate growth rate
const growthRate = prevNewUsers > 0
? ((newUsers - prevNewUsers) / prevNewUsers) * 100
: newUsers > 0 ? 100 : 0;
// Calculate retention rate (users who logged in / active users)
const retentionRate = activeUsers > 0
? (usersLoggedIn / activeUsers) * 100
: 0;
return {
totalUsers,
activeUsers,
inactiveUsers,
pendingVerification,
suspendedUsers,
newUsers,
usersLoggedIn,
growthRate: Math.round(growthRate * 100) / 100,
retentionRate: Math.round(retentionRate * 100) / 100,
};
}
/**
* Get billing metrics for a tenant
*/
async getBillingMetrics(tenantId: string, period: AnalyticsPeriod): Promise<BillingMetricsDto> {
const { startDate, endDate } = this.getDateRange(period);
const { startDate: prevStart, endDate: prevEnd } = this.getPreviousPeriodRange(period);
// Get current subscription
const subscription = await this.subscriptionRepo.findOne({
where: { tenant_id: tenantId },
relations: ['plan'],
order: { created_at: 'DESC' },
});
// Get paid invoices in period
const paidInvoices = await this.invoiceRepo.find({
where: {
tenant_id: tenantId,
status: InvoiceStatus.PAID,
paid_at: Between(startDate, endDate),
},
});
// Get paid invoices in previous period
const prevPaidInvoices = await this.invoiceRepo.find({
where: {
tenant_id: tenantId,
status: InvoiceStatus.PAID,
paid_at: Between(prevStart, prevEnd),
},
});
// Get pending invoices
const pendingInvoicesList = await this.invoiceRepo.find({
where: {
tenant_id: tenantId,
status: InvoiceStatus.OPEN,
},
});
// Calculate totals
const totalRevenue = paidInvoices.reduce((sum, inv) => sum + Number(inv.total), 0);
const prevRevenue = prevPaidInvoices.reduce((sum, inv) => sum + Number(inv.total), 0);
const totalDue = pendingInvoicesList.reduce((sum, inv) => sum + Number(inv.total), 0);
const averageInvoiceAmount = paidInvoices.length > 0
? totalRevenue / paidInvoices.length
: 0;
// Calculate days until expiration
const daysUntilExpiration = subscription
? Math.max(0, Math.ceil(
(new Date(subscription.current_period_end).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
))
: 0;
// Revenue trend
const revenueTrend = prevRevenue > 0
? ((totalRevenue - prevRevenue) / prevRevenue) * 100
: totalRevenue > 0 ? 100 : 0;
return {
subscriptionStatus: subscription?.status || 'none',
currentPlan: subscription?.plan?.name || null,
totalRevenue: Math.round(totalRevenue * 100) / 100,
paidInvoices: paidInvoices.length,
pendingInvoices: pendingInvoicesList.length,
totalDue: Math.round(totalDue * 100) / 100,
averageInvoiceAmount: Math.round(averageInvoiceAmount * 100) / 100,
daysUntilExpiration,
isTrialPeriod: subscription?.status === SubscriptionStatus.TRIAL,
revenueTrend: Math.round(revenueTrend * 100) / 100,
};
}
/**
* Get usage metrics for a tenant
*/
async getUsageMetrics(tenantId: string, period: AnalyticsPeriod): Promise<UsageMetricsDto> {
const { startDate, endDate } = this.getDateRange(period);
// Total actions in period
const totalActions = await this.auditLogRepo.count({
where: {
tenant_id: tenantId,
created_at: Between(startDate, endDate),
},
});
// Unique active users
const activeUsersQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select('COUNT(DISTINCT audit.user_id)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.andWhere('audit.user_id IS NOT NULL')
.getRawOne();
const activeUsersCount = parseInt(activeUsersQuery?.count || '0', 10);
// Actions by type
const actionsByTypeQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select('audit.action', 'action')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('audit.action')
.getRawMany();
const actionsByType: Record<string, number> = {};
actionsByTypeQuery.forEach((row) => {
actionsByType[row.action] = parseInt(row.count, 10);
});
// Actions by entity type
const actionsByEntityQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select('audit.entity_type', 'entity_type')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('audit.entity_type')
.getRawMany();
const actionsByEntity: Record<string, number> = {};
actionsByEntityQuery.forEach((row) => {
actionsByEntity[row.entity_type] = parseInt(row.count, 10);
});
// Peak usage hour
const peakHourQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select('EXTRACT(HOUR FROM audit.created_at)', 'hour')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('EXTRACT(HOUR FROM audit.created_at)')
.orderBy('count', 'DESC')
.limit(1)
.getRawOne();
const peakUsageHour = peakHourQuery ? parseInt(peakHourQuery.hour, 10) : 0;
// Login count
const loginCount = await this.auditLogRepo.count({
where: {
tenant_id: tenantId,
action: AuditAction.LOGIN,
created_at: Between(startDate, endDate),
},
});
// Average actions per user
const averageActionsPerUser = activeUsersCount > 0
? totalActions / activeUsersCount
: 0;
// Estimate average session duration (based on time between first and last action per user per day)
// This is a simplified estimation
const avgSessionDuration = loginCount > 0 && totalActions > 0
? Math.round((totalActions / loginCount) * 2) // Rough estimate: 2 minutes per action on average
: 0;
// Top entities
const topEntitiesQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select('audit.entity_type', 'entityType')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('audit.entity_type')
.orderBy('count', 'DESC')
.limit(5)
.getRawMany();
const topEntities = topEntitiesQuery.map((row) => ({
entityType: row.entityType,
count: parseInt(row.count, 10),
}));
return {
totalActions,
activeUsersCount,
actionsByType,
actionsByEntity,
averageActionsPerUser: Math.round(averageActionsPerUser * 100) / 100,
peakUsageHour,
loginCount,
avgSessionDuration,
topEntities,
};
}
/**
* Get summary KPIs for a tenant
*/
async getSummary(tenantId: string): Promise<AnalyticsSummaryDto> {
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
// Users
const totalUsers = await this.userRepo.count({
where: { tenant_id: tenantId },
});
const activeUsers = await this.userRepo.count({
where: { tenant_id: tenantId, status: 'active' },
});
// Users last month
const usersLastMonth = await this.userRepo.count({
where: {
tenant_id: tenantId,
created_at: Between(lastMonthStart, lastMonthEnd),
},
});
const usersThisMonth = await this.userRepo.count({
where: {
tenant_id: tenantId,
created_at: MoreThanOrEqual(thisMonthStart),
},
});
const userGrowth = usersLastMonth > 0
? ((usersThisMonth - usersLastMonth) / usersLastMonth) * 100
: usersThisMonth > 0 ? 100 : 0;
// Subscription and MRR
const subscription = await this.subscriptionRepo.findOne({
where: { tenant_id: tenantId },
relations: ['plan'],
order: { created_at: 'DESC' },
});
const mrr = subscription?.plan?.price_monthly || 0;
// Actions
const actionsThisMonth = await this.auditLogRepo.count({
where: {
tenant_id: tenantId,
created_at: MoreThanOrEqual(thisMonthStart),
},
});
const actionsLastMonth = await this.auditLogRepo.count({
where: {
tenant_id: tenantId,
created_at: Between(lastMonthStart, lastMonthEnd),
},
});
const actionsGrowth = actionsLastMonth > 0
? ((actionsThisMonth - actionsLastMonth) / actionsLastMonth) * 100
: actionsThisMonth > 0 ? 100 : 0;
// Pending amount
const pendingInvoices = await this.invoiceRepo.find({
where: {
tenant_id: tenantId,
status: InvoiceStatus.OPEN,
},
});
const pendingAmount = pendingInvoices.reduce((sum, inv) => sum + Number(inv.total), 0);
return {
totalUsers,
activeUsers,
mrr: Number(mrr),
subscriptionStatus: subscription?.status || 'none',
actionsThisMonth,
actionsGrowth: Math.round(actionsGrowth * 100) / 100,
userGrowth: Math.round(userGrowth * 100) / 100,
pendingAmount: Math.round(pendingAmount * 100) / 100,
};
}
/**
* Get trend data for various metrics
*/
async getTrends(tenantId: string, period: AnalyticsPeriod): Promise<TrendDataDto[]> {
const { startDate, endDate } = this.getDateRange(period);
// Determine date truncation based on period
const truncate = period === '7d' ? 'day' : period === '30d' ? 'day' : period === '90d' ? 'week' : 'month';
// User registration trend
const userTrendQuery = await this.userRepo
.createQueryBuilder('user')
.select(`DATE_TRUNC('${truncate}', user.created_at)`, 'date')
.addSelect('COUNT(*)', 'value')
.where('user.tenant_id = :tenantId', { tenantId })
.andWhere('user.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy(`DATE_TRUNC('${truncate}', user.created_at)`)
.orderBy('date', 'ASC')
.getRawMany();
const userTrend: TrendDataDto = {
metric: 'new_users',
data: userTrendQuery.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
value: parseInt(row.value, 10),
})),
};
// Actions trend
const actionsTrendQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select(`DATE_TRUNC('${truncate}', audit.created_at)`, 'date')
.addSelect('COUNT(*)', 'value')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy(`DATE_TRUNC('${truncate}', audit.created_at)`)
.orderBy('date', 'ASC')
.getRawMany();
const actionsTrend: TrendDataDto = {
metric: 'actions',
data: actionsTrendQuery.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
value: parseInt(row.value, 10),
})),
};
// Login trend
const loginTrendQuery = await this.auditLogRepo
.createQueryBuilder('audit')
.select(`DATE_TRUNC('${truncate}', audit.created_at)`, 'date')
.addSelect('COUNT(*)', 'value')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.action = :action', { action: AuditAction.LOGIN })
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy(`DATE_TRUNC('${truncate}', audit.created_at)`)
.orderBy('date', 'ASC')
.getRawMany();
const loginTrend: TrendDataDto = {
metric: 'logins',
data: loginTrendQuery.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
value: parseInt(row.value, 10),
})),
};
// Revenue trend (based on paid invoices)
const revenueTrendQuery = await this.invoiceRepo
.createQueryBuilder('invoice')
.select(`DATE_TRUNC('${truncate}', invoice.paid_at)`, 'date')
.addSelect('SUM(invoice.total)', 'value')
.where('invoice.tenant_id = :tenantId', { tenantId })
.andWhere('invoice.status = :status', { status: InvoiceStatus.PAID })
.andWhere('invoice.paid_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy(`DATE_TRUNC('${truncate}', invoice.paid_at)`)
.orderBy('date', 'ASC')
.getRawMany();
const revenueTrend: TrendDataDto = {
metric: 'revenue',
data: revenueTrendQuery.map((row) => ({
date: new Date(row.date).toISOString().split('T')[0],
value: Math.round(parseFloat(row.value || '0') * 100) / 100,
})),
};
return [userTrend, actionsTrend, loginTrend, revenueTrend];
}
}

View File

@ -0,0 +1,15 @@
import { IsOptional, IsIn } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '1y';
export class AnalyticsQueryDto {
@ApiPropertyOptional({
description: 'Period for analytics data',
enum: ['7d', '30d', '90d', '1y'],
default: '30d',
})
@IsOptional()
@IsIn(['7d', '30d', '90d', '1y'])
period?: AnalyticsPeriod = '30d';
}

View File

@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
export class AnalyticsSummaryDto {
@ApiProperty({ description: 'Total users' })
totalUsers: number;
@ApiProperty({ description: 'Active users' })
activeUsers: number;
@ApiProperty({ description: 'Current MRR (Monthly Recurring Revenue)' })
mrr: number;
@ApiProperty({ description: 'Subscription status' })
subscriptionStatus: string;
@ApiProperty({ description: 'Total actions this month' })
actionsThisMonth: number;
@ApiProperty({ description: 'Actions growth percentage' })
actionsGrowth: number;
@ApiProperty({ description: 'User growth percentage' })
userGrowth: number;
@ApiProperty({ description: 'Pending invoices amount' })
pendingAmount: number;
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
export class BillingMetricsDto {
@ApiProperty({ description: 'Current subscription status' })
subscriptionStatus: string;
@ApiProperty({ description: 'Current plan name' })
currentPlan: string | null;
@ApiProperty({ description: 'Total revenue in the period' })
totalRevenue: number;
@ApiProperty({ description: 'Total paid invoices count' })
paidInvoices: number;
@ApiProperty({ description: 'Total pending invoices count' })
pendingInvoices: number;
@ApiProperty({ description: 'Total amount due' })
totalDue: number;
@ApiProperty({ description: 'Average invoice amount' })
averageInvoiceAmount: number;
@ApiProperty({ description: 'Days until subscription expires' })
daysUntilExpiration: number;
@ApiProperty({ description: 'Whether subscription is in trial' })
isTrialPeriod: boolean;
@ApiProperty({ description: 'Revenue trend (positive/negative percentage)' })
revenueTrend: number;
}

View File

@ -0,0 +1,6 @@
export * from './analytics-query.dto';
export * from './user-metrics.dto';
export * from './billing-metrics.dto';
export * from './usage-metrics.dto';
export * from './analytics-summary.dto';
export * from './trend-data.dto';

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
export class TrendDataPointDto {
@ApiProperty({ description: 'Date label' })
date: string;
@ApiProperty({ description: 'Value for this date' })
value: number;
}
export class TrendDataDto {
@ApiProperty({ description: 'Metric name' })
metric: string;
@ApiProperty({ description: 'Data points for the trend', type: [TrendDataPointDto] })
data: TrendDataPointDto[];
}

View File

@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
export class UsageMetricsDto {
@ApiProperty({ description: 'Total audit log entries in the period' })
totalActions: number;
@ApiProperty({ description: 'Unique users who performed actions' })
activeUsersCount: number;
@ApiProperty({ description: 'Actions breakdown by type' })
actionsByType: Record<string, number>;
@ApiProperty({ description: 'Actions breakdown by entity type' })
actionsByEntity: Record<string, number>;
@ApiProperty({ description: 'Average actions per user' })
averageActionsPerUser: number;
@ApiProperty({ description: 'Peak usage hour (0-23)' })
peakUsageHour: number;
@ApiProperty({ description: 'Total login count in the period' })
loginCount: number;
@ApiProperty({ description: 'Average session duration in minutes (estimated)' })
avgSessionDuration: number;
@ApiProperty({ description: 'Most accessed entities' })
topEntities: Array<{ entityType: string; count: number }>;
}

View File

@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserMetricsDto {
@ApiProperty({ description: 'Total users in the tenant' })
totalUsers: number;
@ApiProperty({ description: 'Active users (status = active)' })
activeUsers: number;
@ApiProperty({ description: 'Inactive users (status = inactive)' })
inactiveUsers: number;
@ApiProperty({ description: 'Pending verification users' })
pendingVerification: number;
@ApiProperty({ description: 'Suspended users' })
suspendedUsers: number;
@ApiProperty({ description: 'New users in the period' })
newUsers: number;
@ApiProperty({ description: 'Users who logged in during the period' })
usersLoggedIn: number;
@ApiProperty({ description: 'User growth rate as percentage' })
growthRate: number;
@ApiProperty({ description: 'User retention rate as percentage' })
retentionRate: number;
}

View File

@ -0,0 +1,4 @@
export * from './analytics.module';
export * from './analytics.controller';
export * from './analytics.service';
export * from './dto';

View File

@ -0,0 +1,919 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuditController } from '../audit.controller';
import { AuditService } from '../services/audit.service';
import { QueryAuditLogsDto } from '../dto/query-audit.dto';
import { QueryActivityLogsDto } from '../dto/query-activity.dto';
import { CreateActivityLogDto } from '../dto/create-activity.dto';
import { AuditAction, AuditLog } from '../entities/audit-log.entity';
import { ActivityLog, ActivityType } from '../entities/activity-log.entity';
import { RequestUser } from '../../auth/strategies/jwt.strategy';
describe('AuditController', () => {
let controller: AuditController;
let auditService: jest.Mocked<AuditService>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
const mockRequestUser: RequestUser = {
id: mockUserId,
email: 'test@example.com',
tenant_id: mockTenantId,
};
const mockRequest = {
ip: '192.168.1.1',
headers: {
'user-agent': 'Mozilla/5.0 Test Agent',
'x-session-id': 'session-12345',
},
};
const mockAuditLog: Partial<AuditLog> = {
id: 'audit-001',
tenant_id: mockTenantId,
user_id: mockUserId,
action: AuditAction.CREATE,
entity_type: 'user',
entity_id: 'user-001',
new_values: { email: 'test@example.com' },
changed_fields: ['email'],
ip_address: '192.168.1.1',
created_at: new Date('2026-01-13T10:00:00Z'),
};
const mockActivityLog: Partial<ActivityLog> = {
id: 'activity-001',
tenant_id: mockTenantId,
user_id: mockUserId,
activity_type: ActivityType.PAGE_VIEW,
resource_type: 'dashboard',
description: 'Viewed dashboard',
ip_address: '192.168.1.1',
user_agent: 'Mozilla/5.0 Test Agent',
session_id: 'session-12345',
created_at: new Date('2026-01-13T10:00:00Z'),
};
const mockPaginatedAuditLogs = {
data: [mockAuditLog as AuditLog],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
const mockPaginatedActivityLogs = {
data: [mockActivityLog as ActivityLog],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
};
const mockAuditStats = {
total_actions: 100,
actions_by_type: [
{ action: AuditAction.CREATE, count: 30 },
{ action: AuditAction.UPDATE, count: 50 },
{ action: AuditAction.DELETE, count: 20 },
],
top_users: [
{ user_id: mockUserId, count: 45 },
{ user_id: 'user-002', count: 30 },
],
};
const mockActivitySummary = [
{ activity_type: ActivityType.PAGE_VIEW, count: 50 },
{ activity_type: ActivityType.FEATURE_USE, count: 25 },
];
beforeEach(async () => {
const mockAuditService = {
queryAuditLogs: jest.fn(),
getAuditLogById: jest.fn(),
getEntityAuditHistory: jest.fn(),
getAuditStats: jest.fn(),
queryActivityLogs: jest.fn(),
createActivityLog: jest.fn(),
getUserActivitySummary: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuditController],
providers: [{ provide: AuditService, useValue: mockAuditService }],
}).compile();
controller = module.get<AuditController>(AuditController);
auditService = module.get(AuditService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ==================== AUDIT LOGS TESTS ====================
describe('queryAuditLogs', () => {
it('should return paginated audit logs with default pagination', async () => {
const query: QueryAuditLogsDto = {};
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter audit logs by user_id', async () => {
const query: QueryAuditLogsDto = { user_id: mockUserId };
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter audit logs by action', async () => {
const query: QueryAuditLogsDto = { action: AuditAction.CREATE };
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter audit logs by entity_type', async () => {
const query: QueryAuditLogsDto = { entity_type: 'user' };
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter audit logs by entity_id', async () => {
const query: QueryAuditLogsDto = {
entity_id: '550e8400-e29b-41d4-a716-446655440003',
};
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter audit logs by date range', async () => {
const query: QueryAuditLogsDto = {
from_date: '2026-01-01',
to_date: '2026-01-31',
};
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should handle custom pagination', async () => {
const query: QueryAuditLogsDto = { page: 2, limit: 10 };
const paginatedResult = {
...mockPaginatedAuditLogs,
page: 2,
limit: 10,
totalPages: 5,
total: 50,
};
auditService.queryAuditLogs.mockResolvedValue(paginatedResult);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result.page).toBe(2);
expect(result.limit).toBe(10);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter with multiple criteria', async () => {
const query: QueryAuditLogsDto = {
user_id: mockUserId,
action: AuditAction.UPDATE,
entity_type: 'document',
from_date: '2026-01-01',
page: 1,
limit: 50,
};
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
const result = await controller.queryAuditLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedAuditLogs);
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should return empty data when no results', async () => {
const emptyResult = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
auditService.queryAuditLogs.mockResolvedValue(emptyResult);
const result = await controller.queryAuditLogs(mockRequestUser, {});
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('getAuditLogById', () => {
it('should return audit log by ID', async () => {
auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog);
const result = await controller.getAuditLogById(
mockRequestUser,
'audit-001',
);
expect(result).toEqual(mockAuditLog);
expect(auditService.getAuditLogById).toHaveBeenCalledWith(
mockTenantId,
'audit-001',
);
});
it('should return null when audit log not found', async () => {
auditService.getAuditLogById.mockResolvedValue(null);
const result = await controller.getAuditLogById(
mockRequestUser,
'non-existent-id',
);
expect(result).toBeNull();
expect(auditService.getAuditLogById).toHaveBeenCalledWith(
mockTenantId,
'non-existent-id',
);
});
it('should use tenant_id from current user for isolation', async () => {
auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog);
await controller.getAuditLogById(mockRequestUser, 'audit-001');
expect(auditService.getAuditLogById).toHaveBeenCalledWith(
mockRequestUser.tenant_id,
'audit-001',
);
});
});
describe('getEntityAuditHistory', () => {
it('should return audit history for specific entity', async () => {
const history = [mockAuditLog as AuditLog];
auditService.getEntityAuditHistory.mockResolvedValue(history);
const result = await controller.getEntityAuditHistory(
mockRequestUser,
'user',
'user-001',
);
expect(result).toEqual(history);
expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith(
mockTenantId,
'user',
'user-001',
);
});
it('should return empty array when no history found', async () => {
auditService.getEntityAuditHistory.mockResolvedValue([]);
const result = await controller.getEntityAuditHistory(
mockRequestUser,
'document',
'doc-999',
);
expect(result).toHaveLength(0);
expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith(
mockTenantId,
'document',
'doc-999',
);
});
it('should handle different entity types', async () => {
auditService.getEntityAuditHistory.mockResolvedValue([]);
await controller.getEntityAuditHistory(
mockRequestUser,
'product',
'prod-123',
);
expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith(
mockTenantId,
'product',
'prod-123',
);
});
});
describe('getAuditStats', () => {
it('should return audit statistics with default days', async () => {
auditService.getAuditStats.mockResolvedValue(mockAuditStats);
const result = await controller.getAuditStats(mockRequestUser);
expect(result).toEqual(mockAuditStats);
expect(auditService.getAuditStats).toHaveBeenCalledWith(mockTenantId, 7);
});
it('should return audit statistics with custom days', async () => {
auditService.getAuditStats.mockResolvedValue(mockAuditStats);
const result = await controller.getAuditStats(mockRequestUser, 30);
expect(result).toEqual(mockAuditStats);
expect(auditService.getAuditStats).toHaveBeenCalledWith(mockTenantId, 30);
});
it('should return correct statistics structure', async () => {
auditService.getAuditStats.mockResolvedValue(mockAuditStats);
const result = await controller.getAuditStats(mockRequestUser, 7);
expect(result.total_actions).toBe(100);
expect(result.actions_by_type).toHaveLength(3);
expect(result.top_users).toHaveLength(2);
});
it('should handle zero statistics', async () => {
const emptyStats = {
total_actions: 0,
actions_by_type: [],
top_users: [],
};
auditService.getAuditStats.mockResolvedValue(emptyStats);
const result = await controller.getAuditStats(mockRequestUser, 1);
expect(result.total_actions).toBe(0);
expect(result.actions_by_type).toHaveLength(0);
expect(result.top_users).toHaveLength(0);
});
});
// ==================== ACTIVITY LOGS TESTS ====================
describe('queryActivityLogs', () => {
it('should return paginated activity logs with default pagination', async () => {
const query: QueryActivityLogsDto = {};
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedActivityLogs);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter activity logs by user_id', async () => {
const query: QueryActivityLogsDto = { user_id: mockUserId };
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedActivityLogs);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter activity logs by activity_type', async () => {
const query: QueryActivityLogsDto = {
activity_type: ActivityType.PAGE_VIEW,
};
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedActivityLogs);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter activity logs by resource_type', async () => {
const query: QueryActivityLogsDto = { resource_type: 'dashboard' };
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedActivityLogs);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should filter activity logs by date range', async () => {
const query: QueryActivityLogsDto = {
from_date: '2026-01-01',
to_date: '2026-01-31',
};
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result).toEqual(mockPaginatedActivityLogs);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
query,
);
});
it('should handle custom pagination', async () => {
const query: QueryActivityLogsDto = { page: 3, limit: 15 };
const paginatedResult = {
...mockPaginatedActivityLogs,
page: 3,
limit: 15,
totalPages: 7,
total: 100,
};
auditService.queryActivityLogs.mockResolvedValue(paginatedResult);
const result = await controller.queryActivityLogs(mockRequestUser, query);
expect(result.page).toBe(3);
expect(result.limit).toBe(15);
});
it('should return empty data when no results', async () => {
const emptyResult = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
auditService.queryActivityLogs.mockResolvedValue(emptyResult);
const result = await controller.queryActivityLogs(mockRequestUser, {});
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('createActivityLog', () => {
it('should create activity log with all context', async () => {
const dto: CreateActivityLogDto = {
activity_type: ActivityType.PAGE_VIEW,
resource_type: 'dashboard',
description: 'Viewed main dashboard',
};
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
const result = await controller.createActivityLog(
mockRequestUser,
dto,
mockRequest as any,
);
expect(result).toEqual(mockActivityLog);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
{
ip_address: mockRequest.ip,
user_agent: mockRequest.headers['user-agent'],
session_id: mockRequest.headers['x-session-id'],
},
);
});
it('should create activity log with minimal data', async () => {
const dto: CreateActivityLogDto = {
activity_type: ActivityType.FEATURE_USE,
};
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
const result = await controller.createActivityLog(
mockRequestUser,
dto,
mockRequest as any,
);
expect(result).toEqual(mockActivityLog);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
expect.any(Object),
);
});
it('should create activity log with resource_id', async () => {
const dto: CreateActivityLogDto = {
activity_type: ActivityType.DOWNLOAD,
resource_type: 'document',
resource_id: '550e8400-e29b-41d4-a716-446655440005',
description: 'Downloaded document',
};
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
await controller.createActivityLog(
mockRequestUser,
dto,
mockRequest as any,
);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
expect.objectContaining({
ip_address: mockRequest.ip,
}),
);
});
it('should create activity log with metadata', async () => {
const dto: CreateActivityLogDto = {
activity_type: ActivityType.SEARCH,
description: 'Searched for products',
metadata: { query: 'laptop', results: 25 },
};
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
await controller.createActivityLog(
mockRequestUser,
dto,
mockRequest as any,
);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
expect.any(Object),
);
});
it('should handle request without session_id header', async () => {
const dto: CreateActivityLogDto = {
activity_type: ActivityType.PAGE_VIEW,
};
const requestWithoutSession = {
ip: '192.168.1.1',
headers: {
'user-agent': 'Mozilla/5.0',
},
};
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
await controller.createActivityLog(
mockRequestUser,
dto,
requestWithoutSession as any,
);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
{
ip_address: '192.168.1.1',
user_agent: 'Mozilla/5.0',
session_id: undefined,
},
);
});
it('should create activity for different activity types', async () => {
const activityTypes = [
ActivityType.PAGE_VIEW,
ActivityType.FEATURE_USE,
ActivityType.SEARCH,
ActivityType.DOWNLOAD,
ActivityType.UPLOAD,
ActivityType.SHARE,
ActivityType.INVITE,
ActivityType.SETTINGS_CHANGE,
ActivityType.SUBSCRIPTION_CHANGE,
ActivityType.PAYMENT,
];
for (const activityType of activityTypes) {
const dto: CreateActivityLogDto = { activity_type: activityType };
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
await controller.createActivityLog(
mockRequestUser,
dto,
mockRequest as any,
);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
dto,
expect.any(Object),
);
}
});
});
describe('getUserActivitySummary', () => {
it('should return user activity summary with default days', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
const result = await controller.getUserActivitySummary(mockRequestUser);
expect(result).toEqual(mockActivitySummary);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
30,
);
});
it('should return user activity summary with custom days', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
const result = await controller.getUserActivitySummary(
mockRequestUser,
60,
);
expect(result).toEqual(mockActivitySummary);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockTenantId,
mockUserId,
60,
);
});
it('should return empty summary when no activity', async () => {
auditService.getUserActivitySummary.mockResolvedValue([]);
const result = await controller.getUserActivitySummary(mockRequestUser, 7);
expect(result).toHaveLength(0);
});
it('should use current user id for summary', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
await controller.getUserActivitySummary(mockRequestUser);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockRequestUser.tenant_id,
mockRequestUser.id,
30,
);
});
});
describe('getSpecificUserActivitySummary', () => {
const targetUserId = '550e8400-e29b-41d4-a716-446655440010';
it('should return specific user activity summary with default days', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
const result = await controller.getSpecificUserActivitySummary(
mockRequestUser,
targetUserId,
);
expect(result).toEqual(mockActivitySummary);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockTenantId,
targetUserId,
30,
);
});
it('should return specific user activity summary with custom days', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
const result = await controller.getSpecificUserActivitySummary(
mockRequestUser,
targetUserId,
90,
);
expect(result).toEqual(mockActivitySummary);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockTenantId,
targetUserId,
90,
);
});
it('should return empty summary for user with no activity', async () => {
auditService.getUserActivitySummary.mockResolvedValue([]);
const result = await controller.getSpecificUserActivitySummary(
mockRequestUser,
'inactive-user-id',
);
expect(result).toHaveLength(0);
});
it('should use tenant_id from current user for tenant isolation', async () => {
auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary);
await controller.getSpecificUserActivitySummary(
mockRequestUser,
targetUserId,
30,
);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockRequestUser.tenant_id,
targetUserId,
30,
);
});
});
// ==================== GUARDS VERIFICATION TESTS ====================
describe('Guards and Decorators', () => {
it('should have JwtAuthGuard applied at controller level', () => {
const guards = Reflect.getMetadata('__guards__', AuditController);
expect(guards).toBeDefined();
expect(guards.length).toBeGreaterThan(0);
});
it('should have ApiTags decorator', () => {
const tags = Reflect.getMetadata('swagger/apiUseTags', AuditController);
expect(tags).toContain('Audit');
});
it('should have ApiBearerAuth decorator', () => {
const security = Reflect.getMetadata(
'swagger/apiSecurity',
AuditController,
);
expect(security).toBeDefined();
});
});
// ==================== TENANT ISOLATION TESTS ====================
describe('Tenant Isolation', () => {
it('should always pass tenant_id from current user to service methods', async () => {
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog);
auditService.getEntityAuditHistory.mockResolvedValue([]);
auditService.getAuditStats.mockResolvedValue(mockAuditStats);
auditService.queryActivityLogs.mockResolvedValue(
mockPaginatedActivityLogs,
);
auditService.createActivityLog.mockResolvedValue(
mockActivityLog as ActivityLog,
);
auditService.getUserActivitySummary.mockResolvedValue([]);
// Call all endpoints
await controller.queryAuditLogs(mockRequestUser, {});
await controller.getAuditLogById(mockRequestUser, 'id');
await controller.getEntityAuditHistory(mockRequestUser, 'type', 'id');
await controller.getAuditStats(mockRequestUser);
await controller.queryActivityLogs(mockRequestUser, {});
await controller.createActivityLog(
mockRequestUser,
{ activity_type: ActivityType.PAGE_VIEW },
mockRequest as any,
);
await controller.getUserActivitySummary(mockRequestUser);
await controller.getSpecificUserActivitySummary(
mockRequestUser,
'user-id',
);
// Verify tenant_id is always passed
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
mockTenantId,
expect.any(Object),
);
expect(auditService.getAuditLogById).toHaveBeenCalledWith(
mockTenantId,
expect.any(String),
);
expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith(
mockTenantId,
expect.any(String),
expect.any(String),
);
expect(auditService.getAuditStats).toHaveBeenCalledWith(
mockTenantId,
expect.any(Number),
);
expect(auditService.queryActivityLogs).toHaveBeenCalledWith(
mockTenantId,
expect.any(Object),
);
expect(auditService.createActivityLog).toHaveBeenCalledWith(
mockTenantId,
expect.any(String),
expect.any(Object),
expect.any(Object),
);
expect(auditService.getUserActivitySummary).toHaveBeenCalledWith(
mockTenantId,
expect.any(String),
expect.any(Number),
);
});
it('should not allow cross-tenant data access', async () => {
const differentTenantUser: RequestUser = {
id: 'different-user',
email: 'other@example.com',
tenant_id: 'different-tenant-id',
};
auditService.queryAuditLogs.mockResolvedValue({
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
await controller.queryAuditLogs(differentTenantUser, {});
expect(auditService.queryAuditLogs).toHaveBeenCalledWith(
'different-tenant-id',
expect.any(Object),
);
});
});
});

View File

@ -0,0 +1,567 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditService } from '../services/audit.service';
import { AuditLog, AuditAction } from '../entities/audit-log.entity';
import { ActivityLog, ActivityType } from '../entities/activity-log.entity';
describe('AuditService', () => {
let service: AuditService;
let auditLogRepo: jest.Mocked<Repository<AuditLog>>;
let activityLogRepo: jest.Mocked<Repository<ActivityLog>>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
const mockAuditLog: Partial<AuditLog> = {
id: 'audit-001',
tenant_id: mockTenantId,
user_id: mockUserId,
action: AuditAction.CREATE,
entity_type: 'user',
entity_id: 'user-001',
new_values: { email: 'test@example.com' },
changed_fields: ['email'],
ip_address: '192.168.1.1',
created_at: new Date(),
};
const mockActivityLog: Partial<ActivityLog> = {
id: 'activity-001',
tenant_id: mockTenantId,
user_id: mockUserId,
activity_type: ActivityType.PAGE_VIEW,
resource_type: 'dashboard',
description: 'Viewed dashboard',
ip_address: '192.168.1.1',
created_at: new Date(),
};
beforeEach(async () => {
const mockAuditLogRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
createQueryBuilder: jest.fn(),
};
const mockActivityLogRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
createQueryBuilder: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuditService,
{ provide: getRepositoryToken(AuditLog), useValue: mockAuditLogRepo },
{ provide: getRepositoryToken(ActivityLog), useValue: mockActivityLogRepo },
],
}).compile();
service = module.get<AuditService>(AuditService);
auditLogRepo = module.get(getRepositoryToken(AuditLog));
activityLogRepo = module.get(getRepositoryToken(ActivityLog));
});
afterEach(() => {
jest.clearAllMocks();
});
// ==================== Create Audit Log Tests ====================
describe('createAuditLog', () => {
it('should create audit log successfully', async () => {
auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog);
auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog);
const result = await service.createAuditLog({
tenant_id: mockTenantId,
user_id: mockUserId,
action: AuditAction.CREATE,
entity_type: 'user',
entity_id: 'user-001',
new_values: { email: 'test@example.com' },
});
expect(result).toEqual(mockAuditLog);
expect(auditLogRepo.create).toHaveBeenCalled();
expect(auditLogRepo.save).toHaveBeenCalled();
});
it('should detect changed fields', async () => {
auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog);
auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog);
await service.createAuditLog({
tenant_id: mockTenantId,
user_id: mockUserId,
action: AuditAction.UPDATE,
entity_type: 'user',
entity_id: 'user-001',
old_values: { email: 'old@example.com', name: 'Old Name' },
new_values: { email: 'new@example.com', name: 'Old Name' },
});
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
changed_fields: ['email'],
}),
);
});
it('should handle null old/new values', async () => {
auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog);
auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog);
await service.createAuditLog({
tenant_id: mockTenantId,
action: AuditAction.DELETE,
entity_type: 'user',
entity_id: 'user-001',
});
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
changed_fields: [],
}),
);
});
it('should include request metadata', async () => {
auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog);
auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog);
await service.createAuditLog({
tenant_id: mockTenantId,
user_id: mockUserId,
action: AuditAction.READ,
entity_type: 'document',
ip_address: '192.168.1.1',
user_agent: 'Mozilla/5.0',
endpoint: '/api/documents/1',
http_method: 'GET',
response_status: 200,
duration_ms: 150,
});
expect(auditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
ip_address: '192.168.1.1',
endpoint: '/api/documents/1',
duration_ms: 150,
}),
);
});
});
// ==================== Query Audit Logs Tests ====================
describe('queryAuditLogs', () => {
it('should return paginated audit logs', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockAuditLog as AuditLog], 1]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
const result = await service.queryAuditLogs(mockTenantId, {});
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
});
it('should filter by user_id', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.queryAuditLogs(mockTenantId, { user_id: mockUserId });
expect(qb.andWhere).toHaveBeenCalledWith('audit.user_id = :user_id', {
user_id: mockUserId,
});
});
it('should filter by action', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.queryAuditLogs(mockTenantId, { action: AuditAction.CREATE });
expect(qb.andWhere).toHaveBeenCalledWith('audit.action = :action', {
action: AuditAction.CREATE,
});
});
it('should filter by entity_type', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.queryAuditLogs(mockTenantId, { entity_type: 'user' });
expect(qb.andWhere).toHaveBeenCalledWith('audit.entity_type = :entity_type', {
entity_type: 'user',
});
});
it('should filter by date range', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
const from_date = '2026-01-01';
const to_date = '2026-01-31';
await service.queryAuditLogs(mockTenantId, { from_date, to_date });
expect(qb.andWhere).toHaveBeenCalledWith(
'audit.created_at BETWEEN :from_date AND :to_date',
{ from_date, to_date },
);
});
it('should handle pagination correctly', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 100]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
const result = await service.queryAuditLogs(mockTenantId, {
page: 3,
limit: 10,
});
expect(qb.skip).toHaveBeenCalledWith(20);
expect(qb.take).toHaveBeenCalledWith(10);
expect(result.totalPages).toBe(10);
});
});
// ==================== Get Audit Log By ID Tests ====================
describe('getAuditLogById', () => {
it('should return audit log by id', async () => {
auditLogRepo.findOne.mockResolvedValue(mockAuditLog as AuditLog);
const result = await service.getAuditLogById(mockTenantId, 'audit-001');
expect(result).toEqual(mockAuditLog);
expect(auditLogRepo.findOne).toHaveBeenCalledWith({
where: { id: 'audit-001', tenant_id: mockTenantId },
});
});
it('should return null when not found', async () => {
auditLogRepo.findOne.mockResolvedValue(null);
const result = await service.getAuditLogById(mockTenantId, 'invalid');
expect(result).toBeNull();
});
});
// ==================== Get Entity Audit History Tests ====================
describe('getEntityAuditHistory', () => {
it('should return audit history for entity', async () => {
auditLogRepo.find.mockResolvedValue([mockAuditLog as AuditLog]);
const result = await service.getEntityAuditHistory(
mockTenantId,
'user',
'user-001',
);
expect(result).toHaveLength(1);
expect(auditLogRepo.find).toHaveBeenCalledWith({
where: {
tenant_id: mockTenantId,
entity_type: 'user',
entity_id: 'user-001',
},
order: { created_at: 'DESC' },
});
});
it('should return empty array for no history', async () => {
auditLogRepo.find.mockResolvedValue([]);
const result = await service.getEntityAuditHistory(
mockTenantId,
'document',
'doc-999',
);
expect(result).toHaveLength(0);
});
});
// ==================== Create Activity Log Tests ====================
describe('createActivityLog', () => {
it('should create activity log successfully', async () => {
activityLogRepo.create.mockReturnValue(mockActivityLog as ActivityLog);
activityLogRepo.save.mockResolvedValue(mockActivityLog as ActivityLog);
const result = await service.createActivityLog(
mockTenantId,
mockUserId,
{
activity_type: ActivityType.PAGE_VIEW,
resource_type: 'dashboard',
description: 'Viewed dashboard',
},
{ ip_address: '192.168.1.1' },
);
expect(result).toEqual(mockActivityLog);
expect(activityLogRepo.create).toHaveBeenCalled();
});
it('should include session context', async () => {
activityLogRepo.create.mockReturnValue(mockActivityLog as ActivityLog);
activityLogRepo.save.mockResolvedValue(mockActivityLog as ActivityLog);
await service.createActivityLog(
mockTenantId,
mockUserId,
{
activity_type: ActivityType.FEATURE_USE,
description: 'Used export feature',
},
{
ip_address: '192.168.1.1',
user_agent: 'Mozilla/5.0',
session_id: 'session-001',
},
);
expect(activityLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
session_id: 'session-001',
}),
);
});
});
// ==================== Query Activity Logs Tests ====================
describe('queryActivityLogs', () => {
it('should return paginated activity logs', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest
.fn()
.mockResolvedValue([[mockActivityLog as ActivityLog], 1]),
};
activityLogRepo.createQueryBuilder.mockReturnValue(qb as any);
const result = await service.queryActivityLogs(mockTenantId, {});
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
});
it('should filter by activity_type', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
activityLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.queryActivityLogs(mockTenantId, {
activity_type: ActivityType.PAGE_VIEW,
});
expect(qb.andWhere).toHaveBeenCalledWith(
'activity.activity_type = :activity_type',
{ activity_type: ActivityType.PAGE_VIEW },
);
});
it('should filter by resource_type', async () => {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
activityLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.queryActivityLogs(mockTenantId, { resource_type: 'document' });
expect(qb.andWhere).toHaveBeenCalledWith(
'activity.resource_type = :resource_type',
{ resource_type: 'document' },
);
});
});
// ==================== Get User Activity Summary Tests ====================
describe('getUserActivitySummary', () => {
it('should return activity summary by type', async () => {
const qb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([
{ activity_type: ActivityType.PAGE_VIEW, count: '50' },
{ activity_type: ActivityType.FEATURE_USE, count: '25' },
]),
};
activityLogRepo.createQueryBuilder.mockReturnValue(qb as any);
const result = await service.getUserActivitySummary(
mockTenantId,
mockUserId,
30,
);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
activity_type: ActivityType.PAGE_VIEW,
count: 50,
});
});
it('should use default 30 days', async () => {
const qb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
};
activityLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.getUserActivitySummary(mockTenantId, mockUserId);
expect(qb.andWhere).toHaveBeenCalledWith(
'activity.created_at >= :fromDate',
expect.any(Object),
);
});
});
// ==================== Get Audit Stats Tests ====================
describe('getAuditStats', () => {
it('should return audit statistics', async () => {
auditLogRepo.count.mockResolvedValue(100);
const actionsByTypeQb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([
{ action: AuditAction.CREATE, count: '30' },
{ action: AuditAction.UPDATE, count: '50' },
{ action: AuditAction.DELETE, count: '20' },
]),
};
const topUsersQb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([
{ user_id: mockUserId, count: '45' },
{ user_id: 'user-002', count: '30' },
]),
};
auditLogRepo.createQueryBuilder
.mockReturnValueOnce(actionsByTypeQb as any)
.mockReturnValueOnce(topUsersQb as any);
const result = await service.getAuditStats(mockTenantId, 7);
expect(result.total_actions).toBe(100);
expect(result.actions_by_type).toHaveLength(3);
expect(result.top_users).toHaveLength(2);
});
it('should use default 7 days', async () => {
auditLogRepo.count.mockResolvedValue(0);
const qb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
};
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
await service.getAuditStats(mockTenantId);
expect(auditLogRepo.count).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,145 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Req,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { AuditService } from './services/audit.service';
import { QueryAuditLogsDto } from './dto/query-audit.dto';
import { QueryActivityLogsDto } from './dto/query-activity.dto';
import { CreateActivityLogDto } from './dto/create-activity.dto';
import { JwtAuthGuard } from '../auth/guards';
import { CurrentUser } from '../auth/decorators';
import { RequestUser } from '../auth/strategies/jwt.strategy';
@ApiTags('Audit')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
// ==================== AUDIT LOGS ====================
@Get('logs')
@ApiOperation({ summary: 'Query audit logs with filters' })
@ApiResponse({ status: 200, description: 'Paginated audit logs' })
async queryAuditLogs(
@CurrentUser() user: RequestUser,
@Query() query: QueryAuditLogsDto,
) {
return this.auditService.queryAuditLogs(user.tenant_id, query);
}
@Get('logs/:id')
@ApiOperation({ summary: 'Get audit log by ID' })
@ApiParam({ name: 'id', description: 'Audit log ID' })
@ApiResponse({ status: 200, description: 'Audit log details' })
@ApiResponse({ status: 404, description: 'Audit log not found' })
async getAuditLogById(
@CurrentUser() user: RequestUser,
@Param('id') id: string,
) {
return this.auditService.getAuditLogById(user.tenant_id, id);
}
@Get('entity/:entityType/:entityId')
@ApiOperation({ summary: 'Get audit history for a specific entity' })
@ApiParam({ name: 'entityType', description: 'Entity type (e.g., user, product)' })
@ApiParam({ name: 'entityId', description: 'Entity ID' })
@ApiResponse({ status: 200, description: 'Entity audit history' })
async getEntityAuditHistory(
@CurrentUser() user: RequestUser,
@Param('entityType') entityType: string,
@Param('entityId') entityId: string,
) {
return this.auditService.getEntityAuditHistory(
user.tenant_id,
entityType,
entityId,
);
}
@Get('stats')
@ApiOperation({ summary: 'Get audit statistics for dashboard' })
@ApiResponse({ status: 200, description: 'Audit statistics' })
async getAuditStats(
@CurrentUser() user: RequestUser,
@Query('days') days?: number,
) {
return this.auditService.getAuditStats(user.tenant_id, days || 7);
}
// ==================== ACTIVITY LOGS ====================
@Get('activities')
@ApiOperation({ summary: 'Query activity logs with filters' })
@ApiResponse({ status: 200, description: 'Paginated activity logs' })
async queryActivityLogs(
@CurrentUser() user: RequestUser,
@Query() query: QueryActivityLogsDto,
) {
return this.auditService.queryActivityLogs(user.tenant_id, query);
}
@Post('activities')
@ApiOperation({ summary: 'Create an activity log entry' })
@ApiResponse({ status: 201, description: 'Activity log created' })
async createActivityLog(
@CurrentUser() user: RequestUser,
@Body() dto: CreateActivityLogDto,
@Req() request: any,
) {
return this.auditService.createActivityLog(
user.tenant_id,
user.id,
dto,
{
ip_address: request.ip,
user_agent: request.headers['user-agent'],
session_id: request.headers['x-session-id'],
},
);
}
@Get('activities/summary')
@ApiOperation({ summary: 'Get user activity summary' })
@ApiResponse({ status: 200, description: 'Activity summary by type' })
async getUserActivitySummary(
@CurrentUser() user: RequestUser,
@Query('days') days?: number,
) {
return this.auditService.getUserActivitySummary(
user.tenant_id,
user.id,
days || 30,
);
}
@Get('activities/user/:userId')
@ApiOperation({ summary: 'Get activity summary for a specific user' })
@ApiParam({ name: 'userId', description: 'User ID' })
@ApiResponse({ status: 200, description: 'User activity summary' })
async getSpecificUserActivitySummary(
@CurrentUser() user: RequestUser,
@Param('userId') userId: string,
@Query('days') days?: number,
) {
return this.auditService.getUserActivitySummary(
user.tenant_id,
userId,
days || 30,
);
}
}

View File

@ -0,0 +1,23 @@
import { Module, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuditController } from './audit.controller';
import { AuditService } from './services/audit.service';
import { AuditLog, ActivityLog } from './entities';
import { AuditInterceptor } from './interceptors/audit.interceptor';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog, ActivityLog])],
controllers: [AuditController],
providers: [
AuditService,
// Register interceptor globally
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
exports: [AuditService],
})
export class AuditModule {}

View File

@ -0,0 +1,29 @@
import { IsEnum, IsOptional, IsUUID, IsString, IsObject } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ActivityType } from '../entities/activity-log.entity';
export class CreateActivityLogDto {
@ApiProperty({ description: 'Activity type', enum: ActivityType })
@IsEnum(ActivityType)
activity_type: ActivityType;
@ApiPropertyOptional({ description: 'Resource type' })
@IsOptional()
@IsString()
resource_type?: string;
@ApiPropertyOptional({ description: 'Resource ID' })
@IsOptional()
@IsUUID()
resource_id?: string;
@ApiPropertyOptional({ description: 'Activity description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Additional metadata' })
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}

View File

@ -0,0 +1,3 @@
export * from './query-audit.dto';
export * from './query-activity.dto';
export * from './create-activity.dto';

View File

@ -0,0 +1,60 @@
import {
IsOptional,
IsUUID,
IsEnum,
IsDateString,
IsNumber,
Min,
Max,
IsString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ActivityType } from '../entities/activity-log.entity';
export class QueryActivityLogsDto {
@ApiPropertyOptional({ description: 'Filter by user ID' })
@IsOptional()
@IsUUID()
user_id?: string;
@ApiPropertyOptional({ description: 'Filter by activity type', enum: ActivityType })
@IsOptional()
@IsEnum(ActivityType)
activity_type?: ActivityType;
@ApiPropertyOptional({ description: 'Filter by resource type' })
@IsOptional()
@IsString()
resource_type?: string;
@ApiPropertyOptional({ description: 'Filter by resource ID' })
@IsOptional()
@IsUUID()
resource_id?: string;
@ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' })
@IsOptional()
@IsDateString()
from_date?: string;
@ApiPropertyOptional({ description: 'End date filter (ISO 8601)' })
@IsOptional()
@IsDateString()
to_date?: string;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
}

View File

@ -0,0 +1,60 @@
import {
IsOptional,
IsUUID,
IsEnum,
IsDateString,
IsNumber,
Min,
Max,
IsString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { AuditAction } from '../entities/audit-log.entity';
export class QueryAuditLogsDto {
@ApiPropertyOptional({ description: 'Filter by user ID' })
@IsOptional()
@IsUUID()
user_id?: string;
@ApiPropertyOptional({ description: 'Filter by action', enum: AuditAction })
@IsOptional()
@IsEnum(AuditAction)
action?: AuditAction;
@ApiPropertyOptional({ description: 'Filter by entity type' })
@IsOptional()
@IsString()
entity_type?: string;
@ApiPropertyOptional({ description: 'Filter by entity ID' })
@IsOptional()
@IsUUID()
entity_id?: string;
@ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' })
@IsOptional()
@IsDateString()
from_date?: string;
@ApiPropertyOptional({ description: 'End date filter (ISO 8601)' })
@IsOptional()
@IsDateString()
to_date?: string;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Items per page', default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
}

View File

@ -0,0 +1,64 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum ActivityType {
PAGE_VIEW = 'page_view',
FEATURE_USE = 'feature_use',
SEARCH = 'search',
DOWNLOAD = 'download',
UPLOAD = 'upload',
SHARE = 'share',
INVITE = 'invite',
SETTINGS_CHANGE = 'settings_change',
SUBSCRIPTION_CHANGE = 'subscription_change',
PAYMENT = 'payment',
}
@Entity({ name: 'activity_logs', schema: 'audit' })
@Index(['tenant_id', 'created_at'])
@Index(['user_id', 'activity_type'])
export class ActivityLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({
type: 'enum',
enum: ActivityType,
})
activity_type: ActivityType;
@Column({ type: 'varchar', length: 255, nullable: true })
resource_type: string;
@Column({ type: 'uuid', nullable: true })
resource_id: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@Column({ type: 'varchar', length: 45, nullable: true })
ip_address: string;
@Column({ type: 'varchar', length: 500, nullable: true })
user_agent: string;
@Column({ type: 'varchar', length: 100, nullable: true })
session_id: string;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
}

View File

@ -0,0 +1,81 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum AuditAction {
CREATE = 'create',
UPDATE = 'update',
DELETE = 'delete',
READ = 'read',
LOGIN = 'login',
LOGOUT = 'logout',
EXPORT = 'export',
IMPORT = 'import',
}
@Entity({ name: 'audit_logs', schema: 'audit' })
@Index(['tenant_id', 'created_at'])
@Index(['entity_type', 'entity_id'])
@Index(['user_id', 'created_at'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid', nullable: true })
user_id: string;
@Column({
type: 'enum',
enum: AuditAction,
})
action: AuditAction;
@Column({ type: 'varchar', length: 100 })
entity_type: string;
@Column({ type: 'uuid', nullable: true })
entity_id: string;
@Column({ type: 'jsonb', nullable: true })
old_values: Record<string, any>;
@Column({ type: 'jsonb', nullable: true })
new_values: Record<string, any>;
@Column({ type: 'jsonb', nullable: true })
changed_fields: string[];
@Column({ type: 'varchar', length: 45, nullable: true })
ip_address: string;
@Column({ type: 'varchar', length: 500, nullable: true })
user_agent: string;
@Column({ type: 'varchar', length: 255, nullable: true })
endpoint: string;
@Column({ type: 'varchar', length: 10, nullable: true })
http_method: string;
@Column({ type: 'smallint', nullable: true })
response_status: number;
@Column({ type: 'integer', nullable: true })
duration_ms: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
}

View File

@ -0,0 +1,2 @@
export * from './audit-log.entity';
export * from './activity-log.entity';

View File

@ -0,0 +1,6 @@
export * from './audit.module';
export * from './audit.controller';
export * from './services';
export * from './entities';
export * from './dto';
export { AuditInterceptor, AuditActionDecorator, AuditEntity, SkipAudit, AUDIT_ACTION_KEY, AUDIT_ENTITY_KEY, SKIP_AUDIT_KEY } from './interceptors';

View File

@ -0,0 +1,179 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Reflector } from '@nestjs/core';
import { AuditService, CreateAuditLogParams } from '../services/audit.service';
import { AuditAction as AuditActionEnum } from '../entities/audit-log.entity';
export const AUDIT_ACTION_KEY = 'audit_action';
export const AUDIT_ENTITY_KEY = 'audit_entity';
export const SKIP_AUDIT_KEY = 'skip_audit';
/**
* Decorator to specify audit action for a route
*/
export function AuditActionDecorator(action: AuditActionEnum) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
Reflect.defineMetadata(AUDIT_ACTION_KEY, action, descriptor.value);
return descriptor;
};
}
/**
* Decorator to specify entity type for audit logging
*/
export function AuditEntity(entityType: string) {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
Reflect.defineMetadata(AUDIT_ENTITY_KEY, entityType, descriptor.value);
return descriptor;
};
}
/**
* Decorator to skip audit logging for a route
*/
export function SkipAudit() {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
Reflect.defineMetadata(SKIP_AUDIT_KEY, true, descriptor.value);
return descriptor;
};
}
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(
private readonly auditService: AuditService,
private readonly reflector: Reflector,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const handler = context.getHandler();
const startTime = Date.now();
// Check if audit should be skipped
const skipAudit = this.reflector.get<boolean>(SKIP_AUDIT_KEY, handler);
if (skipAudit) {
return next.handle();
}
// Get audit metadata
const auditAction = this.reflector.get<AuditActionEnum>(AUDIT_ACTION_KEY, handler);
const entityType = this.reflector.get<string>(AUDIT_ENTITY_KEY, handler);
// If no explicit audit action, try to infer from HTTP method
const action = auditAction || this.inferActionFromMethod(request.method);
if (!action) {
return next.handle();
}
return next.handle().pipe(
tap({
next: async (response) => {
const duration = Date.now() - startTime;
await this.logAudit(request, action, entityType, response, 200, duration);
},
error: async (error) => {
const duration = Date.now() - startTime;
const statusCode = error.status || 500;
await this.logAudit(request, action, entityType, null, statusCode, duration);
},
}),
);
}
private inferActionFromMethod(method: string): AuditActionEnum | null {
switch (method.toUpperCase()) {
case 'POST':
return AuditActionEnum.CREATE;
case 'PUT':
case 'PATCH':
return AuditActionEnum.UPDATE;
case 'DELETE':
return AuditActionEnum.DELETE;
case 'GET':
return null; // Don't log reads by default
default:
return null;
}
}
private async logAudit(
request: any,
action: AuditActionEnum,
entityType: string | undefined,
response: any,
statusCode: number,
duration: number,
): Promise<void> {
try {
const tenantId = request.user?.tenantId || request.headers['x-tenant-id'];
if (!tenantId) {
return; // Cannot log without tenant context
}
const params: CreateAuditLogParams = {
tenant_id: tenantId,
user_id: request.user?.sub || request.user?.id,
action,
entity_type: entityType || this.inferEntityFromPath(request.path),
entity_id: request.params?.id,
old_values: request.body?._oldValues, // If provided by controller
new_values: this.sanitizeBody(request.body),
ip_address: this.getClientIp(request),
user_agent: request.headers['user-agent'],
endpoint: request.path,
http_method: request.method,
response_status: statusCode,
duration_ms: duration,
metadata: {
query: request.query,
response_id: response?.id,
},
};
await this.auditService.createAuditLog(params);
} catch (error) {
// Don't let audit logging failures affect the request
console.error('Failed to create audit log:', error);
}
}
private inferEntityFromPath(path: string): string {
// Extract entity type from path like /api/v1/users/:id -> users
const segments = path.split('/').filter(Boolean);
const apiIndex = segments.findIndex((s) => s === 'api');
if (apiIndex !== -1 && segments.length > apiIndex + 2) {
return segments[apiIndex + 2]; // Skip 'api' and version
}
return segments[segments.length - 1] || 'unknown';
}
private sanitizeBody(body: any): Record<string, any> | undefined {
if (!body) return undefined;
const sanitized = { ...body };
// Remove sensitive fields
const sensitiveFields = ['password', 'token', 'secret', 'creditCard', 'cvv', '_oldValues'];
for (const field of sensitiveFields) {
if (field in sanitized) {
sanitized[field] = '[REDACTED]';
}
}
return sanitized;
}
private getClientIp(request: any): string {
return (
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
request.headers['x-real-ip'] ||
request.connection?.remoteAddress ||
request.ip ||
'unknown'
);
}
}

View File

@ -0,0 +1,9 @@
export {
AuditInterceptor,
AuditActionDecorator,
AuditEntity,
SkipAudit,
AUDIT_ACTION_KEY,
AUDIT_ENTITY_KEY,
SKIP_AUDIT_KEY
} from './audit.interceptor';

View File

@ -0,0 +1,320 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AuditLog, AuditAction } from '../entities/audit-log.entity';
import { ActivityLog, ActivityType } from '../entities/activity-log.entity';
import { QueryAuditLogsDto } from '../dto/query-audit.dto';
import { QueryActivityLogsDto } from '../dto/query-activity.dto';
import { CreateActivityLogDto } from '../dto/create-activity.dto';
export interface CreateAuditLogParams {
tenant_id: string;
user_id?: string;
action: AuditAction;
entity_type: string;
entity_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
ip_address?: string;
user_agent?: string;
endpoint?: string;
http_method?: string;
response_status?: number;
duration_ms?: number;
description?: string;
metadata?: Record<string, any>;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class AuditService {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>,
@InjectRepository(ActivityLog)
private readonly activityLogRepository: Repository<ActivityLog>,
) {}
/**
* Create an audit log entry
*/
async createAuditLog(params: CreateAuditLogParams): Promise<AuditLog> {
const changedFields = this.detectChangedFields(
params.old_values,
params.new_values,
);
const auditLog = this.auditLogRepository.create({
...params,
changed_fields: changedFields,
});
return this.auditLogRepository.save(auditLog);
}
/**
* Query audit logs with filters and pagination
*/
async queryAuditLogs(
tenantId: string,
query: QueryAuditLogsDto,
): Promise<PaginatedResult<AuditLog>> {
const { user_id, action, entity_type, entity_id, from_date, to_date, page = 1, limit = 20 } = query;
const queryBuilder = this.auditLogRepository
.createQueryBuilder('audit')
.where('audit.tenant_id = :tenantId', { tenantId });
if (user_id) {
queryBuilder.andWhere('audit.user_id = :user_id', { user_id });
}
if (action) {
queryBuilder.andWhere('audit.action = :action', { action });
}
if (entity_type) {
queryBuilder.andWhere('audit.entity_type = :entity_type', { entity_type });
}
if (entity_id) {
queryBuilder.andWhere('audit.entity_id = :entity_id', { entity_id });
}
if (from_date && to_date) {
queryBuilder.andWhere('audit.created_at BETWEEN :from_date AND :to_date', {
from_date,
to_date,
});
} else if (from_date) {
queryBuilder.andWhere('audit.created_at >= :from_date', { from_date });
} else if (to_date) {
queryBuilder.andWhere('audit.created_at <= :to_date', { to_date });
}
queryBuilder
.orderBy('audit.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Get audit log by ID
*/
async getAuditLogById(tenantId: string, id: string): Promise<AuditLog | null> {
return this.auditLogRepository.findOne({
where: { id, tenant_id: tenantId },
});
}
/**
* Get audit logs for a specific entity
*/
async getEntityAuditHistory(
tenantId: string,
entityType: string,
entityId: string,
): Promise<AuditLog[]> {
return this.auditLogRepository.find({
where: {
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
},
order: { created_at: 'DESC' },
});
}
/**
* Create an activity log entry
*/
async createActivityLog(
tenantId: string,
userId: string,
dto: CreateActivityLogDto,
context?: { ip_address?: string; user_agent?: string; session_id?: string },
): Promise<ActivityLog> {
const activityLog = this.activityLogRepository.create({
tenant_id: tenantId,
user_id: userId,
...dto,
ip_address: context?.ip_address,
user_agent: context?.user_agent,
session_id: context?.session_id,
});
return this.activityLogRepository.save(activityLog);
}
/**
* Query activity logs with filters and pagination
*/
async queryActivityLogs(
tenantId: string,
query: QueryActivityLogsDto,
): Promise<PaginatedResult<ActivityLog>> {
const { user_id, activity_type, resource_type, from_date, to_date, page = 1, limit = 20 } = query;
const queryBuilder = this.activityLogRepository
.createQueryBuilder('activity')
.where('activity.tenant_id = :tenantId', { tenantId });
if (user_id) {
queryBuilder.andWhere('activity.user_id = :user_id', { user_id });
}
if (activity_type) {
queryBuilder.andWhere('activity.activity_type = :activity_type', { activity_type });
}
if (resource_type) {
queryBuilder.andWhere('activity.resource_type = :resource_type', { resource_type });
}
if (from_date && to_date) {
queryBuilder.andWhere('activity.created_at BETWEEN :from_date AND :to_date', {
from_date,
to_date,
});
} else if (from_date) {
queryBuilder.andWhere('activity.created_at >= :from_date', { from_date });
} else if (to_date) {
queryBuilder.andWhere('activity.created_at <= :to_date', { to_date });
}
queryBuilder
.orderBy('activity.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Get user activity summary
*/
async getUserActivitySummary(
tenantId: string,
userId: string,
days: number = 30,
): Promise<{ activity_type: ActivityType; count: number }[]> {
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
const result = await this.activityLogRepository
.createQueryBuilder('activity')
.select('activity.activity_type', 'activity_type')
.addSelect('COUNT(*)', 'count')
.where('activity.tenant_id = :tenantId', { tenantId })
.andWhere('activity.user_id = :userId', { userId })
.andWhere('activity.created_at >= :fromDate', { fromDate })
.groupBy('activity.activity_type')
.getRawMany();
return result.map((r) => ({
activity_type: r.activity_type,
count: parseInt(r.count, 10),
}));
}
/**
* Get audit statistics for dashboard
*/
async getAuditStats(
tenantId: string,
days: number = 7,
): Promise<{
total_actions: number;
actions_by_type: { action: AuditAction; count: number }[];
top_users: { user_id: string; count: number }[];
}> {
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
const [totalActions, actionsByType, topUsers] = await Promise.all([
this.auditLogRepository.count({
where: {
tenant_id: tenantId,
created_at: MoreThanOrEqual(fromDate),
},
}),
this.auditLogRepository
.createQueryBuilder('audit')
.select('audit.action', 'action')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at >= :fromDate', { fromDate })
.groupBy('audit.action')
.getRawMany(),
this.auditLogRepository
.createQueryBuilder('audit')
.select('audit.user_id', 'user_id')
.addSelect('COUNT(*)', 'count')
.where('audit.tenant_id = :tenantId', { tenantId })
.andWhere('audit.created_at >= :fromDate', { fromDate })
.andWhere('audit.user_id IS NOT NULL')
.groupBy('audit.user_id')
.orderBy('count', 'DESC')
.limit(10)
.getRawMany(),
]);
return {
total_actions: totalActions,
actions_by_type: actionsByType.map((r) => ({
action: r.action,
count: parseInt(r.count, 10),
})),
top_users: topUsers.map((r) => ({
user_id: r.user_id,
count: parseInt(r.count, 10),
})),
};
}
/**
* Detect changed fields between old and new values
*/
private detectChangedFields(
oldValues?: Record<string, any>,
newValues?: Record<string, any>,
): string[] {
if (!oldValues || !newValues) return [];
const changedFields: string[] = [];
const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]);
for (const key of allKeys) {
if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValues[key])) {
changedFields.push(key);
}
}
return changedFields;
}
}

View File

@ -0,0 +1 @@
export * from './audit.service';

View File

@ -0,0 +1,240 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from '../auth.controller';
import { AuthService } from '../services/auth.service';
import { MfaService } from '../services/mfa.service';
import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto';
describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<AuthService>;
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440000',
tenant_id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User',
};
const mockAuthResponse = {
user: mockUser,
accessToken: 'access_token',
refreshToken: 'refresh_token',
};
const mockRequestUser = {
id: mockUser.id,
email: mockUser.email,
tenant_id: mockUser.tenant_id,
};
const mockRequest = {
ip: '127.0.0.1',
headers: {
'user-agent': 'test-agent',
'x-tenant-id': mockUser.tenant_id,
},
};
beforeEach(async () => {
const mockAuthService = {
register: jest.fn(),
login: jest.fn(),
logout: jest.fn(),
logoutAll: jest.fn(),
refreshToken: jest.fn(),
changePassword: jest.fn(),
requestPasswordReset: jest.fn(),
resetPassword: jest.fn(),
verifyEmail: jest.fn(),
getProfile: jest.fn(),
};
const mockMfaService = {
generateSecret: jest.fn(),
verifyToken: jest.fn(),
enableMfa: jest.fn(),
disableMfa: jest.fn(),
generateBackupCodes: jest.fn(),
verifyBackupCode: jest.fn(),
isMfaEnabled: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: MfaService, useValue: mockMfaService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
authService = module.get(AuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
it('should register a new user', async () => {
const registerDto: RegisterDto = {
email: 'new@example.com',
password: 'Password123!',
first_name: 'New',
last_name: 'User',
};
authService.register.mockResolvedValue(mockAuthResponse);
const result = await controller.register(registerDto, mockUser.tenant_id, mockRequest as any);
expect(result).toEqual(mockAuthResponse);
expect(authService.register).toHaveBeenCalledWith(
registerDto,
mockUser.tenant_id,
mockRequest.ip,
mockRequest.headers['user-agent'],
);
});
});
describe('login', () => {
it('should login user', async () => {
const loginDto: LoginDto = {
email: 'test@example.com',
password: 'password123',
};
authService.login.mockResolvedValue(mockAuthResponse);
const result = await controller.login(loginDto, mockUser.tenant_id, mockRequest as any);
expect(result).toEqual(mockAuthResponse);
expect(authService.login).toHaveBeenCalledWith(
loginDto,
mockUser.tenant_id,
mockRequest.ip,
mockRequest.headers['user-agent'],
);
});
});
describe('logout', () => {
it('should logout user', async () => {
authService.logout.mockResolvedValue(undefined);
const result = await controller.logout(mockRequestUser, 'session_token');
expect(result).toEqual({ message: 'Sesión cerrada correctamente' });
expect(authService.logout).toHaveBeenCalledWith(
mockRequestUser.id,
'session_token',
);
});
});
describe('logoutAll', () => {
it('should logout all sessions', async () => {
authService.logoutAll.mockResolvedValue(undefined);
const result = await controller.logoutAll(mockRequestUser);
expect(result).toEqual({ message: 'Todas las sesiones cerradas' });
expect(authService.logoutAll).toHaveBeenCalledWith(mockRequestUser.id);
});
});
describe('refresh', () => {
it('should refresh tokens', async () => {
const newTokens = {
accessToken: 'new_access_token',
refreshToken: 'new_refresh_token',
};
authService.refreshToken.mockResolvedValue(newTokens);
const result = await controller.refresh(
'old_refresh_token',
mockRequest as any,
);
expect(result).toEqual(newTokens);
});
});
describe('changePassword', () => {
it('should change password', async () => {
const changePasswordDto: ChangePasswordDto = {
currentPassword: 'oldPassword',
newPassword: 'newPassword',
};
authService.changePassword.mockResolvedValue({
message: 'Password actualizado correctamente',
});
const result = await controller.changePassword(
mockRequestUser,
changePasswordDto,
);
expect(result.message).toBe('Password actualizado correctamente');
expect(authService.changePassword).toHaveBeenCalledWith(
mockRequestUser.id,
changePasswordDto,
);
});
});
describe('requestPasswordReset', () => {
it('should request password reset', async () => {
authService.requestPasswordReset.mockResolvedValue({
message: 'Si el email existe, recibirás instrucciones',
});
const result = await controller.requestPasswordReset(
{ email: 'test@example.com' },
mockRequest as any,
);
expect(result).toHaveProperty('message');
});
});
describe('resetPassword', () => {
it('should reset password', async () => {
authService.resetPassword.mockResolvedValue({
message: 'Password restablecido correctamente',
});
const result = await controller.resetPassword(
{ token: 'reset_token', password: 'newPassword123' },
mockRequest as any,
);
expect(result.message).toBe('Password restablecido correctamente');
});
});
describe('verifyEmail', () => {
it('should verify email', async () => {
authService.verifyEmail.mockResolvedValue({
message: 'Email verificado correctamente',
});
const result = await controller.verifyEmail('verification_token', mockRequest as any);
expect(result.message).toBe('Email verificado correctamente');
});
});
describe('getProfile', () => {
it('should get user profile', async () => {
authService.getProfile.mockResolvedValue(mockUser);
const result = await controller.getProfile(mockRequestUser);
expect(result).toEqual(mockUser);
expect(authService.getProfile).toHaveBeenCalledWith(mockRequestUser.id);
});
});
});

View File

@ -0,0 +1,845 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource, Repository } from 'typeorm';
import {
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from '../services/auth.service';
import { User, Session, Token } from '../entities';
// Mock bcrypt
jest.mock('bcrypt');
const mockedBcrypt = bcrypt as jest.Mocked<typeof bcrypt>;
describe('AuthService', () => {
let service: AuthService;
let userRepository: jest.Mocked<Repository<User>>;
let sessionRepository: jest.Mocked<Repository<Session>>;
let tokenRepository: jest.Mocked<Repository<Token>>;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
const mockUser: Partial<User> = {
id: '550e8400-e29b-41d4-a716-446655440000',
tenant_id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
password_hash: 'hashed_password',
first_name: 'Test',
last_name: 'User',
status: 'active',
email_verified: true,
};
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
beforeEach(async () => {
const mockUserRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const mockSessionRepo = {
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const mockTokenRepo = {
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const mockJwtService = {
sign: jest.fn(),
verify: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const mockDataSource = {
createQueryRunner: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: getRepositoryToken(User), useValue: mockUserRepo },
{ provide: getRepositoryToken(Session), useValue: mockSessionRepo },
{ provide: getRepositoryToken(Token), useValue: mockTokenRepo },
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: DataSource, useValue: mockDataSource },
],
}).compile();
service = module.get<AuthService>(AuthService);
userRepository = module.get(getRepositoryToken(User));
sessionRepository = module.get(getRepositoryToken(Session));
tokenRepository = module.get(getRepositoryToken(Token));
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
const registerDto = {
email: 'newuser@example.com',
password: 'SecurePass123!',
first_name: 'New',
last_name: 'User',
};
it('should register a new user successfully', async () => {
userRepository.findOne.mockResolvedValue(null);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password');
userRepository.create.mockReturnValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
userRepository.save.mockResolvedValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
sessionRepository.save.mockResolvedValue({} as Session);
tokenRepository.save.mockResolvedValue({} as Token);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
const result = await service.register(registerDto, mockTenantId);
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(userRepository.findOne).toHaveBeenCalled();
expect(userRepository.save).toHaveBeenCalled();
});
it('should throw ConflictException if email already exists', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
await expect(
service.register(registerDto, mockTenantId),
).rejects.toThrow(ConflictException);
});
});
describe('login', () => {
const loginDto = {
email: 'test@example.com',
password: 'password123',
};
it('should login user successfully', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
const result = await service.login(loginDto, mockTenantId);
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('refreshToken');
expect(result.user).not.toHaveProperty('password_hash');
});
it('should throw UnauthorizedException for invalid email', async () => {
userRepository.findOne.mockResolvedValue(null);
await expect(
service.login(loginDto, mockTenantId),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for invalid password', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(false);
await expect(
service.login(loginDto, mockTenantId),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for suspended user', async () => {
userRepository.findOne.mockResolvedValue({
...mockUser,
status: 'suspended',
} as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
await expect(
service.login(loginDto, mockTenantId),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for inactive user', async () => {
userRepository.findOne.mockResolvedValue({
...mockUser,
status: 'inactive',
} as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
await expect(
service.login(loginDto, mockTenantId),
).rejects.toThrow(UnauthorizedException);
});
});
describe('logout', () => {
it('should invalidate session successfully', async () => {
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
await service.logout(mockUser.id!, 'session_token');
expect(sessionRepository.update).toHaveBeenCalledWith(
{ user_id: mockUser.id, session_token: 'session_token' },
{ is_active: false },
);
});
});
describe('logoutAll', () => {
it('should invalidate all sessions for user', async () => {
sessionRepository.update.mockResolvedValue({ affected: 3 } as any);
await service.logoutAll(mockUser.id!);
expect(sessionRepository.update).toHaveBeenCalledWith(
{ user_id: mockUser.id },
{ is_active: false },
);
});
});
describe('changePassword', () => {
const changePasswordDto = {
currentPassword: 'oldPassword123',
newPassword: 'newPassword456',
};
it('should change password successfully', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password');
userRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.changePassword(mockUser.id!, changePasswordDto);
expect(result).toHaveProperty('message');
expect(userRepository.update).toHaveBeenCalled();
});
it('should throw NotFoundException if user not found', async () => {
userRepository.findOne.mockResolvedValue(null);
await expect(
service.changePassword('invalid-id', changePasswordDto),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException for incorrect current password', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(false);
await expect(
service.changePassword(mockUser.id!, changePasswordDto),
).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException if new password same as current', async () => {
const samePasswordDto = {
currentPassword: 'samePassword',
newPassword: 'samePassword',
};
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
await expect(
service.changePassword(mockUser.id!, samePasswordDto),
).rejects.toThrow(BadRequestException);
});
});
describe('requestPasswordReset', () => {
it('should create reset token for existing user', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
tokenRepository.save.mockResolvedValue({} as Token);
const result = await service.requestPasswordReset(mockUser.email!, mockTenantId);
expect(result).toHaveProperty('message');
expect(tokenRepository.save).toHaveBeenCalled();
});
it('should return success message even for non-existing email (security)', async () => {
userRepository.findOne.mockResolvedValue(null);
const result = await service.requestPasswordReset('nonexistent@example.com', mockTenantId);
expect(result).toHaveProperty('message');
expect(tokenRepository.save).not.toHaveBeenCalled();
});
});
describe('resetPassword', () => {
const mockToken = {
id: 'token-id',
user_id: mockUser.id,
tenant_id: mockTenantId,
token_type: 'password_reset',
is_used: false,
expires_at: new Date(Date.now() + 3600000), // 1 hour from now
};
it('should reset password successfully', async () => {
tokenRepository.findOne.mockResolvedValue(mockToken as Token);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password');
userRepository.update.mockResolvedValue({ affected: 1 } as any);
tokenRepository.update.mockResolvedValue({ affected: 1 } as any);
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.resetPassword('valid_token', 'newPassword123', mockTenantId);
expect(result).toHaveProperty('message');
expect(userRepository.update).toHaveBeenCalled();
expect(tokenRepository.update).toHaveBeenCalled();
});
it('should throw BadRequestException for invalid token', async () => {
tokenRepository.findOne.mockResolvedValue(null);
await expect(
service.resetPassword('invalid_token', 'newPassword123', mockTenantId),
).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException for expired token', async () => {
tokenRepository.findOne.mockResolvedValue({
...mockToken,
expires_at: new Date(Date.now() - 3600000), // 1 hour ago
} as Token);
await expect(
service.resetPassword('expired_token', 'newPassword123', mockTenantId),
).rejects.toThrow(BadRequestException);
});
});
describe('verifyEmail', () => {
const mockToken = {
id: 'token-id',
user_id: mockUser.id,
tenant_id: mockTenantId,
token_type: 'email_verification',
is_used: false,
expires_at: new Date(Date.now() + 3600000),
};
it('should verify email successfully', async () => {
tokenRepository.findOne.mockResolvedValue(mockToken as Token);
userRepository.update.mockResolvedValue({ affected: 1 } as any);
tokenRepository.update.mockResolvedValue({ affected: 1 } as any);
const result = await service.verifyEmail('valid_token', mockTenantId);
expect(result).toHaveProperty('message');
expect(userRepository.update).toHaveBeenCalledWith(
{ id: mockToken.user_id },
expect.objectContaining({
email_verified: true,
status: 'active',
}),
);
});
it('should throw BadRequestException for invalid token', async () => {
tokenRepository.findOne.mockResolvedValue(null);
await expect(
service.verifyEmail('invalid_token', mockTenantId),
).rejects.toThrow(BadRequestException);
});
});
describe('validateUser', () => {
it('should return user if active', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
const result = await service.validateUser(mockUser.id!);
expect(result).toEqual(mockUser);
});
it('should return null if user not found or not active', async () => {
userRepository.findOne.mockResolvedValue(null);
const result = await service.validateUser('invalid-id');
expect(result).toBeNull();
});
});
describe('getProfile', () => {
it('should return sanitized user profile', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
const result = await service.getProfile(mockUser.id!);
expect(result).not.toHaveProperty('password_hash');
expect(result).toHaveProperty('email');
});
it('should throw NotFoundException if user not found', async () => {
userRepository.findOne.mockResolvedValue(null);
await expect(
service.getProfile('invalid-id'),
).rejects.toThrow(NotFoundException);
});
});
describe('refreshToken', () => {
const mockSession = {
id: 'session-id',
user_id: mockUser.id,
tenant_id: mockTenantId,
refresh_token_hash: 'hashed_refresh_token',
is_active: true,
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
};
it('should refresh tokens successfully with valid refresh token', async () => {
jwtService.verify.mockReturnValue({
sub: mockUser.id,
email: mockUser.email,
tenant_id: mockTenantId,
});
configService.get.mockReturnValue('test-secret');
userRepository.findOne.mockResolvedValue(mockUser as User);
sessionRepository.findOne.mockResolvedValue(mockSession as Session);
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
jwtService.sign.mockReturnValueOnce('new_access_token').mockReturnValueOnce('new_refresh_token');
const result = await service.refreshToken('valid_refresh_token', '127.0.0.1', 'Mozilla/5.0');
expect(result).toHaveProperty('accessToken', 'new_access_token');
expect(result).toHaveProperty('refreshToken', 'new_refresh_token');
expect(jwtService.verify).toHaveBeenCalled();
expect(sessionRepository.update).toHaveBeenCalled();
});
it('should throw UnauthorizedException when user not found', async () => {
jwtService.verify.mockReturnValue({
sub: 'non-existent-user-id',
email: 'test@example.com',
tenant_id: mockTenantId,
});
configService.get.mockReturnValue('test-secret');
userRepository.findOne.mockResolvedValue(null);
await expect(
service.refreshToken('valid_token_but_user_deleted'),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException when session not found', async () => {
jwtService.verify.mockReturnValue({
sub: mockUser.id,
email: mockUser.email,
tenant_id: mockTenantId,
});
configService.get.mockReturnValue('test-secret');
userRepository.findOne.mockResolvedValue(mockUser as User);
sessionRepository.findOne.mockResolvedValue(null);
await expect(
service.refreshToken('token_with_no_session'),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException when session is expired', async () => {
const expiredSession = {
...mockSession,
expires_at: new Date(Date.now() - 3600000), // 1 hour ago
};
jwtService.verify.mockReturnValue({
sub: mockUser.id,
email: mockUser.email,
tenant_id: mockTenantId,
});
configService.get.mockReturnValue('test-secret');
userRepository.findOne.mockResolvedValue(mockUser as User);
sessionRepository.findOne.mockResolvedValue(expiredSession as Session);
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
await expect(
service.refreshToken('token_with_expired_session'),
).rejects.toThrow(UnauthorizedException);
});
it('should throw UnauthorizedException for invalid JWT token', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('Invalid token');
});
configService.get.mockReturnValue('test-secret');
await expect(
service.refreshToken('invalid_jwt_token'),
).rejects.toThrow(UnauthorizedException);
});
it('should deactivate expired session when detected', async () => {
const expiredSession = {
...mockSession,
expires_at: new Date(Date.now() - 3600000),
};
jwtService.verify.mockReturnValue({
sub: mockUser.id,
email: mockUser.email,
tenant_id: mockTenantId,
});
configService.get.mockReturnValue('test-secret');
userRepository.findOne.mockResolvedValue(mockUser as User);
sessionRepository.findOne.mockResolvedValue(expiredSession as Session);
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
await expect(
service.refreshToken('token'),
).rejects.toThrow(UnauthorizedException);
expect(sessionRepository.update).toHaveBeenCalledWith(
{ id: expiredSession.id },
{ is_active: false },
);
});
});
describe('verifyEmail - additional cases', () => {
const mockToken = {
id: 'token-id',
user_id: mockUser.id,
tenant_id: mockTenantId,
token_type: 'email_verification',
is_used: false,
expires_at: new Date(Date.now() + 3600000),
};
it('should throw BadRequestException for expired verification token', async () => {
tokenRepository.findOne.mockResolvedValue({
...mockToken,
expires_at: new Date(Date.now() - 3600000), // 1 hour ago
} as Token);
await expect(
service.verifyEmail('expired_verification_token', mockTenantId),
).rejects.toThrow(BadRequestException);
});
});
describe('register - additional cases', () => {
const registerDto = {
email: 'newuser@example.com',
password: 'SecurePass123!',
};
it('should register user with IP and userAgent metadata', async () => {
userRepository.findOne.mockResolvedValue(null);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password');
userRepository.create.mockReturnValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
userRepository.save.mockResolvedValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
sessionRepository.save.mockResolvedValue({} as Session);
tokenRepository.save.mockResolvedValue({} as Token);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
const ip = '192.168.1.100';
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
const result = await service.register(registerDto, mockTenantId, ip, userAgent);
expect(result).toHaveProperty('user');
expect(result).toHaveProperty('accessToken');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
ip_address: ip,
user_agent: userAgent,
}),
);
});
it('should register user without optional fields', async () => {
userRepository.findOne.mockResolvedValue(null);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password');
userRepository.create.mockReturnValue({
...mockUser,
email: registerDto.email,
first_name: null,
last_name: null,
phone: null,
status: 'pending_verification',
} as User);
userRepository.save.mockResolvedValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
sessionRepository.save.mockResolvedValue({} as Session);
tokenRepository.save.mockResolvedValue({} as Token);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
const result = await service.register(registerDto, mockTenantId);
expect(result).toHaveProperty('user');
expect(userRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
first_name: null,
last_name: null,
phone: null,
}),
);
});
it('should create email verification token on registration', async () => {
userRepository.findOne.mockResolvedValue(null);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password');
userRepository.create.mockReturnValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
userRepository.save.mockResolvedValue({
...mockUser,
email: registerDto.email,
status: 'pending_verification',
} as User);
sessionRepository.save.mockResolvedValue({} as Session);
tokenRepository.save.mockResolvedValue({} as Token);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.register(registerDto, mockTenantId);
expect(tokenRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
token_type: 'email_verification',
}),
);
});
});
describe('login - additional cases', () => {
const loginDto = {
email: 'test@example.com',
password: 'password123',
};
it('should update last_login_at and last_login_ip on successful login', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
const ip = '10.0.0.1';
await service.login(loginDto, mockTenantId, ip);
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
last_login_ip: ip,
}),
);
});
it('should detect device type from userAgent - mobile', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (iPhone; CPU iPhone OS)');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
device_type: 'mobile',
}),
);
});
it('should detect device type from userAgent - tablet', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (iPad; CPU OS)');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
device_type: 'tablet',
}),
);
});
it('should detect device type from userAgent - desktop', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
device_type: 'desktop',
}),
);
});
it('should detect device type as unknown when no userAgent', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.login(loginDto, mockTenantId, '127.0.0.1');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
device_type: 'unknown',
}),
);
});
it('should login with Android device', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
(mockedBcrypt.compare as jest.Mock).mockResolvedValue(true);
userRepository.save.mockResolvedValue(mockUser as User);
sessionRepository.save.mockResolvedValue({} as Session);
jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token');
configService.get.mockReturnValue('15m');
await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (Linux; Android 10)');
expect(sessionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
device_type: 'mobile',
}),
);
});
});
describe('requestPasswordReset - additional cases', () => {
it('should create token with correct expiry time (1 hour)', async () => {
const beforeCall = Date.now();
userRepository.findOne.mockResolvedValue(mockUser as User);
tokenRepository.save.mockResolvedValue({} as Token);
await service.requestPasswordReset(mockUser.email!, mockTenantId);
const savedToken = tokenRepository.save.mock.calls[0][0];
expect(savedToken.expires_at).toBeDefined();
const expiryTime = (savedToken.expires_at as Date).getTime();
const expectedMinExpiry = beforeCall + 60 * 60 * 1000 - 1000; // 1 hour minus 1 second tolerance
const expectedMaxExpiry = beforeCall + 60 * 60 * 1000 + 1000; // 1 hour plus 1 second tolerance
expect(expiryTime).toBeGreaterThanOrEqual(expectedMinExpiry);
expect(expiryTime).toBeLessThanOrEqual(expectedMaxExpiry);
expect(savedToken.token_type).toBe('password_reset');
});
});
describe('resetPassword - additional cases', () => {
const mockToken = {
id: 'token-id',
user_id: mockUser.id,
tenant_id: mockTenantId,
token_type: 'password_reset',
is_used: false,
expires_at: new Date(Date.now() + 3600000),
};
it('should invalidate all sessions after password reset', async () => {
tokenRepository.findOne.mockResolvedValue(mockToken as Token);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password');
userRepository.update.mockResolvedValue({ affected: 1 } as any);
tokenRepository.update.mockResolvedValue({ affected: 1 } as any);
sessionRepository.update.mockResolvedValue({ affected: 3 } as any);
await service.resetPassword('valid_token', 'newPassword123', mockTenantId);
expect(sessionRepository.update).toHaveBeenCalledWith(
{ user_id: mockToken.user_id },
{ is_active: false },
);
});
it('should mark token as used with timestamp', async () => {
const beforeCall = Date.now();
tokenRepository.findOne.mockResolvedValue(mockToken as Token);
(mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password');
userRepository.update.mockResolvedValue({ affected: 1 } as any);
tokenRepository.update.mockResolvedValue({ affected: 1 } as any);
sessionRepository.update.mockResolvedValue({ affected: 1 } as any);
await service.resetPassword('valid_token', 'newPassword123', mockTenantId);
expect(tokenRepository.update).toHaveBeenCalledWith(
{ id: mockToken.id },
expect.objectContaining({
is_used: true,
}),
);
const updateCall = tokenRepository.update.mock.calls[0][1];
expect(updateCall.used_at).toBeDefined();
expect((updateCall.used_at as Date).getTime()).toBeGreaterThanOrEqual(beforeCall);
});
});
describe('validateUser - additional cases', () => {
it('should query for active users only', async () => {
userRepository.findOne.mockResolvedValue(mockUser as User);
await service.validateUser(mockUser.id!);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: mockUser.id, status: 'active' },
});
});
});
});

View File

@ -0,0 +1,81 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { JwtStrategy, RequestUser } from '../strategies/jwt.strategy';
import { AuthService } from '../services/auth.service';
describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let authService: jest.Mocked<AuthService>;
const mockUser = {
id: '550e8400-e29b-41d4-a716-446655440000',
tenant_id: '550e8400-e29b-41d4-a716-446655440001',
email: 'test@example.com',
first_name: 'Test',
last_name: 'User',
status: 'active',
};
const mockPayload = {
sub: mockUser.id,
email: mockUser.email,
tenant_id: mockUser.tenant_id,
};
beforeEach(async () => {
const mockAuthService = {
validateUser: jest.fn(),
};
const mockConfigService = {
get: jest.fn().mockReturnValue('test-secret'),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
JwtStrategy,
{ provide: AuthService, useValue: mockAuthService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
strategy = module.get<JwtStrategy>(JwtStrategy);
authService = module.get(AuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('validate', () => {
it('should return RequestUser for valid payload', async () => {
authService.validateUser.mockResolvedValue(mockUser as any);
const result: RequestUser = await strategy.validate(mockPayload);
expect(result).toEqual({
id: mockUser.id,
email: mockUser.email,
tenant_id: mockUser.tenant_id,
});
expect(authService.validateUser).toHaveBeenCalledWith(mockPayload.sub);
});
it('should throw UnauthorizedException for invalid user', async () => {
authService.validateUser.mockResolvedValue(null);
await expect(strategy.validate(mockPayload)).rejects.toThrow(
UnauthorizedException,
);
});
it('should include tenant_id from payload in result', async () => {
authService.validateUser.mockResolvedValue(mockUser as any);
const result = await strategy.validate(mockPayload);
expect(result.tenant_id).toBe(mockPayload.tenant_id);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,281 @@
import {
Controller,
Post,
Body,
Get,
UseGuards,
Req,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { AuthService, AuthResponse } from './services/auth.service';
import { MfaService } from './services/mfa.service';
import {
LoginDto,
RegisterDto,
RequestPasswordResetDto,
ResetPasswordDto,
ChangePasswordDto,
} from './dto';
import {
SetupMfaResponseDto,
VerifyMfaSetupDto,
DisableMfaDto,
MfaStatusDto,
RegenerateBackupCodesDto,
BackupCodesResponseDto,
} from './dto/mfa.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { CurrentTenant } from './decorators/tenant.decorator';
import { RequestUser } from './strategies/jwt.strategy';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly mfaService: MfaService,
) {}
@Post('register')
@Public()
@ApiOperation({ summary: 'Register new user' })
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 409, description: 'Email already exists' })
async register(
@Body() dto: RegisterDto,
@CurrentTenant() tenantId: string,
@Req() req: Request,
): Promise<AuthResponse> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
return this.authService.register(
dto,
tenantId,
req.ip,
req.headers['user-agent'],
);
}
@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login user' })
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(
@Body() dto: LoginDto,
@CurrentTenant() tenantId: string,
@Req() req: Request,
): Promise<AuthResponse> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
return this.authService.login(
dto,
tenantId,
req.ip,
req.headers['user-agent'],
);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout user' })
@ApiResponse({ status: 200, description: 'Logout successful' })
async logout(
@CurrentUser() user: RequestUser,
@Body('sessionToken') sessionToken: string,
): Promise<{ message: string }> {
await this.authService.logout(user.id, sessionToken);
return { message: 'Sesión cerrada correctamente' };
}
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout all sessions' })
@ApiResponse({ status: 200, description: 'All sessions closed' })
async logoutAll(@CurrentUser() user: RequestUser): Promise<{ message: string }> {
await this.authService.logoutAll(user.id);
return { message: 'Todas las sesiones cerradas' };
}
@Post('refresh')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 200, description: 'Token refreshed' })
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refresh(
@Body('refreshToken') refreshToken: string,
@Req() req: Request,
): Promise<{ accessToken: string; refreshToken: string }> {
return this.authService.refreshToken(
refreshToken,
req.ip,
req.headers['user-agent'],
);
}
@Post('password/request-reset')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request password reset' })
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 200, description: 'Reset email sent if user exists' })
async requestPasswordReset(
@Body() dto: RequestPasswordResetDto,
@CurrentTenant() tenantId: string,
): Promise<{ message: string }> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
return this.authService.requestPasswordReset(dto.email, tenantId);
}
@Post('password/reset')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset password with token' })
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 200, description: 'Password reset successful' })
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
async resetPassword(
@Body() dto: ResetPasswordDto,
@CurrentTenant() tenantId: string,
): Promise<{ message: string }> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
return this.authService.resetPassword(dto.token, dto.password, tenantId);
}
@Post('password/change')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Change password' })
@ApiResponse({ status: 200, description: 'Password changed' })
@ApiResponse({ status: 400, description: 'Invalid current password' })
async changePassword(
@CurrentUser() user: RequestUser,
@Body() dto: ChangePasswordDto,
): Promise<{ message: string }> {
return this.authService.changePassword(user.id, dto);
}
@Post('verify-email')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify email with token' })
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 200, description: 'Email verified' })
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
async verifyEmail(
@Body('token') token: string,
@CurrentTenant() tenantId: string,
): Promise<{ message: string }> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
return this.authService.verifyEmail(token, tenantId);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, description: 'Current user profile' })
async getProfile(@CurrentUser() user: RequestUser) {
return this.authService.getProfile(user.id);
}
// ==================== MFA Endpoints ====================
@Get('mfa/status')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get MFA status for current user' })
@ApiResponse({ status: 200, description: 'MFA status', type: MfaStatusDto })
async getMfaStatus(@CurrentUser() user: RequestUser): Promise<MfaStatusDto> {
return this.mfaService.getMfaStatus(user.id);
}
@Post('mfa/setup')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Initialize MFA setup - get QR code and secret' })
@ApiResponse({ status: 200, description: 'MFA setup data', type: SetupMfaResponseDto })
@ApiResponse({ status: 400, description: 'MFA already enabled' })
async setupMfa(@CurrentUser() user: RequestUser): Promise<SetupMfaResponseDto> {
return this.mfaService.setupMfa(user.id);
}
@Post('mfa/verify-setup')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Verify TOTP code and enable MFA' })
@ApiResponse({ status: 200, description: 'MFA enabled successfully' })
@ApiResponse({ status: 400, description: 'Invalid code or MFA already enabled' })
async verifyMfaSetup(
@CurrentUser() user: RequestUser,
@Body() dto: VerifyMfaSetupDto,
): Promise<{ success: boolean; message: string }> {
return this.mfaService.verifyMfaSetup(user.id, dto);
}
@Post('mfa/disable')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Disable MFA (requires password and code)' })
@ApiResponse({ status: 200, description: 'MFA disabled successfully' })
@ApiResponse({ status: 400, description: 'Invalid code or MFA not enabled' })
@ApiResponse({ status: 401, description: 'Invalid password' })
async disableMfa(
@CurrentUser() user: RequestUser,
@Body() dto: DisableMfaDto,
): Promise<{ success: boolean; message: string }> {
return this.mfaService.disableMfa(user.id, dto);
}
@Post('mfa/backup-codes/regenerate')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Regenerate backup codes (requires password and code)' })
@ApiResponse({ status: 200, description: 'New backup codes generated', type: BackupCodesResponseDto })
@ApiResponse({ status: 400, description: 'Invalid code or MFA not enabled' })
@ApiResponse({ status: 401, description: 'Invalid password' })
async regenerateBackupCodes(
@CurrentUser() user: RequestUser,
@Body() dto: RegenerateBackupCodesDto,
): Promise<BackupCodesResponseDto> {
return this.mfaService.regenerateBackupCodes(user.id, dto.password, dto.code);
}
}

View File

@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
// Entities
import { User, Session, Token, OAuthConnection } from './entities';
// Services
import { AuthService } from './services/auth.service';
import { OAuthService } from './services/oauth.service';
import { MfaService } from './services/mfa.service';
// Controllers
import { AuthController } from './auth.controller';
import { OAuthController } from './controllers/oauth.controller';
// Strategies
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
// Passport
PassportModule.register({ defaultStrategy: 'jwt' }),
// JWT
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('jwt.secret'),
signOptions: {
expiresIn: (configService.get<string>('jwt.expiresIn') || '15m') as any,
},
}),
inject: [ConfigService],
}),
// TypeORM entities
TypeOrmModule.forFeature([User, Session, Token, OAuthConnection]),
],
controllers: [AuthController, OAuthController],
providers: [AuthService, OAuthService, MfaService, JwtStrategy],
exports: [AuthService, OAuthService, MfaService, JwtModule, PassportModule],
})
export class AuthModule {}

View File

@ -0,0 +1 @@
export * from './oauth.controller';

View File

@ -0,0 +1,278 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
Req,
Res,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiHeader,
ApiParam,
} from '@nestjs/swagger';
import {
OAuthService,
OAuthProfile,
OAuthTokens,
OAuthUrlResponse,
OAuthConnectionResponse,
} from '../services/oauth.service';
import { AuthResponse } from '../services/auth.service';
import { OAuthProvider } from '../entities/oauth-provider.enum';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { Public } from '../decorators/public.decorator';
import { CurrentUser } from '../decorators/current-user.decorator';
import { CurrentTenant } from '../decorators/tenant.decorator';
import { RequestUser } from '../strategies/jwt.strategy';
@ApiTags('auth/oauth')
@Controller('auth/oauth')
export class OAuthController {
constructor(
private readonly oauthService: OAuthService,
private readonly configService: ConfigService,
) {}
@Get(':provider/url')
@Public()
@ApiOperation({ summary: 'Get OAuth authorization URL' })
@ApiParam({
name: 'provider',
enum: OAuthProvider,
description: 'OAuth provider',
})
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({
status: 200,
description: 'Authorization URL returned',
schema: {
type: 'object',
properties: {
url: { type: 'string' },
state: { type: 'string' },
},
},
})
@ApiResponse({ status: 400, description: 'Invalid provider or missing tenant' })
getAuthorizationUrl(
@Param('provider') provider: string,
@CurrentTenant() tenantId: string,
): OAuthUrlResponse {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
const validProvider = this.validateProvider(provider);
return this.oauthService.getAuthorizationUrl(validProvider, tenantId);
}
/**
* Apple OAuth form_post callback handler
* Apple uses response_mode=form_post, which sends data via POST body
* This endpoint receives the data and redirects to the frontend callback page
*/
@Post('apple/form-callback')
@Public()
@ApiOperation({ summary: 'Handle Apple OAuth form_post callback' })
@ApiResponse({ status: 302, description: 'Redirect to frontend callback' })
async handleAppleFormCallback(
@Body() body: { code?: string; state?: string; id_token?: string; user?: string; error?: string },
@Res() res: Response,
): Promise<void> {
const frontendCallbackUrl = this.configService.get<string>('oauth.frontendCallbackUrl');
// Handle OAuth error from Apple
if (body.error) {
const errorUrl = `${frontendCallbackUrl}/apple?error=${encodeURIComponent(body.error)}`;
res.redirect(errorUrl);
return;
}
// Build redirect URL with OAuth data
const params = new URLSearchParams();
if (body.code) params.append('code', body.code);
if (body.state) params.append('state', body.state);
if (body.id_token) params.append('id_token', body.id_token);
if (body.user) params.append('user', body.user); // Only sent on first authorization
res.redirect(`${frontendCallbackUrl}/apple?${params.toString()}`);
}
@Post(':provider/callback')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Handle OAuth callback' })
@ApiParam({
name: 'provider',
enum: OAuthProvider,
description: 'OAuth provider',
})
@ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' })
@ApiResponse({ status: 200, description: 'OAuth login successful' })
@ApiResponse({ status: 400, description: 'Invalid callback data' })
@ApiResponse({ status: 401, description: 'OAuth authentication failed' })
async handleCallback(
@Param('provider') provider: string,
@Body() body: {
code: string;
state?: string;
profile?: OAuthProfile;
tokens?: OAuthTokens;
// Apple-specific params
id_token?: string;
user?: string; // JSON string with name/email (only on first auth)
},
@CurrentTenant() tenantId: string,
@Req() req: Request,
): Promise<AuthResponse> {
if (!tenantId) {
throw new BadRequestException('Tenant ID es requerido');
}
const validProvider = this.validateProvider(provider);
// Handle Apple OAuth - process id_token and user data
if (validProvider === OAuthProvider.APPLE) {
return this.oauthService.handleAppleOAuth(
body.code,
body.id_token,
body.user,
tenantId,
req,
);
}
// For other providers, expect profile and tokens to be provided
if (!body.profile || !body.tokens) {
throw new BadRequestException(
'Se requiere profile y tokens del proveedor OAuth',
);
}
return this.oauthService.handleOAuthLogin(
validProvider,
body.profile,
body.tokens,
tenantId,
req,
);
}
@Get('connections')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user OAuth connections' })
@ApiResponse({
status: 200,
description: 'List of OAuth connections',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
provider: { type: 'string', enum: Object.values(OAuthProvider) },
provider_email: { type: 'string', nullable: true },
provider_name: { type: 'string', nullable: true },
provider_avatar_url: { type: 'string', nullable: true },
created_at: { type: 'string', format: 'date-time' },
last_used_at: { type: 'string', format: 'date-time', nullable: true },
},
},
},
})
async getConnections(
@CurrentUser() user: RequestUser,
): Promise<OAuthConnectionResponse[]> {
return this.oauthService.getConnections(user.id, user.tenant_id);
}
@Delete('connections/:provider')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Disconnect OAuth provider' })
@ApiParam({
name: 'provider',
enum: OAuthProvider,
description: 'OAuth provider to disconnect',
})
@ApiResponse({ status: 200, description: 'Provider disconnected' })
@ApiResponse({ status: 404, description: 'Connection not found' })
@ApiResponse({
status: 409,
description: 'Cannot disconnect only authentication method',
})
async disconnectProvider(
@Param('provider') provider: string,
@CurrentUser() user: RequestUser,
): Promise<{ message: string }> {
const validProvider = this.validateProvider(provider);
return this.oauthService.disconnectProvider(
user.id,
user.tenant_id,
validProvider,
);
}
@Post('connections/:provider/link')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED)
@ApiBearerAuth()
@ApiOperation({ summary: 'Link OAuth provider to existing account' })
@ApiParam({
name: 'provider',
enum: OAuthProvider,
description: 'OAuth provider to link',
})
@ApiResponse({ status: 201, description: 'Provider linked successfully' })
@ApiResponse({ status: 409, description: 'Provider already linked' })
async linkProvider(
@Param('provider') provider: string,
@Body() body: { profile: OAuthProfile; tokens: OAuthTokens },
@CurrentUser() user: RequestUser,
): Promise<OAuthConnectionResponse> {
const validProvider = this.validateProvider(provider);
if (!body.profile || !body.tokens) {
throw new BadRequestException(
'Se requiere profile y tokens del proveedor OAuth',
);
}
return this.oauthService.linkProvider(
user.id,
user.tenant_id,
validProvider,
body.profile,
body.tokens,
);
}
// ==================== Private Methods ====================
private validateProvider(provider: string): OAuthProvider {
const validProviders = Object.values(OAuthProvider);
const lowercaseProvider = provider.toLowerCase();
if (!validProviders.includes(lowercaseProvider as OAuthProvider)) {
throw new BadRequestException(
`Proveedor OAuth no válido: ${provider}. Proveedores soportados: ${validProviders.join(', ')}`,
);
}
return lowercaseProvider as OAuthProvider;
}
}

View File

@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { RequestUser } from '../strategies/jwt.strategy';
export const CurrentUser = createParamDecorator(
(data: keyof RequestUser | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as RequestUser;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@ -0,0 +1,4 @@
export * from './public.decorator';
export * from './current-user.decorator';
export * from './tenant.decorator';
export * from './roles.decorator';

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* Decorator to specify required roles for a route
* Usage: @Roles('admin', 'superadmin')
*/
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,27 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
// Get tenant from user (if authenticated)
if (request.user?.tenant_id) {
return request.user.tenant_id;
}
// Get tenant from header (for public routes)
const tenantId = request.headers['x-tenant-id'];
if (tenantId) {
return tenantId as string;
}
// Get tenant from subdomain (optional)
const host = request.headers.host || '';
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
return subdomain;
}
return '';
},
);

View File

@ -0,0 +1,3 @@
export * from './login.dto';
export * from './register.dto';
export * from './reset-password.dto';

View File

@ -0,0 +1,15 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email: string;
@ApiProperty({ example: 'password123' })
@IsString()
@IsNotEmpty({ message: 'Password es requerido' })
@MinLength(8, { message: 'Password debe tener al menos 8 caracteres' })
password: string;
}

View File

@ -0,0 +1,94 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsNotEmpty, Length, IsOptional, Matches } from 'class-validator';
export class SetupMfaResponseDto {
@ApiProperty({ description: 'Base32 encoded TOTP secret' })
secret: string;
@ApiProperty({ description: 'QR code data URI for authenticator apps' })
qrCodeDataUrl: string;
@ApiProperty({ description: 'Backup codes for account recovery', type: [String] })
backupCodes: string[];
}
export class VerifyMfaSetupDto {
@ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' })
@IsString()
@IsNotEmpty()
@Length(6, 6, { message: 'TOTP code must be exactly 6 digits' })
@Matches(/^\d{6}$/, { message: 'TOTP code must contain only digits' })
code: string;
@ApiProperty({ description: 'TOTP secret from setup step' })
@IsString()
@IsNotEmpty()
secret: string;
}
export class VerifyMfaLoginDto {
@ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' })
@IsString()
@IsNotEmpty()
@Length(6, 8, { message: 'Code must be 6-8 characters' })
code: string;
@ApiPropertyOptional({ description: 'If true, code is a backup code' })
@IsOptional()
isBackupCode?: boolean;
}
export class DisableMfaDto {
@ApiProperty({ description: 'Current password for confirmation' })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ description: 'TOTP code or backup code' })
@IsString()
@IsNotEmpty()
code: string;
}
export class MfaStatusDto {
@ApiProperty({ description: 'Whether MFA is enabled' })
enabled: boolean;
@ApiProperty({ description: 'When MFA was enabled', required: false })
enabledAt?: Date;
@ApiProperty({ description: 'Number of remaining backup codes' })
backupCodesRemaining: number;
}
export class MfaChallengeResponseDto {
@ApiProperty({ description: 'Temporary challenge token' })
challengeToken: string;
@ApiProperty({ description: 'Type of challenge', example: 'mfa_required' })
type: string;
@ApiProperty({ description: 'Challenge message' })
message: string;
}
export class RegenerateBackupCodesDto {
@ApiProperty({ description: 'Current password for confirmation' })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ description: 'TOTP code for verification' })
@IsString()
@IsNotEmpty()
@Length(6, 6)
code: string;
}
export class BackupCodesResponseDto {
@ApiProperty({ description: 'New backup codes', type: [String] })
backupCodes: string[];
@ApiProperty({ description: 'Message to user' })
message: string;
}

View File

@ -0,0 +1,41 @@
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
IsOptional,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@IsNotEmpty({ message: 'Password es requerido' })
@MinLength(8, { message: 'Password debe tener al menos 8 caracteres' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
{ message: 'Password debe contener mayúsculas, minúsculas y números' },
)
password: string;
@ApiPropertyOptional({ example: 'John' })
@IsOptional()
@IsString()
first_name?: string;
@ApiPropertyOptional({ example: 'Doe' })
@IsOptional()
@IsString()
last_name?: string;
@ApiPropertyOptional({ example: '+1234567890' })
@IsOptional()
@IsString()
phone?: string;
}

View File

@ -0,0 +1,43 @@
import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RequestPasswordResetDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail({}, { message: 'Email inválido' })
@IsNotEmpty({ message: 'Email es requerido' })
email: string;
}
export class ResetPasswordDto {
@ApiProperty({ example: 'abc123token' })
@IsString()
@IsNotEmpty({ message: 'Token es requerido' })
token: string;
@ApiProperty({ example: 'NewSecurePass123!' })
@IsString()
@IsNotEmpty({ message: 'Password es requerido' })
@MinLength(8, { message: 'Password debe tener al menos 8 caracteres' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
{ message: 'Password debe contener mayúsculas, minúsculas y números' },
)
password: string;
}
export class ChangePasswordDto {
@ApiProperty({ example: 'OldPassword123!' })
@IsString()
@IsNotEmpty({ message: 'Password actual es requerido' })
currentPassword: string;
@ApiProperty({ example: 'NewPassword456!' })
@IsString()
@IsNotEmpty({ message: 'Nuevo password es requerido' })
@MinLength(8, { message: 'Password debe tener al menos 8 caracteres' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
{ message: 'Password debe contener mayúsculas, minúsculas y números' },
)
newPassword: string;
}

View File

@ -0,0 +1,5 @@
export * from './user.entity';
export * from './session.entity';
export * from './token.entity';
export * from './oauth-provider.enum';
export * from './oauth-connection.entity';

View File

@ -0,0 +1,76 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
import { OAuthProvider } from './oauth-provider.enum';
@Entity({ schema: 'auth', name: 'oauth_connections' })
@Index(['tenant_id', 'user_id'])
@Index(['tenant_id', 'provider', 'provider_user_id'], { unique: true })
export class OAuthConnection {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
@Index()
tenant_id: string;
@Column({ type: 'uuid' })
@Index()
user_id: string;
@Column({
type: 'enum',
enum: OAuthProvider,
enumName: 'auth.oauth_provider',
})
provider: OAuthProvider;
@Column({ type: 'varchar', length: 255 })
provider_user_id: string;
@Column({ type: 'varchar', length: 255, nullable: true })
provider_email: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
provider_name: string | null;
@Column({ type: 'varchar', length: 500, nullable: true })
provider_avatar_url: string | null;
@Column({ type: 'text', nullable: true })
access_token: string | null;
@Column({ type: 'text', nullable: true })
refresh_token: string | null;
@Column({ type: 'timestamp with time zone', nullable: true })
token_expires_at: Date | null;
@Column({ type: 'jsonb', nullable: true })
scopes: string[] | null;
@Column({ type: 'jsonb', nullable: true })
raw_data: Record<string, any> | null;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
last_used_at: Date | null;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,9 @@
/**
* Enum for supported OAuth 2.0 providers
*/
export enum OAuthProvider {
GOOGLE = 'google',
MICROSOFT = 'microsoft',
GITHUB = 'github',
APPLE = 'apple',
}

View File

@ -0,0 +1,49 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'auth', name: 'sessions' })
export class Session {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
@Index()
user_id: string;
@Column({ type: 'uuid' })
@Index()
tenant_id: string;
@Column({ type: 'varchar', length: 64 })
@Index({ unique: true })
session_token: string;
@Column({ type: 'varchar', length: 64, nullable: true })
refresh_token_hash: string | null;
@Column({ type: 'varchar', length: 45, nullable: true })
ip_address: string | null;
@Column({ type: 'text', nullable: true })
user_agent: string | null;
@Column({ type: 'varchar', length: 50, nullable: true })
device_type: string | null;
@Column({ type: 'timestamp with time zone' })
expires_at: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
last_activity_at: Date | null;
@Column({ type: 'boolean', default: true })
is_active: boolean;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
}

View File

@ -0,0 +1,47 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'auth', name: 'tokens' })
export class Token {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
@Index()
user_id: string;
@Column({ type: 'uuid' })
@Index()
tenant_id: string;
@Column({
type: 'enum',
enum: ['email_verification', 'password_reset', 'invitation', 'api_key'],
enumName: 'auth.token_type',
})
token_type: string;
@Column({ type: 'varchar', length: 64 })
@Index({ unique: true })
token_hash: string;
@Column({ type: 'timestamp with time zone' })
expires_at: Date;
@Column({ type: 'boolean', default: false })
is_used: boolean;
@Column({ type: 'timestamp with time zone', nullable: true })
used_at: Date | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
}

View File

@ -0,0 +1,85 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
@Entity({ schema: 'users', name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
@Index()
tenant_id: string;
@Column({ type: 'varchar', length: 255 })
@Index()
email: string;
@Column({ type: 'varchar', length: 255 })
password_hash: string;
@Column({ type: 'varchar', length: 100, nullable: true })
first_name: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
last_name: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
avatar_url: string | null;
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string | null;
@Column({
type: 'enum',
enum: ['active', 'inactive', 'suspended', 'pending_verification'],
enumName: 'users.user_status',
default: 'pending_verification',
})
status: string;
@Column({ type: 'boolean', default: false })
email_verified: boolean;
@Column({ type: 'timestamp with time zone', nullable: true })
email_verified_at: Date | null;
@Column({ type: 'boolean', default: false })
mfa_enabled: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
mfa_secret: string | null;
@Column({ type: 'text', array: true, nullable: true })
mfa_backup_codes: string[] | null;
@Column({ type: 'timestamp with time zone', nullable: true })
mfa_enabled_at: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
last_login_at: Date | null;
@Column({ type: 'varchar', length: 45, nullable: true })
last_login_ip: string | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@CreateDateColumn({ type: 'timestamp with time zone' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp with time zone' })
updated_at: Date;
// Computed property
get fullName(): string {
return [this.first_name, this.last_name].filter(Boolean).join(' ');
}
}

View File

@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';

View File

@ -0,0 +1,28 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
return false;
}
return requiredRoles.some((role) => user.role === role);
}
}

View File

@ -0,0 +1,8 @@
export * from './auth.module';
export * from './auth.controller';
export * from './services';
export * from './entities';
export * from './dto';
export * from './guards';
export * from './decorators';
export * from './strategies';

View File

@ -0,0 +1,482 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User, Session, Token } from '../entities';
import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto';
export interface AuthResponse {
user: Partial<User>;
accessToken: string;
refreshToken: string;
}
export interface JwtPayload {
sub: string;
email: string;
tenant_id: string;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Session)
private readonly sessionRepository: Repository<Session>,
@InjectRepository(Token)
private readonly tokenRepository: Repository<Token>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly dataSource: DataSource,
) {}
/**
* Register new user with tenant context
*/
async register(
dto: RegisterDto,
tenantId: string,
ip?: string,
userAgent?: string,
): Promise<AuthResponse> {
// Check if email already exists for this tenant
const existing = await this.userRepository.findOne({
where: { email: dto.email, tenant_id: tenantId },
});
if (existing) {
throw new ConflictException('Email ya registrado en esta organización');
}
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 12);
// Create user
const user = this.userRepository.create({
tenant_id: tenantId,
email: dto.email,
password_hash: passwordHash,
first_name: dto.first_name || null,
last_name: dto.last_name || null,
phone: dto.phone || null,
status: 'pending_verification',
email_verified: false,
});
await this.userRepository.save(user);
// Generate tokens
const tokens = await this.generateTokens(user, ip, userAgent);
// Create email verification token
await this.createVerificationToken(user);
return {
user: this.sanitizeUser(user),
...tokens,
};
}
/**
* Login user
*/
async login(
dto: LoginDto,
tenantId: string,
ip?: string,
userAgent?: string,
): Promise<AuthResponse> {
// Find user
const user = await this.userRepository.findOne({
where: { email: dto.email, tenant_id: tenantId },
});
if (!user) {
throw new UnauthorizedException('Credenciales inválidas');
}
// Validate password
const isValid = await bcrypt.compare(dto.password, user.password_hash);
if (!isValid) {
throw new UnauthorizedException('Credenciales inválidas');
}
// Check user status
if (user.status === 'suspended') {
throw new UnauthorizedException('Cuenta suspendida');
}
if (user.status === 'inactive') {
throw new UnauthorizedException('Cuenta inactiva');
}
// Update last login
user.last_login_at = new Date();
user.last_login_ip = ip || null;
await this.userRepository.save(user);
// Generate tokens
const tokens = await this.generateTokens(user, ip, userAgent);
return {
user: this.sanitizeUser(user),
...tokens,
};
}
/**
* Logout user - invalidate session
*/
async logout(userId: string, sessionToken: string): Promise<void> {
await this.sessionRepository.update(
{ user_id: userId, session_token: sessionToken },
{ is_active: false },
);
}
/**
* Logout all sessions for user
*/
async logoutAll(userId: string): Promise<void> {
await this.sessionRepository.update(
{ user_id: userId },
{ is_active: false },
);
}
/**
* Refresh access token
*/
async refreshToken(
refreshToken: string,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string }> {
try {
// Verify refresh token
const payload = this.jwtService.verify<JwtPayload>(refreshToken, {
secret: this.configService.get<string>('jwt.secret'),
});
// Find user
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user) {
throw new UnauthorizedException('Usuario no encontrado');
}
// Find session with this refresh token
const refreshTokenHash = this.hashToken(refreshToken);
const session = await this.sessionRepository.findOne({
where: {
user_id: user.id,
refresh_token_hash: refreshTokenHash,
is_active: true,
},
});
if (!session) {
throw new UnauthorizedException('Sesión inválida');
}
// Check if session expired
if (new Date() > session.expires_at) {
await this.sessionRepository.update({ id: session.id }, { is_active: false });
throw new UnauthorizedException('Sesión expirada');
}
// Generate new tokens
const tokens = await this.generateTokens(user, ip, userAgent, session.id);
return tokens;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Token inválido');
}
}
/**
* Change password for authenticated user
*/
async changePassword(
userId: string,
dto: ChangePasswordDto,
): Promise<{ message: string }> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('Usuario no encontrado');
}
// Validate current password
const isValid = await bcrypt.compare(dto.currentPassword, user.password_hash);
if (!isValid) {
throw new BadRequestException('Password actual incorrecto');
}
// Validate new password is different
if (dto.currentPassword === dto.newPassword) {
throw new BadRequestException('El nuevo password debe ser diferente al actual');
}
// Hash new password
const newHash = await bcrypt.hash(dto.newPassword, 12);
// Update password
await this.userRepository.update({ id: userId }, { password_hash: newHash });
// Optionally invalidate all sessions except current
// await this.logoutAll(userId);
return { message: 'Password actualizado correctamente' };
}
/**
* Request password reset
*/
async requestPasswordReset(email: string, tenantId: string): Promise<{ message: string }> {
const user = await this.userRepository.findOne({
where: { email, tenant_id: tenantId },
});
// Always return success to prevent email enumeration
if (!user) {
return { message: 'Si el email existe, recibirás instrucciones para restablecer tu password' };
}
// Create reset token
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = this.hashToken(token);
await this.tokenRepository.save({
user_id: user.id,
tenant_id: tenantId,
token_type: 'password_reset',
token_hash: tokenHash,
expires_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
});
// TODO: Send email with reset link containing token
return { message: 'Si el email existe, recibirás instrucciones para restablecer tu password' };
}
/**
* Reset password with token
*/
async resetPassword(
token: string,
newPassword: string,
tenantId: string,
): Promise<{ message: string }> {
const tokenHash = this.hashToken(token);
const tokenRecord = await this.tokenRepository.findOne({
where: {
token_hash: tokenHash,
tenant_id: tenantId,
token_type: 'password_reset',
is_used: false,
},
});
if (!tokenRecord) {
throw new BadRequestException('Token inválido o expirado');
}
if (new Date() > tokenRecord.expires_at) {
throw new BadRequestException('Token expirado');
}
// Hash new password
const passwordHash = await bcrypt.hash(newPassword, 12);
// Update password
await this.userRepository.update(
{ id: tokenRecord.user_id },
{ password_hash: passwordHash },
);
// Mark token as used
await this.tokenRepository.update(
{ id: tokenRecord.id },
{ is_used: true, used_at: new Date() },
);
// Invalidate all sessions
await this.logoutAll(tokenRecord.user_id);
return { message: 'Password restablecido correctamente' };
}
/**
* Verify email with token
*/
async verifyEmail(token: string, tenantId: string): Promise<{ message: string }> {
const tokenHash = this.hashToken(token);
const tokenRecord = await this.tokenRepository.findOne({
where: {
token_hash: tokenHash,
tenant_id: tenantId,
token_type: 'email_verification',
is_used: false,
},
});
if (!tokenRecord) {
throw new BadRequestException('Token inválido o expirado');
}
if (new Date() > tokenRecord.expires_at) {
throw new BadRequestException('Token expirado');
}
// Update user
await this.userRepository.update(
{ id: tokenRecord.user_id },
{
email_verified: true,
email_verified_at: new Date(),
status: 'active',
},
);
// Mark token as used
await this.tokenRepository.update(
{ id: tokenRecord.id },
{ is_used: true, used_at: new Date() },
);
return { message: 'Email verificado correctamente' };
}
/**
* Validate user by ID (for JWT strategy)
*/
async validateUser(userId: string): Promise<User | null> {
return this.userRepository.findOne({
where: { id: userId, status: 'active' },
});
}
/**
* Get current user profile
*/
async getProfile(userId: string): Promise<Partial<User>> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('Usuario no encontrado');
}
return this.sanitizeUser(user);
}
// ==================== Private Methods ====================
private async generateTokens(
user: User,
ip?: string,
userAgent?: string,
existingSessionId?: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
tenant_id: user.tenant_id,
};
const accessTokenExpiry = this.configService.get<string>('jwt.expiresIn') || '15m';
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshExpiresIn') || '7d';
const accessToken = this.jwtService.sign(payload, {
expiresIn: accessTokenExpiry as any,
});
const refreshToken = this.jwtService.sign(payload, {
expiresIn: refreshTokenExpiry as any,
});
const sessionToken = crypto.randomBytes(32).toString('hex');
const refreshTokenHash = this.hashToken(refreshToken);
if (existingSessionId) {
// Update existing session
await this.sessionRepository.update(
{ id: existingSessionId },
{
refresh_token_hash: refreshTokenHash,
last_activity_at: new Date(),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
);
} else {
// Create new session
await this.sessionRepository.save({
user_id: user.id,
tenant_id: user.tenant_id,
session_token: sessionToken,
refresh_token_hash: refreshTokenHash,
ip_address: ip || null,
user_agent: userAgent || null,
device_type: this.detectDeviceType(userAgent),
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
last_activity_at: new Date(),
is_active: true,
});
}
return { accessToken, refreshToken };
}
private async createVerificationToken(user: User): Promise<string> {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = this.hashToken(token);
await this.tokenRepository.save({
user_id: user.id,
tenant_id: user.tenant_id,
token_type: 'email_verification',
token_hash: tokenHash,
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
});
return token;
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private sanitizeUser(user: User): Partial<User> {
const { password_hash, ...sanitized } = user;
return sanitized;
}
private detectDeviceType(userAgent?: string): string {
if (!userAgent) return 'unknown';
const ua = userAgent.toLowerCase();
if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile';
if (/tablet|ipad/.test(ua)) return 'tablet';
return 'desktop';
}
}

View File

@ -0,0 +1,2 @@
export * from './auth.service';
export * from './oauth.service';

View File

@ -0,0 +1,351 @@
import {
Injectable,
BadRequestException,
UnauthorizedException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as speakeasy from 'speakeasy';
import * as QRCode from 'qrcode';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User } from '../entities';
import {
SetupMfaResponseDto,
VerifyMfaSetupDto,
DisableMfaDto,
MfaStatusDto,
BackupCodesResponseDto,
} from '../dto/mfa.dto';
@Injectable()
export class MfaService {
private readonly appName: string;
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly configService: ConfigService,
) {
this.appName = this.configService.get<string>('app.name') || 'Template SaaS';
}
/**
* Initialize MFA setup - generate secret and QR code
*/
async setupMfa(userId: string): Promise<SetupMfaResponseDto> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (user.mfa_enabled) {
throw new BadRequestException('MFA is already enabled');
}
// Generate TOTP secret
const secret = speakeasy.generateSecret({
name: `${this.appName} (${user.email})`,
length: 20,
});
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!);
// Generate backup codes
const backupCodes = this.generateBackupCodes();
return {
secret: secret.base32,
qrCodeDataUrl,
backupCodes,
};
}
/**
* Verify MFA setup and enable
*/
async verifyMfaSetup(
userId: string,
dto: VerifyMfaSetupDto,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (user.mfa_enabled) {
throw new BadRequestException('MFA is already enabled');
}
// Verify the TOTP code
const isValid = speakeasy.totp.verify({
secret: dto.secret,
encoding: 'base32',
token: dto.code,
window: 1, // Allow 1 step (30 seconds) tolerance
});
if (!isValid) {
throw new BadRequestException('Invalid verification code');
}
// Generate and hash backup codes
const backupCodes = this.generateBackupCodes();
const hashedBackupCodes = await Promise.all(
backupCodes.map((code) => bcrypt.hash(code, 10)),
);
// Enable MFA and store secret
await this.userRepository.update(
{ id: userId },
{
mfa_enabled: true,
mfa_secret: this.encryptSecret(dto.secret),
mfa_backup_codes: hashedBackupCodes,
mfa_enabled_at: new Date(),
},
);
return {
success: true,
message: 'MFA enabled successfully. Please save your backup codes.',
};
}
/**
* Verify TOTP code during login
*/
async verifyMfaCode(
userId: string,
code: string,
isBackupCode: boolean = false,
): Promise<boolean> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (!user.mfa_enabled || !user.mfa_secret) {
throw new BadRequestException('MFA is not enabled for this user');
}
if (isBackupCode) {
return this.verifyBackupCode(user, code);
}
// Decrypt secret and verify TOTP
const secret = this.decryptSecret(user.mfa_secret);
const isValid = speakeasy.totp.verify({
secret,
encoding: 'base32',
token: code,
window: 1,
});
return isValid;
}
/**
* Disable MFA for user
*/
async disableMfa(
userId: string,
dto: DisableMfaDto,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (!user.mfa_enabled) {
throw new BadRequestException('MFA is not enabled');
}
// Verify password
const isPasswordValid = await bcrypt.compare(dto.password, user.password_hash);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}
// Verify MFA code
const isMfaValid = await this.verifyMfaCode(userId, dto.code, dto.code.length > 6);
if (!isMfaValid) {
throw new BadRequestException('Invalid verification code');
}
// Disable MFA
await this.userRepository.update(
{ id: userId },
{
mfa_enabled: false,
mfa_secret: null,
mfa_backup_codes: null,
mfa_enabled_at: null,
},
);
return {
success: true,
message: 'MFA disabled successfully',
};
}
/**
* Get MFA status for user
*/
async getMfaStatus(userId: string): Promise<MfaStatusDto> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
const backupCodesRemaining = user.mfa_backup_codes?.length || 0;
return {
enabled: user.mfa_enabled || false,
enabledAt: user.mfa_enabled_at || undefined,
backupCodesRemaining,
};
}
/**
* Regenerate backup codes
*/
async regenerateBackupCodes(
userId: string,
password: string,
code: string,
): Promise<BackupCodesResponseDto> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new NotFoundException('User not found');
}
if (!user.mfa_enabled) {
throw new BadRequestException('MFA is not enabled');
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}
// Verify MFA code
const isMfaValid = await this.verifyMfaCode(userId, code);
if (!isMfaValid) {
throw new BadRequestException('Invalid verification code');
}
// Generate new backup codes
const backupCodes = this.generateBackupCodes();
const hashedBackupCodes = await Promise.all(
backupCodes.map((code) => bcrypt.hash(code, 10)),
);
// Update user
await this.userRepository.update(
{ id: userId },
{ mfa_backup_codes: hashedBackupCodes },
);
return {
backupCodes,
message: 'New backup codes generated. Please save them securely.',
};
}
// ==================== Private Methods ====================
/**
* Generate 10 random backup codes
*/
private generateBackupCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
// Format as XXXX-XXXX for readability
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
}
return codes;
}
/**
* Verify a backup code
*/
private async verifyBackupCode(user: User, code: string): Promise<boolean> {
if (!user.mfa_backup_codes || user.mfa_backup_codes.length === 0) {
return false;
}
// Normalize code (remove dashes, uppercase)
const normalizedCode = code.replace(/-/g, '').toUpperCase();
const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`;
// Check each hashed backup code
for (let i = 0; i < user.mfa_backup_codes.length; i++) {
const isMatch = await bcrypt.compare(formattedCode, user.mfa_backup_codes[i]);
if (isMatch) {
// Remove the used backup code
const updatedCodes = [...user.mfa_backup_codes];
updatedCodes.splice(i, 1);
await this.userRepository.update(
{ id: user.id },
{ mfa_backup_codes: updatedCodes },
);
return true;
}
}
return false;
}
/**
* Encrypt MFA secret for storage
*/
private encryptSecret(secret: string): string {
const encryptionKey = this.configService.get<string>('mfa.encryptionKey') ||
this.configService.get<string>('jwt.secret') ||
'default-encryption-key-change-me';
// Use first 32 bytes of key for AES-256
const key = crypto.createHash('sha256').update(encryptionKey).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(secret, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Return IV + encrypted data
return iv.toString('hex') + ':' + encrypted;
}
/**
* Decrypt MFA secret from storage
*/
private decryptSecret(encryptedSecret: string): string {
const encryptionKey = this.configService.get<string>('mfa.encryptionKey') ||
this.configService.get<string>('jwt.secret') ||
'default-encryption-key-change-me';
const key = crypto.createHash('sha256').update(encryptionKey).digest();
const [ivHex, encrypted] = encryptedSecret.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

@ -0,0 +1,521 @@
import {
Injectable,
ConflictException,
NotFoundException,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as jwt from 'jsonwebtoken';
import { OAuthConnection } from '../entities/oauth-connection.entity';
import { User } from '../entities/user.entity';
import { OAuthProvider } from '../entities/oauth-provider.enum';
import { AuthService, AuthResponse } from './auth.service';
/**
* OAuth profile data from provider
*/
export interface OAuthProfile {
id: string;
email: string;
name?: string;
avatar_url?: string;
raw_data?: Record<string, any>;
}
/**
* OAuth tokens from provider
*/
export interface OAuthTokens {
access_token: string;
refresh_token?: string;
expires_at?: Date;
scopes?: string[];
}
/**
* OAuth authorization URL response
*/
export interface OAuthUrlResponse {
url: string;
state: string;
}
/**
* OAuth connection response
*/
export interface OAuthConnectionResponse {
id: string;
provider: OAuthProvider;
provider_email: string | null;
provider_name: string | null;
provider_avatar_url: string | null;
created_at: Date;
last_used_at: Date | null;
}
@Injectable()
export class OAuthService {
constructor(
@InjectRepository(OAuthConnection)
private readonly oauthConnectionRepository: Repository<OAuthConnection>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly dataSource: DataSource,
) {}
/**
* Get OAuth authorization URL for a provider
*/
getAuthorizationUrl(
provider: OAuthProvider,
tenantId: string,
redirectUri?: string,
): OAuthUrlResponse {
const state = this.generateState(tenantId);
const baseRedirectUri = redirectUri || this.getDefaultRedirectUri(provider);
let url: string;
switch (provider) {
case OAuthProvider.GOOGLE:
url = this.buildGoogleAuthUrl(state, baseRedirectUri);
break;
case OAuthProvider.MICROSOFT:
url = this.buildMicrosoftAuthUrl(state, baseRedirectUri);
break;
case OAuthProvider.GITHUB:
url = this.buildGithubAuthUrl(state, baseRedirectUri);
break;
case OAuthProvider.APPLE:
url = this.buildAppleAuthUrl(state, baseRedirectUri);
break;
default:
throw new BadRequestException(`Proveedor OAuth no soportado: ${provider}`);
}
return { url, state };
}
/**
* Handle OAuth login/registration callback
*/
async handleOAuthLogin(
provider: OAuthProvider,
profile: OAuthProfile,
tokens: OAuthTokens,
tenantId: string,
req: Request,
): Promise<AuthResponse> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Check if OAuth connection already exists
let connection = await this.oauthConnectionRepository.findOne({
where: {
tenant_id: tenantId,
provider,
provider_user_id: profile.id,
},
relations: ['user'],
});
let user: User | null = null;
if (connection) {
// Existing connection - update tokens and last used
user = connection.user;
connection.access_token = tokens.access_token;
connection.refresh_token = tokens.refresh_token || connection.refresh_token;
connection.token_expires_at = tokens.expires_at || null;
connection.scopes = tokens.scopes || null;
connection.provider_email = profile.email;
connection.provider_name = profile.name || null;
connection.provider_avatar_url = profile.avatar_url || null;
connection.raw_data = profile.raw_data || null;
connection.last_used_at = new Date();
await queryRunner.manager.save(connection);
} else {
// Check if user with this email already exists
user = await this.userRepository.findOne({
where: {
email: profile.email,
tenant_id: tenantId,
},
});
if (!user) {
// Create new user
user = this.userRepository.create({
tenant_id: tenantId,
email: profile.email,
password_hash: '', // OAuth users don't have password
first_name: profile.name?.split(' ')[0] || null,
last_name: profile.name?.split(' ').slice(1).join(' ') || null,
avatar_url: profile.avatar_url || null,
status: 'active',
email_verified: true, // OAuth emails are pre-verified
email_verified_at: new Date(),
});
await queryRunner.manager.save(user);
}
// Create new OAuth connection
connection = this.oauthConnectionRepository.create({
tenant_id: tenantId,
user_id: user.id,
provider,
provider_user_id: profile.id,
provider_email: profile.email,
provider_name: profile.name || null,
provider_avatar_url: profile.avatar_url || null,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token || null,
token_expires_at: tokens.expires_at || null,
scopes: tokens.scopes || null,
raw_data: profile.raw_data || null,
last_used_at: new Date(),
});
await queryRunner.manager.save(connection);
}
// Update last login
if (!user) {
throw new BadRequestException('Failed to find or create user');
}
user.last_login_at = new Date();
user.last_login_ip = req.ip || null;
await queryRunner.manager.save(user);
await queryRunner.commitTransaction();
// Generate auth tokens using AuthService
const authTokens = await this.generateAuthTokens(
user,
req.ip,
req.headers['user-agent'] as string,
);
return {
user: this.sanitizeUser(user),
...authTokens,
};
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Get all OAuth connections for a user
*/
async getConnections(
userId: string,
tenantId: string,
): Promise<OAuthConnectionResponse[]> {
const connections = await this.oauthConnectionRepository.find({
where: {
user_id: userId,
tenant_id: tenantId,
},
order: { created_at: 'DESC' },
});
return connections.map((conn) => ({
id: conn.id,
provider: conn.provider,
provider_email: conn.provider_email,
provider_name: conn.provider_name,
provider_avatar_url: conn.provider_avatar_url,
created_at: conn.created_at,
last_used_at: conn.last_used_at,
}));
}
/**
* Disconnect an OAuth provider from user account
*/
async disconnectProvider(
userId: string,
tenantId: string,
provider: OAuthProvider,
): Promise<{ message: string }> {
// Find the connection
const connection = await this.oauthConnectionRepository.findOne({
where: {
user_id: userId,
tenant_id: tenantId,
provider,
},
});
if (!connection) {
throw new NotFoundException(`No existe conexión con ${provider}`);
}
// Check if user has password or other OAuth connections
const user = await this.userRepository.findOne({
where: { id: userId },
});
const otherConnections = await this.oauthConnectionRepository.count({
where: {
user_id: userId,
tenant_id: tenantId,
},
});
// Prevent disconnection if it's the only auth method
if (!user?.password_hash && otherConnections <= 1) {
throw new ConflictException(
'No puedes desconectar el único método de autenticación. Agrega un password primero.',
);
}
// Delete the connection
await this.oauthConnectionRepository.delete({ id: connection.id });
return { message: `Proveedor ${provider} desconectado correctamente` };
}
/**
* Link OAuth provider to existing user account
*/
async linkProvider(
userId: string,
tenantId: string,
provider: OAuthProvider,
profile: OAuthProfile,
tokens: OAuthTokens,
): Promise<OAuthConnectionResponse> {
// Check if connection already exists for this provider
const existingConnection = await this.oauthConnectionRepository.findOne({
where: {
tenant_id: tenantId,
provider,
provider_user_id: profile.id,
},
});
if (existingConnection) {
if (existingConnection.user_id === userId) {
throw new ConflictException(`Ya tienes vinculado ${provider}`);
} else {
throw new ConflictException(
`Esta cuenta de ${provider} ya está vinculada a otro usuario`,
);
}
}
// Create new connection
const connection = this.oauthConnectionRepository.create({
tenant_id: tenantId,
user_id: userId,
provider,
provider_user_id: profile.id,
provider_email: profile.email,
provider_name: profile.name || null,
provider_avatar_url: profile.avatar_url || null,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token || null,
token_expires_at: tokens.expires_at || null,
scopes: tokens.scopes || null,
raw_data: profile.raw_data || null,
last_used_at: new Date(),
});
await this.oauthConnectionRepository.save(connection);
return {
id: connection.id,
provider: connection.provider,
provider_email: connection.provider_email,
provider_name: connection.provider_name,
provider_avatar_url: connection.provider_avatar_url,
created_at: connection.created_at,
last_used_at: connection.last_used_at,
};
}
/**
* Handle Apple OAuth callback
* Apple OAuth is different from other providers:
* - Uses id_token for user info (JWT)
* - User data (name) is only sent on first authorization
* - Requires generating a client_secret JWT for token exchange
*/
async handleAppleOAuth(
code: string,
idToken?: string,
userData?: string,
tenantId?: string,
req?: Request,
): Promise<AuthResponse> {
if (!code) {
throw new BadRequestException('Authorization code is required');
}
if (!tenantId) {
throw new BadRequestException('Tenant ID is required');
}
// Parse user info from id_token
let email: string;
let appleUserId: string;
let name: string | undefined;
if (idToken) {
// Decode the id_token (don't verify signature in this simplified implementation)
// In production, you should verify with Apple's public keys
const decoded = jwt.decode(idToken) as {
sub?: string;
email?: string;
email_verified?: string;
};
if (!decoded || !decoded.sub || !decoded.email) {
throw new UnauthorizedException('Invalid id_token from Apple');
}
appleUserId = decoded.sub;
email = decoded.email;
} else {
throw new BadRequestException('id_token is required for Apple OAuth');
}
// Parse user data (only sent on first authorization)
if (userData) {
try {
const parsedUser = JSON.parse(userData);
if (parsedUser.name) {
const firstName = parsedUser.name.firstName || '';
const lastName = parsedUser.name.lastName || '';
name = [firstName, lastName].filter(Boolean).join(' ');
}
} catch {
// Ignore parsing errors for user data
}
}
// Create profile and tokens for handleOAuthLogin
const profile: OAuthProfile = {
id: appleUserId,
email,
name,
raw_data: { idToken, userData },
};
// For simplified implementation, we use the code as access_token placeholder
// In production, you should exchange the code for tokens using Apple's token endpoint
const tokens: OAuthTokens = {
access_token: code, // Placeholder - should exchange code for actual tokens
};
return this.handleOAuthLogin(
OAuthProvider.APPLE,
profile,
tokens,
tenantId,
req!,
);
}
// ==================== Private Methods ====================
private generateState(tenantId: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
return Buffer.from(JSON.stringify({ tenantId, timestamp, random })).toString(
'base64',
);
}
private getDefaultRedirectUri(provider: OAuthProvider): string {
const baseUrl = this.configService.get<string>('app.baseUrl') || 'http://localhost:3000';
// Apple uses form_post response mode, so we need a special endpoint
if (provider === OAuthProvider.APPLE) {
return `${baseUrl}/api/auth/oauth/apple/form-callback`;
}
return `${baseUrl}/api/auth/oauth/${provider}/callback`;
}
private buildGoogleAuthUrl(state: string, redirectUri: string): string {
const clientId = this.configService.get<string>('oauth.google.clientId');
const params = new URLSearchParams({
client_id: clientId || '',
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
state,
access_type: 'offline',
prompt: 'consent',
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
private buildMicrosoftAuthUrl(state: string, redirectUri: string): string {
const clientId = this.configService.get<string>('oauth.microsoft.clientId');
const tenantId = this.configService.get<string>('oauth.microsoft.tenantId') || 'common';
const params = new URLSearchParams({
client_id: clientId || '',
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile offline_access',
state,
response_mode: 'query',
});
return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
}
private buildGithubAuthUrl(state: string, redirectUri: string): string {
const clientId = this.configService.get<string>('oauth.github.clientId');
const params = new URLSearchParams({
client_id: clientId || '',
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}
private buildAppleAuthUrl(state: string, redirectUri: string): string {
const clientId = this.configService.get<string>('oauth.apple.clientId');
const params = new URLSearchParams({
client_id: clientId || '',
redirect_uri: redirectUri,
response_type: 'code id_token',
scope: 'name email',
state,
response_mode: 'form_post',
});
return `https://appleid.apple.com/auth/authorize?${params.toString()}`;
}
private async generateAuthTokens(
user: User,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string }> {
// Use reflection to access private method via AuthService
// Note: In production, consider exposing this method properly
return (this.authService as any).generateTokens(user, ip, userAgent);
}
private sanitizeUser(user: User): Partial<User> {
const { password_hash, ...sanitized } = user;
return sanitized;
}
}

View File

@ -0,0 +1 @@
export * from './jwt.strategy';

View File

@ -0,0 +1,39 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService, JwtPayload } from '../services/auth.service';
export interface RequestUser {
id: string;
email: string;
tenant_id: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.secret') || 'default-secret-change-in-production',
});
}
async validate(payload: JwtPayload): Promise<RequestUser> {
const user = await this.authService.validateUser(payload.sub);
if (!user) {
throw new UnauthorizedException('Usuario no encontrado o inactivo');
}
return {
id: user.id,
email: user.email,
tenant_id: user.tenant_id,
};
}
}

View File

@ -0,0 +1,903 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { BillingService } from '../services/billing.service';
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
import { Invoice, InvoiceStatus } from '../entities/invoice.entity';
import { PaymentMethod, PaymentMethodType } from '../entities/payment-method.entity';
/**
* Edge case tests for BillingService
* These tests cover scenarios not covered in the main billing.service.spec.ts
*/
describe('BillingService - Edge Cases', () => {
let service: BillingService;
let subscriptionRepo: jest.Mocked<Repository<Subscription>>;
let invoiceRepo: jest.Mocked<Repository<Invoice>>;
let paymentMethodRepo: jest.Mocked<Repository<PaymentMethod>>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockPlanId = '550e8400-e29b-41d4-a716-446655440002';
// Helper to create partial mock objects with proper typing
const createMockSubscription = (overrides: Partial<Subscription> = {}): Subscription => ({
id: 'sub-001',
tenant_id: mockTenantId,
plan_id: mockPlanId,
plan: null,
status: SubscriptionStatus.ACTIVE,
current_period_start: new Date('2026-01-01'),
current_period_end: new Date('2026-02-01'),
trial_end: null,
cancelled_at: null,
external_subscription_id: '',
payment_provider: 'stripe',
metadata: {},
created_at: new Date(),
updated_at: new Date(),
...overrides,
});
const createMockInvoice = (overrides: Partial<Invoice> = {}): Invoice => ({
id: 'inv-001',
tenant_id: mockTenantId,
subscription_id: 'sub-001',
invoice_number: 'INV-202601-000001',
status: InvoiceStatus.OPEN,
currency: 'MXN',
subtotal: 100,
tax: 16,
total: 116,
due_date: new Date('2026-01-15'),
paid_at: null as unknown as Date,
external_invoice_id: '',
pdf_url: null,
line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }],
billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string },
created_at: new Date(),
updated_at: new Date(),
...overrides,
});
const createMockPaymentMethod = (overrides: Partial<PaymentMethod> = {}): PaymentMethod => ({
id: 'pm-001',
tenant_id: mockTenantId,
type: PaymentMethodType.CARD,
card_last_four: '4242',
card_brand: 'visa',
card_exp_month: 12,
card_exp_year: 2026,
external_payment_method_id: '',
payment_provider: 'stripe',
is_default: true,
is_active: true,
metadata: {},
created_at: new Date(),
updated_at: new Date(),
...overrides,
});
beforeEach(async () => {
const mockSubscriptionRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockInvoiceRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
findAndCount: jest.fn(),
count: jest.fn(),
};
const mockPaymentMethodRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingService,
{ provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo },
{ provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo },
{ provide: getRepositoryToken(PaymentMethod), useValue: mockPaymentMethodRepo },
],
}).compile();
service = module.get<BillingService>(BillingService);
subscriptionRepo = module.get(getRepositoryToken(Subscription));
invoiceRepo = module.get(getRepositoryToken(Invoice));
paymentMethodRepo = module.get(getRepositoryToken(PaymentMethod));
});
afterEach(() => {
jest.clearAllMocks();
});
// ==================== Subscription Edge Cases ====================
describe('createSubscription - Edge Cases', () => {
it('should create subscription with far future trial end date', async () => {
const farFutureTrial = new Date();
farFutureTrial.setFullYear(farFutureTrial.getFullYear() + 1);
const trialSub = createMockSubscription({
status: SubscriptionStatus.TRIAL,
trial_end: farFutureTrial,
});
subscriptionRepo.create.mockReturnValue(trialSub);
subscriptionRepo.save.mockResolvedValue(trialSub);
const dto = {
tenant_id: mockTenantId,
plan_id: mockPlanId,
payment_provider: 'stripe',
trial_end: farFutureTrial.toISOString(),
};
const result = await service.createSubscription(dto);
expect(result.status).toBe(SubscriptionStatus.TRIAL);
expect(result.trial_end).toEqual(farFutureTrial);
});
it('should create subscription without optional payment_provider', async () => {
const subscription = createMockSubscription({
payment_provider: undefined,
});
subscriptionRepo.create.mockReturnValue(subscription);
subscriptionRepo.save.mockResolvedValue(subscription);
const dto = {
tenant_id: mockTenantId,
plan_id: mockPlanId,
};
await service.createSubscription(dto as any);
expect(subscriptionRepo.create).toHaveBeenCalled();
});
it('should set period end one month from creation', async () => {
const now = new Date('2026-01-15T10:00:00Z');
jest.useFakeTimers();
jest.setSystemTime(now);
const subscription = createMockSubscription({
current_period_start: now,
current_period_end: new Date('2026-02-15T10:00:00Z'),
});
subscriptionRepo.create.mockReturnValue(subscription);
subscriptionRepo.save.mockResolvedValue(subscription);
await service.createSubscription({
tenant_id: mockTenantId,
plan_id: mockPlanId,
payment_provider: 'stripe',
});
expect(subscriptionRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
current_period_start: now,
}),
);
jest.useRealTimers();
});
});
describe('cancelSubscription - Edge Cases', () => {
it('should cancel with custom reason in metadata', async () => {
const existingSub = createMockSubscription({
metadata: { existing_key: 'existing_value' },
});
const cancelledSub = createMockSubscription({
cancelled_at: new Date(),
metadata: {
existing_key: 'existing_value',
cancellation_reason: 'Too expensive',
},
});
subscriptionRepo.findOne.mockResolvedValue(existingSub);
subscriptionRepo.save.mockResolvedValue(cancelledSub);
const result = await service.cancelSubscription(mockTenantId, {
immediately: false,
reason: 'Too expensive',
});
expect(result.metadata.cancellation_reason).toBe('Too expensive');
expect(result.metadata.existing_key).toBe('existing_value');
});
it('should cancel trial subscription immediately', async () => {
const trialSub = createMockSubscription({
status: SubscriptionStatus.TRIAL,
trial_end: new Date('2026-01-20'),
});
const cancelledSub = createMockSubscription({
status: SubscriptionStatus.CANCELLED,
cancelled_at: new Date(),
});
subscriptionRepo.findOne.mockResolvedValue(trialSub);
subscriptionRepo.save.mockResolvedValue(cancelledSub);
const result = await service.cancelSubscription(mockTenantId, {
immediately: true,
});
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
});
it('should preserve active status when scheduling end-of-period cancellation', async () => {
const activeSub = createMockSubscription({
current_period_end: new Date('2026-02-01'),
});
subscriptionRepo.findOne.mockResolvedValue(activeSub);
subscriptionRepo.save.mockImplementation((sub) =>
Promise.resolve({ ...sub, cancelled_at: new Date() } as Subscription),
);
const result = await service.cancelSubscription(mockTenantId, {
immediately: false,
});
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
expect(result.cancelled_at).toBeDefined();
});
});
describe('changePlan - Edge Cases (Upgrade/Downgrade)', () => {
it('should upgrade from basic to pro plan', async () => {
const basicSub = createMockSubscription({
plan_id: 'plan-basic',
});
const upgradedSub = createMockSubscription({
plan_id: 'plan-pro',
metadata: { plan_changed_at: expect.any(String) },
});
subscriptionRepo.findOne.mockResolvedValue(basicSub);
subscriptionRepo.save.mockResolvedValue(upgradedSub);
const result = await service.changePlan(mockTenantId, 'plan-pro');
expect(result.plan_id).toBe('plan-pro');
expect(result.metadata.plan_changed_at).toBeDefined();
});
it('should downgrade from pro to basic plan', async () => {
const proSub = createMockSubscription({
plan_id: 'plan-pro',
});
const downgradedSub = createMockSubscription({
plan_id: 'plan-basic',
metadata: { plan_changed_at: expect.any(String) },
});
subscriptionRepo.findOne.mockResolvedValue(proSub);
subscriptionRepo.save.mockResolvedValue(downgradedSub);
const result = await service.changePlan(mockTenantId, 'plan-basic');
expect(result.plan_id).toBe('plan-basic');
});
it('should preserve existing metadata when changing plan', async () => {
const existingSub = createMockSubscription({
plan_id: 'plan-basic',
metadata: {
original_signup: '2025-01-01',
referral_code: 'ABC123',
},
});
subscriptionRepo.findOne.mockResolvedValue(existingSub);
subscriptionRepo.save.mockImplementation((sub) => Promise.resolve(sub as Subscription));
const result = await service.changePlan(mockTenantId, 'plan-pro');
expect(result.metadata.original_signup).toBe('2025-01-01');
expect(result.metadata.referral_code).toBe('ABC123');
expect(result.metadata.plan_changed_at).toBeDefined();
});
});
describe('renewSubscription - Edge Cases', () => {
it('should renew expired subscription', async () => {
const expiredSub = createMockSubscription({
status: SubscriptionStatus.EXPIRED,
current_period_start: new Date('2025-12-01'),
current_period_end: new Date('2026-01-01'),
});
const renewedSub = createMockSubscription({
status: SubscriptionStatus.ACTIVE,
current_period_start: new Date('2026-01-01'),
current_period_end: new Date('2026-02-01'),
});
subscriptionRepo.findOne.mockResolvedValue(expiredSub);
subscriptionRepo.save.mockResolvedValue(renewedSub);
const result = await service.renewSubscription(mockTenantId);
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});
it('should renew past_due subscription after payment', async () => {
const pastDueSub = createMockSubscription({
status: SubscriptionStatus.PAST_DUE,
current_period_start: new Date('2025-12-01'),
current_period_end: new Date('2026-01-01'),
});
const renewedSub = createMockSubscription({
status: SubscriptionStatus.ACTIVE,
current_period_start: new Date('2026-01-01'),
current_period_end: new Date('2026-02-01'),
});
subscriptionRepo.findOne.mockResolvedValue(pastDueSub);
subscriptionRepo.save.mockResolvedValue(renewedSub);
const result = await service.renewSubscription(mockTenantId);
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});
it('should throw NotFoundException when renewing non-existent subscription', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
await expect(service.renewSubscription(mockTenantId)).rejects.toThrow(
NotFoundException,
);
});
it('should correctly calculate new period end across year boundary', async () => {
const decemberSub = createMockSubscription({
current_period_start: new Date('2025-12-15'),
current_period_end: new Date('2026-01-15'),
});
subscriptionRepo.findOne.mockResolvedValue(decemberSub);
subscriptionRepo.save.mockImplementation((sub) => Promise.resolve(sub as Subscription));
const result = await service.renewSubscription(mockTenantId);
// New period should start from old end and add one month
expect(result.current_period_start).toEqual(new Date('2026-01-15'));
const expectedEnd = new Date('2026-01-15');
expectedEnd.setMonth(expectedEnd.getMonth() + 1);
expect(result.current_period_end).toEqual(expectedEnd);
});
});
// ==================== Invoice Edge Cases ====================
describe('createInvoice - Edge Cases', () => {
it('should calculate tax correctly (16% IVA)', async () => {
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 1, unit_price: 1000 },
]);
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 1000,
tax: 160, // 16% of 1000
total: 1160,
}),
);
});
it('should calculate totals for multiple line items', async () => {
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 1, unit_price: 100 },
{ description: 'Extra Users', quantity: 5, unit_price: 10 },
{ description: 'Storage Add-on', quantity: 2, unit_price: 25 },
]);
// 100 + 50 + 50 = 200 subtotal
// 200 * 0.16 = 32 tax
// 200 + 32 = 232 total
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 200,
tax: 32,
total: 232,
}),
);
});
it('should generate unique invoice number', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-03-15'));
invoiceRepo.count.mockResolvedValue(42);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 1, unit_price: 100 },
]);
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
invoice_number: 'INV-202603-000043', // count + 1
}),
);
jest.useRealTimers();
});
it('should set due date 15 days from creation', async () => {
const now = new Date('2026-01-10T12:00:00Z');
jest.useFakeTimers();
jest.setSystemTime(now);
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 1, unit_price: 100 },
]);
const expectedDueDate = new Date('2026-01-25T12:00:00Z');
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
due_date: expectedDueDate,
}),
);
jest.useRealTimers();
});
it('should handle zero quantity line items', async () => {
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 0, unit_price: 100 },
]);
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 0,
tax: 0,
total: 0,
}),
);
});
it('should handle empty line items array', async () => {
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockImplementation((data) => data as Invoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
await service.createInvoice(mockTenantId, 'sub-001', []);
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 0,
tax: 0,
total: 0,
line_items: [],
}),
);
});
});
describe('getInvoices - Edge Cases', () => {
it('should return empty array when no invoices exist', async () => {
invoiceRepo.findAndCount.mockResolvedValue([[], 0]);
const result = await service.getInvoices(mockTenantId);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
it('should handle high page numbers with no results', async () => {
invoiceRepo.findAndCount.mockResolvedValue([[], 0]);
const result = await service.getInvoices(mockTenantId, { page: 999, limit: 10 });
expect(result.data).toHaveLength(0);
expect(result.page).toBe(999);
});
it('should handle custom limit values', async () => {
const invoices = Array(50)
.fill(null)
.map((_, i) => createMockInvoice({ id: `inv-${i}` }));
invoiceRepo.findAndCount.mockResolvedValue([invoices.slice(0, 25), 50]);
const result = await service.getInvoices(mockTenantId, { page: 1, limit: 25 });
expect(result.data).toHaveLength(25);
expect(result.limit).toBe(25);
expect(result.total).toBe(50);
});
});
describe('voidInvoice - Edge Cases', () => {
it('should void draft invoice', async () => {
const draftInvoice = createMockInvoice({ status: InvoiceStatus.DRAFT });
invoiceRepo.findOne.mockResolvedValue(draftInvoice);
invoiceRepo.save.mockResolvedValue(createMockInvoice({ status: InvoiceStatus.VOID }));
const result = await service.voidInvoice('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.VOID);
});
it('should void open invoice', async () => {
const openInvoice = createMockInvoice({ status: InvoiceStatus.OPEN });
invoiceRepo.findOne.mockResolvedValue(openInvoice);
invoiceRepo.save.mockResolvedValue(createMockInvoice({ status: InvoiceStatus.VOID }));
const result = await service.voidInvoice('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.VOID);
});
it('should throw when voiding already voided invoice', async () => {
const voidedInvoice = createMockInvoice({ status: InvoiceStatus.VOID });
invoiceRepo.findOne.mockResolvedValue(voidedInvoice);
invoiceRepo.save.mockResolvedValue(voidedInvoice);
// Note: Current implementation doesn't prevent re-voiding
// This test documents the current behavior
const result = await service.voidInvoice('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.VOID);
});
});
describe('markInvoicePaid - Edge Cases', () => {
it('should mark draft invoice as paid', async () => {
const draftInvoice = createMockInvoice({ status: InvoiceStatus.DRAFT });
invoiceRepo.findOne.mockResolvedValue(draftInvoice);
invoiceRepo.save.mockResolvedValue(
createMockInvoice({ status: InvoiceStatus.PAID, paid_at: new Date() }),
);
const result = await service.markInvoicePaid('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.PAID);
expect(result.paid_at).toBeDefined();
});
it('should update paid_at timestamp', async () => {
const now = new Date('2026-01-15T14:30:00Z');
jest.useFakeTimers();
jest.setSystemTime(now);
const openInvoice = createMockInvoice({ status: InvoiceStatus.OPEN });
invoiceRepo.findOne.mockResolvedValue(openInvoice);
invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice));
const result = await service.markInvoicePaid('inv-001', mockTenantId);
expect(result.paid_at).toEqual(now);
jest.useRealTimers();
});
});
// ==================== Payment Method Edge Cases ====================
describe('addPaymentMethod - Edge Cases', () => {
it('should add non-default payment method without updating others', async () => {
const newMethod = createMockPaymentMethod({
id: 'pm-002',
is_default: false,
});
paymentMethodRepo.create.mockReturnValue(newMethod);
paymentMethodRepo.save.mockResolvedValue(newMethod);
const dto = {
type: PaymentMethodType.CARD,
card_last_four: '1234',
card_brand: 'mastercard',
is_default: false,
};
await service.addPaymentMethod(mockTenantId, dto);
expect(paymentMethodRepo.update).not.toHaveBeenCalled();
});
it('should handle bank_transfer payment method type', async () => {
const bankMethod = createMockPaymentMethod({
id: 'pm-003',
type: PaymentMethodType.BANK_TRANSFER,
});
paymentMethodRepo.create.mockReturnValue(bankMethod);
paymentMethodRepo.save.mockResolvedValue(bankMethod);
const dto = {
type: PaymentMethodType.BANK_TRANSFER,
is_default: false,
};
await service.addPaymentMethod(mockTenantId, dto);
expect(paymentMethodRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
type: PaymentMethodType.BANK_TRANSFER,
}),
);
});
});
describe('getPaymentMethods - Edge Cases', () => {
it('should return empty array when no payment methods exist', async () => {
paymentMethodRepo.find.mockResolvedValue([]);
const result = await service.getPaymentMethods(mockTenantId);
expect(result).toHaveLength(0);
});
it('should return only active payment methods', async () => {
const activeMethod = createMockPaymentMethod({ is_active: true });
paymentMethodRepo.find.mockResolvedValue([activeMethod]);
const result = await service.getPaymentMethods(mockTenantId);
expect(result).toHaveLength(1);
expect(paymentMethodRepo.find).toHaveBeenCalledWith({
where: { tenant_id: mockTenantId, is_active: true },
order: { is_default: 'DESC', created_at: 'DESC' },
});
});
it('should order payment methods with default first', async () => {
const methods = [
createMockPaymentMethod({ id: 'pm-001', is_default: false, created_at: new Date('2026-01-01') }),
createMockPaymentMethod({ id: 'pm-002', is_default: true, created_at: new Date('2026-01-02') }),
createMockPaymentMethod({ id: 'pm-003', is_default: false, created_at: new Date('2026-01-03') }),
];
paymentMethodRepo.find.mockResolvedValue(methods);
await service.getPaymentMethods(mockTenantId);
expect(paymentMethodRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
order: { is_default: 'DESC', created_at: 'DESC' },
}),
);
});
});
describe('setDefaultPaymentMethod - Edge Cases', () => {
it('should unset previous default when setting new default', async () => {
const newDefault = createMockPaymentMethod({
id: 'pm-002',
is_default: false,
});
paymentMethodRepo.findOne.mockResolvedValue(newDefault);
paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any);
paymentMethodRepo.save.mockResolvedValue(
createMockPaymentMethod({ id: 'pm-002', is_default: true }),
);
await service.setDefaultPaymentMethod('pm-002', mockTenantId);
expect(paymentMethodRepo.update).toHaveBeenCalledWith(
{ tenant_id: mockTenantId, is_default: true },
{ is_default: false },
);
});
it('should handle setting already default payment method as default', async () => {
const alreadyDefault = createMockPaymentMethod({ is_default: true });
paymentMethodRepo.findOne.mockResolvedValue(alreadyDefault);
paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any);
paymentMethodRepo.save.mockResolvedValue(alreadyDefault);
const result = await service.setDefaultPaymentMethod('pm-001', mockTenantId);
expect(result.is_default).toBe(true);
});
});
describe('removePaymentMethod - Edge Cases', () => {
it('should deactivate instead of delete payment method', async () => {
const nonDefaultMethod = createMockPaymentMethod({
id: 'pm-002',
is_default: false,
is_active: true,
});
paymentMethodRepo.findOne.mockResolvedValue(nonDefaultMethod);
paymentMethodRepo.save.mockImplementation((pm) => Promise.resolve(pm as PaymentMethod));
await service.removePaymentMethod('pm-002', mockTenantId);
expect(paymentMethodRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
is_active: false,
}),
);
});
it('should throw when trying to remove default payment method', async () => {
const defaultMethod = createMockPaymentMethod({
is_default: true,
is_active: true,
});
paymentMethodRepo.findOne.mockResolvedValue(defaultMethod);
await expect(
service.removePaymentMethod('pm-001', mockTenantId),
).rejects.toThrow(BadRequestException);
});
});
// ==================== Billing Summary Edge Cases ====================
describe('getBillingSummary - Edge Cases', () => {
it('should return null values when no subscription or payment method', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
paymentMethodRepo.findOne.mockResolvedValue(null);
invoiceRepo.find.mockResolvedValue([]);
const result = await service.getBillingSummary(mockTenantId);
expect(result.subscription).toBeNull();
expect(result.defaultPaymentMethod).toBeNull();
expect(result.pendingInvoices).toBe(0);
expect(result.totalDue).toBe(0);
});
it('should calculate total due from multiple pending invoices', async () => {
const pendingInvoices = [
createMockInvoice({ id: 'inv-001', total: 116, status: InvoiceStatus.OPEN }),
createMockInvoice({ id: 'inv-002', total: 58, status: InvoiceStatus.OPEN }),
createMockInvoice({ id: 'inv-003', total: 232, status: InvoiceStatus.OPEN }),
];
subscriptionRepo.findOne.mockResolvedValue(createMockSubscription());
paymentMethodRepo.findOne.mockResolvedValue(createMockPaymentMethod());
invoiceRepo.find.mockResolvedValue(pendingInvoices);
const result = await service.getBillingSummary(mockTenantId);
expect(result.pendingInvoices).toBe(3);
expect(result.totalDue).toBe(406); // 116 + 58 + 232
});
it('should handle decimal totals correctly', async () => {
const pendingInvoices = [
createMockInvoice({ id: 'inv-001', total: 116.5 as unknown as number, status: InvoiceStatus.OPEN }),
createMockInvoice({ id: 'inv-002', total: 58.25 as unknown as number, status: InvoiceStatus.OPEN }),
];
subscriptionRepo.findOne.mockResolvedValue(createMockSubscription());
paymentMethodRepo.findOne.mockResolvedValue(createMockPaymentMethod());
invoiceRepo.find.mockResolvedValue(pendingInvoices);
const result = await service.getBillingSummary(mockTenantId);
expect(result.totalDue).toBe(174.75);
});
});
describe('checkSubscriptionStatus - Edge Cases', () => {
it('should return zero days remaining when period has ended', async () => {
const expiredSub = createMockSubscription({
current_period_end: new Date('2025-12-01'), // Past date
});
subscriptionRepo.findOne.mockResolvedValue(expiredSub);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.daysRemaining).toBe(0);
});
it('should calculate days remaining correctly', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-10'));
const activeSub = createMockSubscription({
current_period_end: new Date('2026-01-25'),
});
subscriptionRepo.findOne.mockResolvedValue(activeSub);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.daysRemaining).toBe(15);
expect(result.isActive).toBe(true);
jest.useRealTimers();
});
it('should return inactive for past_due subscription', async () => {
const pastDueSub = createMockSubscription({
status: SubscriptionStatus.PAST_DUE,
current_period_end: new Date('2026-02-01'),
});
subscriptionRepo.findOne.mockResolvedValue(pastDueSub);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(false);
expect(result.status).toBe(SubscriptionStatus.PAST_DUE);
});
it('should return inactive for cancelled subscription', async () => {
const cancelledSub = createMockSubscription({
status: SubscriptionStatus.CANCELLED,
current_period_end: new Date('2026-02-01'),
});
subscriptionRepo.findOne.mockResolvedValue(cancelledSub);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(false);
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
});
it('should return active for trial subscription', async () => {
const trialSub = createMockSubscription({
status: SubscriptionStatus.TRIAL,
current_period_end: new Date('2026-02-01'),
trial_end: new Date('2026-01-15'),
});
subscriptionRepo.findOne.mockResolvedValue(trialSub);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(true);
expect(result.status).toBe(SubscriptionStatus.TRIAL);
});
});
});

View File

@ -0,0 +1,268 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { BillingController } from '../billing.controller';
import { BillingService } from '../services/billing.service';
import { RbacService } from '../../rbac/services/rbac.service';
describe('BillingController', () => {
let controller: BillingController;
let service: jest.Mocked<BillingService>;
const mockRequestUser = {
id: 'user-123',
sub: 'user-123',
tenant_id: 'tenant-123',
email: 'test@example.com',
role: 'admin',
};
const mockSubscription = {
id: 'sub-123',
tenant_id: 'tenant-123',
plan_id: 'plan-123',
status: 'active',
current_period_start: new Date('2026-01-01'),
current_period_end: new Date('2026-02-01'),
};
const mockInvoice = {
id: 'inv-123',
tenant_id: 'tenant-123',
status: 'paid',
total: 99.99,
};
const mockPaymentMethod = {
id: 'pm-123',
tenant_id: 'tenant-123',
type: 'card',
card_last_four: '4242',
is_default: true,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BillingController],
providers: [
{
provide: BillingService,
useValue: {
getSubscription: jest.fn(),
checkSubscriptionStatus: jest.fn(),
createSubscription: jest.fn(),
updateSubscription: jest.fn(),
cancelSubscription: jest.fn(),
changePlan: jest.fn(),
getInvoices: jest.fn(),
getInvoice: jest.fn(),
markInvoicePaid: jest.fn(),
voidInvoice: jest.fn(),
getPaymentMethods: jest.fn(),
addPaymentMethod: jest.fn(),
setDefaultPaymentMethod: jest.fn(),
removePaymentMethod: jest.fn(),
getBillingSummary: jest.fn(),
},
},
{
provide: RbacService,
useValue: {
userHasPermission: jest.fn().mockResolvedValue(true),
userHasAnyPermission: jest.fn().mockResolvedValue(true),
},
},
Reflector,
],
}).compile();
controller = module.get<BillingController>(BillingController);
service = module.get(BillingService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getSubscription', () => {
it('should return current subscription', async () => {
service.getSubscription.mockResolvedValue(mockSubscription as any);
const result = await controller.getSubscription(mockRequestUser);
expect(result).toEqual(mockSubscription);
expect(service.getSubscription).toHaveBeenCalledWith('tenant-123');
});
});
describe('getSubscriptionStatus', () => {
it('should return subscription status', async () => {
const status = { active: true, plan: 'pro', daysRemaining: 25 };
service.checkSubscriptionStatus.mockResolvedValue(status as any);
const result = await controller.getSubscriptionStatus(mockRequestUser);
expect(result).toEqual(status);
expect(service.checkSubscriptionStatus).toHaveBeenCalledWith('tenant-123');
});
});
describe('createSubscription', () => {
it('should create subscription', async () => {
const createDto = { plan_id: 'plan-123', tenant_id: '' };
service.createSubscription.mockResolvedValue(mockSubscription as any);
const result = await controller.createSubscription(createDto as any, mockRequestUser);
expect(result).toEqual(mockSubscription);
expect(createDto.tenant_id).toBe('tenant-123');
});
});
describe('updateSubscription', () => {
it('should update subscription', async () => {
const updateDto = { plan_id: 'plan-456' };
const updated = { ...mockSubscription, plan_id: 'plan-456' };
service.updateSubscription.mockResolvedValue(updated as any);
const result = await controller.updateSubscription(updateDto as any, mockRequestUser);
expect(result.plan_id).toBe('plan-456');
expect(service.updateSubscription).toHaveBeenCalledWith('tenant-123', updateDto);
});
});
describe('cancelSubscription', () => {
it('should cancel subscription', async () => {
const cancelDto = { immediately: false, reason: 'Not needed' };
const cancelled = { ...mockSubscription, status: 'cancelled' };
service.cancelSubscription.mockResolvedValue(cancelled as any);
const result = await controller.cancelSubscription(cancelDto as any, mockRequestUser);
expect(result.status).toBe('cancelled');
expect(service.cancelSubscription).toHaveBeenCalledWith('tenant-123', cancelDto);
});
});
describe('changePlan', () => {
it('should change plan', async () => {
const updated = { ...mockSubscription, plan_id: 'plan-456' };
service.changePlan.mockResolvedValue(updated as any);
const result = await controller.changePlan('plan-456', mockRequestUser);
expect(result.plan_id).toBe('plan-456');
expect(service.changePlan).toHaveBeenCalledWith('tenant-123', 'plan-456');
});
});
describe('getInvoices', () => {
it('should return paginated invoices', async () => {
const invoicesResult = { data: [mockInvoice], total: 1, page: 1, limit: 10 };
service.getInvoices.mockResolvedValue(invoicesResult as any);
const result = await controller.getInvoices(mockRequestUser, 1, 10);
expect(result).toEqual(invoicesResult);
expect(service.getInvoices).toHaveBeenCalledWith('tenant-123', { page: 1, limit: 10 });
});
});
describe('getInvoice', () => {
it('should return invoice by id', async () => {
service.getInvoice.mockResolvedValue(mockInvoice as any);
const result = await controller.getInvoice('inv-123', mockRequestUser);
expect(result).toEqual(mockInvoice);
expect(service.getInvoice).toHaveBeenCalledWith('inv-123', 'tenant-123');
});
});
describe('markInvoicePaid', () => {
it('should mark invoice as paid', async () => {
const paidInvoice = { ...mockInvoice, status: 'paid' };
service.markInvoicePaid.mockResolvedValue(paidInvoice as any);
const result = await controller.markInvoicePaid('inv-123', mockRequestUser);
expect(result.status).toBe('paid');
expect(service.markInvoicePaid).toHaveBeenCalledWith('inv-123', 'tenant-123');
});
});
describe('voidInvoice', () => {
it('should void invoice', async () => {
const voidedInvoice = { ...mockInvoice, status: 'void' };
service.voidInvoice.mockResolvedValue(voidedInvoice as any);
const result = await controller.voidInvoice('inv-123', mockRequestUser);
expect(result.status).toBe('void');
expect(service.voidInvoice).toHaveBeenCalledWith('inv-123', 'tenant-123');
});
});
describe('getPaymentMethods', () => {
it('should return payment methods', async () => {
service.getPaymentMethods.mockResolvedValue([mockPaymentMethod] as any);
const result = await controller.getPaymentMethods(mockRequestUser);
expect(result).toEqual([mockPaymentMethod]);
expect(service.getPaymentMethods).toHaveBeenCalledWith('tenant-123');
});
});
describe('addPaymentMethod', () => {
it('should add payment method', async () => {
const createDto = { type: 'card', external_payment_method_id: 'pm_test' };
service.addPaymentMethod.mockResolvedValue(mockPaymentMethod as any);
const result = await controller.addPaymentMethod(createDto as any, mockRequestUser);
expect(result).toEqual(mockPaymentMethod);
expect(service.addPaymentMethod).toHaveBeenCalledWith('tenant-123', createDto);
});
});
describe('setDefaultPaymentMethod', () => {
it('should set default payment method', async () => {
const updatedMethod = { ...mockPaymentMethod, is_default: true };
service.setDefaultPaymentMethod.mockResolvedValue(updatedMethod as any);
const result = await controller.setDefaultPaymentMethod('pm-123', mockRequestUser);
expect(result.is_default).toBe(true);
expect(service.setDefaultPaymentMethod).toHaveBeenCalledWith('pm-123', 'tenant-123');
});
});
describe('removePaymentMethod', () => {
it('should remove payment method', async () => {
service.removePaymentMethod.mockResolvedValue(undefined);
const result = await controller.removePaymentMethod('pm-123', mockRequestUser);
expect(result.message).toBe('Payment method removed');
expect(service.removePaymentMethod).toHaveBeenCalledWith('pm-123', 'tenant-123');
});
});
describe('getBillingSummary', () => {
it('should return billing summary', async () => {
const summary = {
subscription: mockSubscription,
nextInvoice: mockInvoice,
paymentMethods: [mockPaymentMethod],
};
service.getBillingSummary.mockResolvedValue(summary as any);
const result = await controller.getBillingSummary(mockRequestUser);
expect(result).toEqual(summary);
expect(service.getBillingSummary).toHaveBeenCalledWith('tenant-123');
});
});
});

View File

@ -0,0 +1,552 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { BillingService } from '../services/billing.service';
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
import { Invoice, InvoiceStatus } from '../entities/invoice.entity';
import { PaymentMethod, PaymentMethodType } from '../entities/payment-method.entity';
describe('BillingService', () => {
let service: BillingService;
let subscriptionRepo: jest.Mocked<Repository<Subscription>>;
let invoiceRepo: jest.Mocked<Repository<Invoice>>;
let paymentMethodRepo: jest.Mocked<Repository<PaymentMethod>>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockPlanId = '550e8400-e29b-41d4-a716-446655440002';
const mockSubscription: Partial<Subscription> = {
id: 'sub-001',
tenant_id: mockTenantId,
plan_id: mockPlanId,
status: SubscriptionStatus.ACTIVE,
current_period_start: new Date('2026-01-01'),
current_period_end: new Date('2026-02-01'),
payment_provider: 'stripe',
metadata: {},
};
const mockInvoice: Partial<Invoice> = {
id: 'inv-001',
tenant_id: mockTenantId,
subscription_id: 'sub-001',
invoice_number: 'INV-202601-000001',
status: InvoiceStatus.OPEN,
subtotal: 100,
tax: 16,
total: 116,
due_date: new Date('2026-01-15'),
line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }],
};
const mockPaymentMethod: Partial<PaymentMethod> = {
id: 'pm-001',
tenant_id: mockTenantId,
type: PaymentMethodType.CARD,
card_last_four: '4242',
card_brand: 'visa',
is_default: true,
is_active: true,
};
beforeEach(async () => {
const mockSubscriptionRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockInvoiceRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
findAndCount: jest.fn(),
count: jest.fn(),
};
const mockPaymentMethodRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingService,
{ provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo },
{ provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo },
{ provide: getRepositoryToken(PaymentMethod), useValue: mockPaymentMethodRepo },
],
}).compile();
service = module.get<BillingService>(BillingService);
subscriptionRepo = module.get(getRepositoryToken(Subscription));
invoiceRepo = module.get(getRepositoryToken(Invoice));
paymentMethodRepo = module.get(getRepositoryToken(PaymentMethod));
});
afterEach(() => {
jest.clearAllMocks();
});
// ==================== Subscription Tests ====================
describe('createSubscription', () => {
it('should create a subscription successfully', async () => {
subscriptionRepo.create.mockReturnValue(mockSubscription as Subscription);
subscriptionRepo.save.mockResolvedValue(mockSubscription as Subscription);
const dto = {
tenant_id: mockTenantId,
plan_id: mockPlanId,
payment_provider: 'stripe',
};
const result = await service.createSubscription(dto);
expect(result).toEqual(mockSubscription);
expect(subscriptionRepo.create).toHaveBeenCalled();
expect(subscriptionRepo.save).toHaveBeenCalled();
});
it('should create trial subscription when trial_end provided', async () => {
const trialSub = {
...mockSubscription,
status: SubscriptionStatus.TRIAL,
trial_end: new Date('2026-01-15'),
};
subscriptionRepo.create.mockReturnValue(trialSub as Subscription);
subscriptionRepo.save.mockResolvedValue(trialSub as Subscription);
const dto = {
tenant_id: mockTenantId,
plan_id: mockPlanId,
payment_provider: 'stripe',
trial_end: '2026-01-15',
};
const result = await service.createSubscription(dto);
expect(result.status).toBe(SubscriptionStatus.TRIAL);
});
});
describe('getSubscription', () => {
it('should return subscription for tenant', async () => {
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
const result = await service.getSubscription(mockTenantId);
expect(result).toEqual(mockSubscription);
expect(subscriptionRepo.findOne).toHaveBeenCalledWith({
where: { tenant_id: mockTenantId },
order: { created_at: 'DESC' },
});
});
it('should return null if no subscription found', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
const result = await service.getSubscription(mockTenantId);
expect(result).toBeNull();
});
});
describe('updateSubscription', () => {
it('should update subscription successfully', async () => {
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
subscriptionRepo.save.mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.PAST_DUE,
} as Subscription);
const result = await service.updateSubscription(mockTenantId, {
status: SubscriptionStatus.PAST_DUE,
});
expect(result.status).toBe(SubscriptionStatus.PAST_DUE);
});
it('should throw NotFoundException if subscription not found', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
await expect(
service.updateSubscription(mockTenantId, { status: SubscriptionStatus.ACTIVE }),
).rejects.toThrow(NotFoundException);
});
});
describe('cancelSubscription', () => {
it('should cancel subscription immediately', async () => {
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
subscriptionRepo.save.mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.CANCELLED,
cancelled_at: new Date(),
} as Subscription);
const result = await service.cancelSubscription(mockTenantId, {
immediately: true,
reason: 'User requested',
});
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
expect(result.cancelled_at).toBeDefined();
});
it('should schedule cancellation at period end', async () => {
const activeSub = { ...mockSubscription, status: SubscriptionStatus.ACTIVE };
const savedSub = { ...activeSub, cancelled_at: new Date() };
subscriptionRepo.findOne.mockResolvedValue(activeSub as Subscription);
subscriptionRepo.save.mockResolvedValue(savedSub as Subscription);
const result = await service.cancelSubscription(mockTenantId, {
immediately: false,
});
expect(result.cancelled_at).toBeDefined();
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});
it('should throw NotFoundException if subscription not found', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
await expect(
service.cancelSubscription(mockTenantId, { immediately: true }),
).rejects.toThrow(NotFoundException);
});
});
describe('changePlan', () => {
it('should change plan successfully', async () => {
const newPlanId = 'new-plan-id';
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
subscriptionRepo.save.mockResolvedValue({
...mockSubscription,
plan_id: newPlanId,
} as Subscription);
const result = await service.changePlan(mockTenantId, newPlanId);
expect(result.plan_id).toBe(newPlanId);
});
it('should throw NotFoundException if subscription not found', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
await expect(service.changePlan(mockTenantId, 'new-plan')).rejects.toThrow(
NotFoundException,
);
});
});
describe('renewSubscription', () => {
it('should renew subscription successfully', async () => {
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
subscriptionRepo.save.mockResolvedValue({
...mockSubscription,
current_period_start: mockSubscription.current_period_end,
current_period_end: new Date('2026-03-01'),
status: SubscriptionStatus.ACTIVE,
} as Subscription);
const result = await service.renewSubscription(mockTenantId);
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});
});
// ==================== Invoice Tests ====================
describe('createInvoice', () => {
it('should create invoice with correct calculations', async () => {
invoiceRepo.count.mockResolvedValue(0);
invoiceRepo.create.mockReturnValue(mockInvoice as Invoice);
invoiceRepo.save.mockResolvedValue(mockInvoice as Invoice);
const result = await service.createInvoice(mockTenantId, 'sub-001', [
{ description: 'Pro Plan', quantity: 1, unit_price: 100 },
]);
expect(result).toEqual(mockInvoice);
expect(invoiceRepo.create).toHaveBeenCalled();
});
});
describe('getInvoices', () => {
it('should return paginated invoices', async () => {
invoiceRepo.findAndCount.mockResolvedValue([[mockInvoice as Invoice], 1]);
const result = await service.getInvoices(mockTenantId, { page: 1, limit: 10 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
});
it('should use default pagination values', async () => {
invoiceRepo.findAndCount.mockResolvedValue([[mockInvoice as Invoice], 1]);
const result = await service.getInvoices(mockTenantId);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
});
});
describe('getInvoice', () => {
it('should return invoice by id', async () => {
invoiceRepo.findOne.mockResolvedValue(mockInvoice as Invoice);
const result = await service.getInvoice('inv-001', mockTenantId);
expect(result).toEqual(mockInvoice);
});
it('should throw NotFoundException if invoice not found', async () => {
invoiceRepo.findOne.mockResolvedValue(null);
await expect(service.getInvoice('invalid-id', mockTenantId)).rejects.toThrow(
NotFoundException,
);
});
});
describe('markInvoicePaid', () => {
it('should mark invoice as paid', async () => {
const openInvoice = { ...mockInvoice, status: InvoiceStatus.OPEN };
invoiceRepo.findOne.mockResolvedValue(openInvoice as Invoice);
invoiceRepo.save.mockResolvedValue({
...openInvoice,
status: InvoiceStatus.PAID,
paid_at: new Date(),
} as Invoice);
const result = await service.markInvoicePaid('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.PAID);
expect(result.paid_at).toBeDefined();
});
});
describe('voidInvoice', () => {
it('should void open invoice', async () => {
const openInvoice = { ...mockInvoice, status: InvoiceStatus.OPEN };
invoiceRepo.findOne.mockResolvedValue(openInvoice as Invoice);
invoiceRepo.save.mockResolvedValue({
...openInvoice,
status: InvoiceStatus.VOID,
} as Invoice);
const result = await service.voidInvoice('inv-001', mockTenantId);
expect(result.status).toBe(InvoiceStatus.VOID);
});
it('should throw BadRequestException for paid invoice', async () => {
invoiceRepo.findOne.mockResolvedValue({
...mockInvoice,
status: InvoiceStatus.PAID,
} as Invoice);
await expect(service.voidInvoice('inv-001', mockTenantId)).rejects.toThrow(
BadRequestException,
);
});
});
// ==================== Payment Method Tests ====================
describe('addPaymentMethod', () => {
it('should add payment method successfully', async () => {
paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any);
paymentMethodRepo.create.mockReturnValue(mockPaymentMethod as PaymentMethod);
paymentMethodRepo.save.mockResolvedValue(mockPaymentMethod as PaymentMethod);
const dto = {
type: PaymentMethodType.CARD,
card_last_four: '4242',
card_brand: 'visa',
is_default: true,
};
const result = await service.addPaymentMethod(mockTenantId, dto);
expect(result).toEqual(mockPaymentMethod);
});
it('should unset other defaults when adding default', async () => {
paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any);
paymentMethodRepo.create.mockReturnValue(mockPaymentMethod as PaymentMethod);
paymentMethodRepo.save.mockResolvedValue(mockPaymentMethod as PaymentMethod);
const dto = {
type: PaymentMethodType.CARD,
card_last_four: '4242',
card_brand: 'visa',
is_default: true,
};
await service.addPaymentMethod(mockTenantId, dto);
expect(paymentMethodRepo.update).toHaveBeenCalledWith(
{ tenant_id: mockTenantId, is_default: true },
{ is_default: false },
);
});
});
describe('getPaymentMethods', () => {
it('should return active payment methods', async () => {
paymentMethodRepo.find.mockResolvedValue([mockPaymentMethod as PaymentMethod]);
const result = await service.getPaymentMethods(mockTenantId);
expect(result).toHaveLength(1);
expect(paymentMethodRepo.find).toHaveBeenCalledWith({
where: { tenant_id: mockTenantId, is_active: true },
order: { is_default: 'DESC', created_at: 'DESC' },
});
});
});
describe('getDefaultPaymentMethod', () => {
it('should return default payment method', async () => {
paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod);
const result = await service.getDefaultPaymentMethod(mockTenantId);
expect(result).toEqual(mockPaymentMethod);
});
it('should return null if no default', async () => {
paymentMethodRepo.findOne.mockResolvedValue(null);
const result = await service.getDefaultPaymentMethod(mockTenantId);
expect(result).toBeNull();
});
});
describe('setDefaultPaymentMethod', () => {
it('should set payment method as default', async () => {
paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod);
paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any);
paymentMethodRepo.save.mockResolvedValue({
...mockPaymentMethod,
is_default: true,
} as PaymentMethod);
const result = await service.setDefaultPaymentMethod('pm-001', mockTenantId);
expect(result.is_default).toBe(true);
});
it('should throw NotFoundException if not found', async () => {
paymentMethodRepo.findOne.mockResolvedValue(null);
await expect(
service.setDefaultPaymentMethod('invalid-id', mockTenantId),
).rejects.toThrow(NotFoundException);
});
});
describe('removePaymentMethod', () => {
it('should deactivate non-default payment method', async () => {
paymentMethodRepo.findOne.mockResolvedValue({
...mockPaymentMethod,
is_default: false,
} as PaymentMethod);
paymentMethodRepo.save.mockResolvedValue({} as PaymentMethod);
await service.removePaymentMethod('pm-001', mockTenantId);
expect(paymentMethodRepo.save).toHaveBeenCalled();
});
it('should throw BadRequestException for default payment method', async () => {
paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod);
await expect(
service.removePaymentMethod('pm-001', mockTenantId),
).rejects.toThrow(BadRequestException);
});
it('should throw NotFoundException if not found', async () => {
paymentMethodRepo.findOne.mockResolvedValue(null);
await expect(
service.removePaymentMethod('invalid-id', mockTenantId),
).rejects.toThrow(NotFoundException);
});
});
// ==================== Summary Tests ====================
describe('getBillingSummary', () => {
it('should return billing summary', async () => {
subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription);
paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod);
invoiceRepo.find.mockResolvedValue([mockInvoice as Invoice]);
const result = await service.getBillingSummary(mockTenantId);
expect(result.subscription).toEqual(mockSubscription);
expect(result.defaultPaymentMethod).toEqual(mockPaymentMethod);
expect(result.pendingInvoices).toBe(1);
expect(result.totalDue).toBe(116);
});
});
describe('checkSubscriptionStatus', () => {
it('should return active subscription status', async () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 15);
subscriptionRepo.findOne.mockResolvedValue({
...mockSubscription,
current_period_end: futureDate,
} as Subscription);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(true);
expect(result.daysRemaining).toBeGreaterThan(0);
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
});
it('should return expired status when no subscription', async () => {
subscriptionRepo.findOne.mockResolvedValue(null);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(false);
expect(result.daysRemaining).toBe(0);
expect(result.status).toBe(SubscriptionStatus.EXPIRED);
});
it('should return active for trial subscription', async () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
subscriptionRepo.findOne.mockResolvedValue({
...mockSubscription,
status: SubscriptionStatus.TRIAL,
current_period_end: futureDate,
} as Subscription);
const result = await service.checkSubscriptionStatus(mockTenantId);
expect(result.isActive).toBe(true);
expect(result.status).toBe(SubscriptionStatus.TRIAL);
});
});
});

View File

@ -0,0 +1,854 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PlansController } from '../controllers/plans.controller';
import { PlansService } from '../services/plans.service';
import { PlanResponseDto, PlanDetailResponseDto } from '../dto/plan-response.dto';
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
describe('PlansController', () => {
let controller: PlansController;
let service: jest.Mocked<PlansService>;
let reflector: Reflector;
// Mock plan response data
const mockFreePlanResponse: PlanResponseDto = {
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Free',
slug: 'free',
display_name: 'Free',
description: 'Perfect for getting started',
price_monthly: 0,
price_yearly: 0,
currency: 'USD',
features: ['Up to 3 users', 'Basic features', 'Community support'],
limits: { max_users: 3, storage_gb: 1 },
is_popular: false,
trial_days: 0,
};
const mockProPlanResponse: PlanResponseDto = {
id: '550e8400-e29b-41d4-a716-446655440002',
name: 'Professional',
slug: 'professional',
display_name: 'Professional',
description: 'For growing businesses',
tagline: 'Best for teams',
price_monthly: 79,
price_yearly: 790,
currency: 'USD',
features: [
'Up to 50 users',
'Storage: 100 GB',
'API access',
'Priority support',
'Custom integrations',
],
limits: { max_users: 50, storage_gb: 100 },
is_popular: true,
trial_days: 14,
};
const mockEnterprisePlanResponse: PlanResponseDto = {
id: '550e8400-e29b-41d4-a716-446655440003',
name: 'Enterprise',
slug: 'enterprise',
display_name: 'Enterprise',
description: 'For large organizations',
tagline: 'Custom solutions',
price_monthly: 0,
price_yearly: 0,
currency: 'USD',
features: ['Unlimited users', 'SSO/SAML', 'Dedicated support', 'SLA guarantee'],
limits: {},
is_popular: false,
trial_days: 30,
};
const mockProPlanDetailResponse: PlanDetailResponseDto = {
...mockProPlanResponse,
is_enterprise: false,
detailed_features: [
{ name: 'Up to 50 users', value: true, highlight: false },
{ name: 'Storage', value: '100 GB', highlight: true },
{ name: 'API access', value: true },
],
metadata: { promotion: 'summer2026' },
};
const mockEnterprisePlanDetailResponse: PlanDetailResponseDto = {
...mockEnterprisePlanResponse,
is_enterprise: true,
detailed_features: [
{ name: 'Unlimited users', value: true },
{ name: 'SSO/SAML', value: true },
],
metadata: undefined,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PlansController],
providers: [
{
provide: PlansService,
useValue: {
findAll: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
},
},
Reflector,
],
}).compile();
controller = module.get<PlansController>(PlansController);
service = module.get(PlansService);
reflector = module.get<Reflector>(Reflector);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Controller Definition', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should have PlansService injected', () => {
expect(service).toBeDefined();
});
});
// ============================================================
// PUBLIC ENDPOINT DECORATOR TESTS
// ============================================================
describe('Public Endpoint Decorators', () => {
it('should have @Public decorator on findAll method', () => {
const isPublic = reflector.get<boolean>(
IS_PUBLIC_KEY,
PlansController.prototype.findAll,
);
expect(isPublic).toBe(true);
});
it('should have @Public decorator on findOne method', () => {
const isPublic = reflector.get<boolean>(
IS_PUBLIC_KEY,
PlansController.prototype.findOne,
);
expect(isPublic).toBe(true);
});
});
// ============================================================
// findAll() - GET /plans
// ============================================================
describe('findAll', () => {
describe('Success Cases', () => {
it('should return all available plans', async () => {
const plans = [mockFreePlanResponse, mockProPlanResponse, mockEnterprisePlanResponse];
service.findAll.mockResolvedValue(plans);
const result = await controller.findAll();
expect(result).toEqual(plans);
expect(result).toHaveLength(3);
expect(service.findAll).toHaveBeenCalledTimes(1);
expect(service.findAll).toHaveBeenCalledWith();
});
it('should return empty array when no plans exist', async () => {
service.findAll.mockResolvedValue([]);
const result = await controller.findAll();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
expect(service.findAll).toHaveBeenCalledTimes(1);
});
it('should return plans with all required DTO fields', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(result[0]).toHaveProperty('id');
expect(result[0]).toHaveProperty('name');
expect(result[0]).toHaveProperty('slug');
expect(result[0]).toHaveProperty('display_name');
expect(result[0]).toHaveProperty('description');
expect(result[0]).toHaveProperty('price_monthly');
expect(result[0]).toHaveProperty('price_yearly');
expect(result[0]).toHaveProperty('currency');
expect(result[0]).toHaveProperty('features');
});
it('should return plans with optional fields when present', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(result[0].tagline).toBe('Best for teams');
expect(result[0].limits).toEqual({ max_users: 50, storage_gb: 100 });
expect(result[0].is_popular).toBe(true);
expect(result[0].trial_days).toBe(14);
});
it('should return plans without optional fields when not present', async () => {
const planWithoutOptionals: PlanResponseDto = {
id: 'plan-123',
name: 'Basic',
slug: 'basic',
display_name: 'Basic',
description: 'Basic plan',
price_monthly: 10,
price_yearly: 100,
currency: 'USD',
features: ['Feature 1'],
};
service.findAll.mockResolvedValue([planWithoutOptionals]);
const result = await controller.findAll();
expect(result[0].tagline).toBeUndefined();
expect(result[0].limits).toBeUndefined();
expect(result[0].is_popular).toBeUndefined();
expect(result[0].trial_days).toBeUndefined();
});
it('should return plans with features as string array', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(Array.isArray(result[0].features)).toBe(true);
expect(result[0].features).toContain('Up to 50 users');
expect(result[0].features).toContain('Storage: 100 GB');
expect(result[0].features).toContain('API access');
});
it('should return single plan when only one exists', async () => {
service.findAll.mockResolvedValue([mockFreePlanResponse]);
const result = await controller.findAll();
expect(result).toHaveLength(1);
expect(result[0].slug).toBe('free');
});
});
describe('Error Cases', () => {
it('should propagate service errors', async () => {
const error = new Error('Database connection failed');
service.findAll.mockRejectedValue(error);
await expect(controller.findAll()).rejects.toThrow('Database connection failed');
expect(service.findAll).toHaveBeenCalledTimes(1);
});
it('should handle unexpected service errors', async () => {
service.findAll.mockRejectedValue(new Error('Unexpected error'));
await expect(controller.findAll()).rejects.toThrow();
});
});
describe('Data Integrity', () => {
it('should preserve plan ordering from service', async () => {
const orderedPlans = [
{ ...mockFreePlanResponse, slug: 'free' },
{ ...mockProPlanResponse, slug: 'professional' },
{ ...mockEnterprisePlanResponse, slug: 'enterprise' },
];
service.findAll.mockResolvedValue(orderedPlans);
const result = await controller.findAll();
expect(result[0].slug).toBe('free');
expect(result[1].slug).toBe('professional');
expect(result[2].slug).toBe('enterprise');
});
it('should return exact data from service without modification', async () => {
const plans = [mockProPlanResponse];
service.findAll.mockResolvedValue(plans);
const result = await controller.findAll();
expect(result).toBe(plans);
});
});
});
// ============================================================
// findOne() - GET /plans/:id
// ============================================================
describe('findOne', () => {
describe('Success Cases', () => {
it('should return plan by ID', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002');
expect(result).toEqual(mockProPlanDetailResponse);
expect(service.findOne).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440002');
expect(service.findOne).toHaveBeenCalledTimes(1);
});
it('should return detailed plan response with additional fields', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002');
expect(result).toHaveProperty('detailed_features');
expect(result).toHaveProperty('metadata');
expect(result.detailed_features).toEqual([
{ name: 'Up to 50 users', value: true, highlight: false },
{ name: 'Storage', value: '100 GB', highlight: true },
{ name: 'API access', value: true },
]);
expect(result.metadata).toEqual({ promotion: 'summer2026' });
});
it('should return enterprise plan with is_enterprise flag', async () => {
service.findOne.mockResolvedValue(mockEnterprisePlanDetailResponse);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440003');
expect(result.is_enterprise).toBe(true);
expect(result.name).toBe('Enterprise');
});
it('should return non-enterprise plan without is_enterprise flag', async () => {
const nonEnterprisePlan: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
is_enterprise: undefined,
};
service.findOne.mockResolvedValue(nonEnterprisePlan);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002');
expect(result.is_enterprise).toBeUndefined();
});
it('should handle plan with all optional fields', async () => {
const fullPlan: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
tagline: 'Best value',
is_popular: true,
trial_days: 30,
is_enterprise: false,
metadata: { custom_field: 'value' },
};
service.findOne.mockResolvedValue(fullPlan);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002');
expect(result.tagline).toBe('Best value');
expect(result.is_popular).toBe(true);
expect(result.trial_days).toBe(30);
expect(result.metadata).toEqual({ custom_field: 'value' });
});
it('should handle plan with empty detailed_features', async () => {
const planWithEmptyFeatures: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
detailed_features: [],
};
service.findOne.mockResolvedValue(planWithEmptyFeatures);
const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002');
expect(result.detailed_features).toEqual([]);
});
it('should handle UUID format IDs', async () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
await controller.findOne(uuid);
expect(service.findOne).toHaveBeenCalledWith(uuid);
});
});
describe('Error Cases - NotFoundException', () => {
it('should throw NotFoundException when plan not found', async () => {
const notFoundError = new NotFoundException('Plan with ID "non-existent-id" not found');
service.findOne.mockRejectedValue(notFoundError);
await expect(controller.findOne('non-existent-id')).rejects.toThrow(NotFoundException);
expect(service.findOne).toHaveBeenCalledWith('non-existent-id');
});
it('should include ID in NotFoundException message', async () => {
const planId = 'missing-plan-123';
service.findOne.mockRejectedValue(
new NotFoundException(`Plan with ID "${planId}" not found`),
);
await expect(controller.findOne(planId)).rejects.toThrow(
`Plan with ID "${planId}" not found`,
);
});
it('should throw NotFoundException for invalid UUID', async () => {
service.findOne.mockRejectedValue(
new NotFoundException('Plan with ID "invalid-uuid" not found'),
);
await expect(controller.findOne('invalid-uuid')).rejects.toThrow(NotFoundException);
});
it('should throw NotFoundException for empty string ID', async () => {
service.findOne.mockRejectedValue(new NotFoundException('Plan with ID "" not found'));
await expect(controller.findOne('')).rejects.toThrow(NotFoundException);
});
});
describe('Error Cases - Other Errors', () => {
it('should propagate database errors', async () => {
service.findOne.mockRejectedValue(new Error('Database connection error'));
await expect(controller.findOne('plan-123')).rejects.toThrow('Database connection error');
});
it('should propagate generic errors', async () => {
service.findOne.mockRejectedValue(new Error('Unexpected error'));
await expect(controller.findOne('plan-123')).rejects.toThrow('Unexpected error');
});
});
describe('Parameter Handling', () => {
it('should pass ID parameter to service without modification', async () => {
const testId = 'test-plan-id-123';
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
await controller.findOne(testId);
expect(service.findOne).toHaveBeenCalledWith(testId);
});
it('should handle special characters in ID', async () => {
const specialId = 'plan-with-special_chars.123';
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
await controller.findOne(specialId);
expect(service.findOne).toHaveBeenCalledWith(specialId);
});
});
});
// ============================================================
// PLAN FEATURES MANAGEMENT TESTS
// ============================================================
describe('Plan Features', () => {
describe('Features Array Structure', () => {
it('should return features as string array in findAll response', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(Array.isArray(result[0].features)).toBe(true);
result[0].features.forEach((feature) => {
expect(typeof feature).toBe('string');
});
});
it('should return detailed_features in findOne response', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
expect(Array.isArray(result.detailed_features)).toBe(true);
expect(result.detailed_features?.[0]).toHaveProperty('name');
expect(result.detailed_features?.[0]).toHaveProperty('value');
});
it('should handle detailed_features with highlight flag', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
const highlightedFeature = result.detailed_features?.find((f) => f.highlight === true);
expect(highlightedFeature).toBeDefined();
expect(highlightedFeature?.name).toBe('Storage');
});
it('should handle detailed_features with boolean values', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
const booleanFeature = result.detailed_features?.find(
(f) => typeof f.value === 'boolean',
);
expect(booleanFeature).toBeDefined();
expect(booleanFeature?.value).toBe(true);
});
it('should handle detailed_features with string values', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
const stringFeature = result.detailed_features?.find((f) => typeof f.value === 'string');
expect(stringFeature).toBeDefined();
expect(stringFeature?.value).toBe('100 GB');
});
});
describe('Empty Features', () => {
it('should handle plan with empty features array', async () => {
const planWithNoFeatures: PlanResponseDto = {
...mockProPlanResponse,
features: [],
};
service.findAll.mockResolvedValue([planWithNoFeatures]);
const result = await controller.findAll();
expect(result[0].features).toEqual([]);
});
it('should handle detailed plan with undefined detailed_features', async () => {
const planWithoutDetailedFeatures: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
detailed_features: undefined,
};
service.findOne.mockResolvedValue(planWithoutDetailedFeatures);
const result = await controller.findOne('plan-123');
expect(result.detailed_features).toBeUndefined();
});
});
});
// ============================================================
// PLAN LIMITS TESTS
// ============================================================
describe('Plan Limits', () => {
it('should return limits as Record<string, number>', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(typeof result[0].limits).toBe('object');
expect(result[0].limits?.max_users).toBe(50);
expect(result[0].limits?.storage_gb).toBe(100);
});
it('should handle plan with empty limits', async () => {
service.findAll.mockResolvedValue([mockEnterprisePlanResponse]);
const result = await controller.findAll();
expect(result[0].limits).toEqual({});
});
it('should handle plan with undefined limits', async () => {
const planWithNoLimits: PlanResponseDto = {
...mockProPlanResponse,
limits: undefined,
};
service.findAll.mockResolvedValue([planWithNoLimits]);
const result = await controller.findAll();
expect(result[0].limits).toBeUndefined();
});
});
// ============================================================
// PRICING TESTS
// ============================================================
describe('Plan Pricing', () => {
it('should return correct monthly price', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(result[0].price_monthly).toBe(79);
});
it('should return correct yearly price', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(result[0].price_yearly).toBe(790);
});
it('should handle free plan with zero prices', async () => {
service.findAll.mockResolvedValue([mockFreePlanResponse]);
const result = await controller.findAll();
expect(result[0].price_monthly).toBe(0);
expect(result[0].price_yearly).toBe(0);
});
it('should handle enterprise plan with contact pricing (zero)', async () => {
service.findAll.mockResolvedValue([mockEnterprisePlanResponse]);
const result = await controller.findAll();
expect(result[0].price_monthly).toBe(0);
expect(result[0].price_yearly).toBe(0);
});
it('should return currency code', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const result = await controller.findAll();
expect(result[0].currency).toBe('USD');
});
});
// ============================================================
// METADATA TESTS
// ============================================================
describe('Plan Metadata', () => {
it('should return metadata in detailed plan response', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
expect(result.metadata).toBeDefined();
expect(result.metadata?.promotion).toBe('summer2026');
});
it('should handle plan without metadata', async () => {
const planWithoutMetadata: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
metadata: undefined,
};
service.findOne.mockResolvedValue(planWithoutMetadata);
const result = await controller.findOne('plan-123');
expect(result.metadata).toBeUndefined();
});
it('should handle plan with complex metadata', async () => {
const planWithComplexMetadata: PlanDetailResponseDto = {
...mockProPlanDetailResponse,
metadata: {
promotion: 'summer2026',
discount_percentage: 20,
valid_until: '2026-12-31',
eligible_countries: ['US', 'CA', 'MX'],
},
};
service.findOne.mockResolvedValue(planWithComplexMetadata);
const result = await controller.findOne('plan-123');
expect(result.metadata?.promotion).toBe('summer2026');
expect(result.metadata?.discount_percentage).toBe(20);
expect(result.metadata?.eligible_countries).toContain('US');
});
});
// ============================================================
// SERVICE INTERACTION TESTS
// ============================================================
describe('Service Interaction', () => {
it('should call service.findAll exactly once', async () => {
service.findAll.mockResolvedValue([]);
await controller.findAll();
expect(service.findAll).toHaveBeenCalledTimes(1);
});
it('should call service.findOne exactly once', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
await controller.findOne('plan-123');
expect(service.findOne).toHaveBeenCalledTimes(1);
});
it('should not call findOne when calling findAll', async () => {
service.findAll.mockResolvedValue([]);
await controller.findAll();
expect(service.findOne).not.toHaveBeenCalled();
});
it('should not call findAll when calling findOne', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
await controller.findOne('plan-123');
expect(service.findAll).not.toHaveBeenCalled();
});
it('should return the exact value from service.findAll', async () => {
const plans = [mockFreePlanResponse, mockProPlanResponse];
service.findAll.mockResolvedValue(plans);
const result = await controller.findAll();
expect(result).toBe(plans);
});
it('should return the exact value from service.findOne', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const result = await controller.findOne('plan-123');
expect(result).toBe(mockProPlanDetailResponse);
});
});
// ============================================================
// CONCURRENT ACCESS TESTS
// ============================================================
describe('Concurrent Access', () => {
it('should handle multiple concurrent findAll requests', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
const results = await Promise.all([
controller.findAll(),
controller.findAll(),
controller.findAll(),
]);
expect(results).toHaveLength(3);
results.forEach((result) => {
expect(result).toEqual([mockProPlanResponse]);
});
expect(service.findAll).toHaveBeenCalledTimes(3);
});
it('should handle multiple concurrent findOne requests', async () => {
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const ids = ['plan-1', 'plan-2', 'plan-3'];
const results = await Promise.all(ids.map((id) => controller.findOne(id)));
expect(results).toHaveLength(3);
expect(service.findOne).toHaveBeenCalledTimes(3);
});
it('should handle mixed concurrent requests', async () => {
service.findAll.mockResolvedValue([mockProPlanResponse]);
service.findOne.mockResolvedValue(mockProPlanDetailResponse);
const results = await Promise.all([
controller.findAll(),
controller.findOne('plan-1'),
controller.findAll(),
controller.findOne('plan-2'),
]);
expect(results).toHaveLength(4);
expect(service.findAll).toHaveBeenCalledTimes(2);
expect(service.findOne).toHaveBeenCalledTimes(2);
});
});
// ============================================================
// EDGE CASES
// ============================================================
describe('Edge Cases', () => {
it('should handle very long plan name', async () => {
const longNamePlan: PlanResponseDto = {
...mockProPlanResponse,
name: 'A'.repeat(100),
display_name: 'A'.repeat(100),
};
service.findAll.mockResolvedValue([longNamePlan]);
const result = await controller.findAll();
expect(result[0].name).toHaveLength(100);
});
it('should handle very long description', async () => {
const longDescriptionPlan: PlanResponseDto = {
...mockProPlanResponse,
description: 'B'.repeat(1000),
};
service.findAll.mockResolvedValue([longDescriptionPlan]);
const result = await controller.findAll();
expect(result[0].description).toHaveLength(1000);
});
it('should handle plan with many features', async () => {
const manyFeatures = Array.from({ length: 50 }, (_, i) => `Feature ${i + 1}`);
const planWithManyFeatures: PlanResponseDto = {
...mockProPlanResponse,
features: manyFeatures,
};
service.findAll.mockResolvedValue([planWithManyFeatures]);
const result = await controller.findAll();
expect(result[0].features).toHaveLength(50);
});
it('should handle plan with special characters in slug', async () => {
const planWithSpecialSlug: PlanResponseDto = {
...mockProPlanResponse,
slug: 'professional-2026',
};
service.findAll.mockResolvedValue([planWithSpecialSlug]);
const result = await controller.findAll();
expect(result[0].slug).toBe('professional-2026');
});
it('should handle plan with large numeric limits', async () => {
const planWithLargeLimits: PlanResponseDto = {
...mockProPlanResponse,
limits: { max_users: 999999, storage_gb: 10000 },
};
service.findAll.mockResolvedValue([planWithLargeLimits]);
const result = await controller.findAll();
expect(result[0].limits?.max_users).toBe(999999);
expect(result[0].limits?.storage_gb).toBe(10000);
});
it('should handle plan with high prices', async () => {
const expensivePlan: PlanResponseDto = {
...mockProPlanResponse,
price_monthly: 9999.99,
price_yearly: 99999.99,
};
service.findAll.mockResolvedValue([expensivePlan]);
const result = await controller.findAll();
expect(result[0].price_monthly).toBe(9999.99);
expect(result[0].price_yearly).toBe(99999.99);
});
it('should handle different currency codes', async () => {
const eurPlan: PlanResponseDto = {
...mockProPlanResponse,
currency: 'EUR',
};
service.findAll.mockResolvedValue([eurPlan]);
const result = await controller.findAll();
expect(result[0].currency).toBe('EUR');
});
});
});

View File

@ -0,0 +1,313 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotFoundException } from '@nestjs/common';
import { PlansService } from '../services/plans.service';
import { Plan } from '../entities/plan.entity';
describe('PlansService', () => {
let service: PlansService;
let planRepo: jest.Mocked<Repository<Plan>>;
const mockPlan: Partial<Plan> = {
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'Professional',
slug: 'professional',
description: 'For growing businesses',
tagline: 'Best for teams',
price_monthly: 79,
price_yearly: 790,
currency: 'USD',
features: [
{ name: 'Up to 50 users', value: true, highlight: false },
{ name: 'Storage', value: '100 GB', highlight: true },
{ name: 'API access', value: true },
],
included_features: ['Priority support', 'Custom integrations'],
limits: { max_users: 50, storage_gb: 100 },
is_popular: true,
is_enterprise: false,
is_active: true,
is_visible: true,
sort_order: 2,
trial_days: 14,
metadata: { promotion: 'summer2026' },
created_at: new Date('2026-01-01'),
updated_at: new Date('2026-01-01'),
};
const mockFreePlan: Partial<Plan> = {
id: '550e8400-e29b-41d4-a716-446655440002',
name: 'Free',
slug: 'free',
description: 'Perfect for getting started',
tagline: null,
price_monthly: 0,
price_yearly: 0,
currency: 'USD',
features: [
{ name: 'Up to 3 users', value: true },
{ name: 'Basic features', value: true },
],
included_features: ['Community support'],
limits: { max_users: 3, storage_gb: 1 },
is_popular: false,
is_enterprise: false,
is_active: true,
is_visible: true,
sort_order: 1,
trial_days: 0,
metadata: null,
created_at: new Date('2026-01-01'),
updated_at: new Date('2026-01-01'),
};
const mockEnterprisePlan: Partial<Plan> = {
id: '550e8400-e29b-41d4-a716-446655440003',
name: 'Enterprise',
slug: 'enterprise',
description: 'For large organizations',
tagline: 'Custom solutions',
price_monthly: null,
price_yearly: null,
currency: 'USD',
features: [
{ name: 'Unlimited users', value: true },
{ name: 'SSO/SAML', value: true },
],
included_features: ['Dedicated support', 'SLA guarantee'],
limits: {},
is_popular: false,
is_enterprise: true,
is_active: true,
is_visible: true,
sort_order: 4,
trial_days: 30,
metadata: null,
created_at: new Date('2026-01-01'),
updated_at: new Date('2026-01-01'),
};
beforeEach(async () => {
const mockPlanRepo = {
find: jest.fn(),
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
PlansService,
{ provide: getRepositoryToken(Plan), useValue: mockPlanRepo },
],
}).compile();
service = module.get<PlansService>(PlansService);
planRepo = module.get(getRepositoryToken(Plan));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return all visible and active plans', async () => {
const plans = [mockFreePlan, mockPlan, mockEnterprisePlan];
planRepo.find.mockResolvedValue(plans as Plan[]);
const result = await service.findAll();
expect(result).toHaveLength(3);
expect(planRepo.find).toHaveBeenCalledWith({
where: {
is_active: true,
is_visible: true,
},
order: {
sort_order: 'ASC',
},
});
});
it('should return empty array when no plans exist', async () => {
planRepo.find.mockResolvedValue([]);
const result = await service.findAll();
expect(result).toHaveLength(0);
expect(result).toEqual([]);
});
it('should transform plan entity to response DTO correctly', async () => {
planRepo.find.mockResolvedValue([mockPlan as Plan]);
const result = await service.findAll();
expect(result[0]).toMatchObject({
id: mockPlan.id,
name: mockPlan.name,
slug: mockPlan.slug,
display_name: mockPlan.name,
description: mockPlan.description,
tagline: mockPlan.tagline,
price_monthly: 79,
price_yearly: 790,
currency: 'USD',
is_popular: true,
trial_days: 14,
});
});
it('should extract features as string array', async () => {
planRepo.find.mockResolvedValue([mockPlan as Plan]);
const result = await service.findAll();
// Features should include both features array items and included_features
expect(result[0].features).toContain('Up to 50 users');
expect(result[0].features).toContain('Storage: 100 GB');
expect(result[0].features).toContain('API access');
expect(result[0].features).toContain('Priority support');
expect(result[0].features).toContain('Custom integrations');
});
it('should handle null prices correctly', async () => {
planRepo.find.mockResolvedValue([mockEnterprisePlan as Plan]);
const result = await service.findAll();
expect(result[0].price_monthly).toBe(0);
expect(result[0].price_yearly).toBe(0);
});
it('should include limits in response', async () => {
planRepo.find.mockResolvedValue([mockPlan as Plan]);
const result = await service.findAll();
expect(result[0].limits).toEqual({ max_users: 50, storage_gb: 100 });
});
});
describe('findOne', () => {
it('should return plan by ID', async () => {
planRepo.findOne.mockResolvedValue(mockPlan as Plan);
const result = await service.findOne(mockPlan.id!);
expect(result).toBeDefined();
expect(result.id).toBe(mockPlan.id);
expect(planRepo.findOne).toHaveBeenCalledWith({
where: { id: mockPlan.id },
});
});
it('should throw NotFoundException when plan not found', async () => {
planRepo.findOne.mockResolvedValue(null);
await expect(service.findOne('non-existent-id')).rejects.toThrow(
NotFoundException,
);
await expect(service.findOne('non-existent-id')).rejects.toThrow(
'Plan with ID "non-existent-id" not found',
);
});
it('should return detailed DTO with additional fields', async () => {
planRepo.findOne.mockResolvedValue(mockPlan as Plan);
const result = await service.findOne(mockPlan.id!);
// is_enterprise is false so it becomes undefined due to || undefined pattern
expect(result.is_enterprise).toBeUndefined();
expect(result.detailed_features).toEqual(mockPlan.features);
expect(result.metadata).toEqual({ promotion: 'summer2026' });
});
it('should include is_enterprise flag for enterprise plans', async () => {
planRepo.findOne.mockResolvedValue(mockEnterprisePlan as Plan);
const result = await service.findOne(mockEnterprisePlan.id!);
expect(result.is_enterprise).toBe(true);
});
});
describe('findBySlug', () => {
it('should return plan by slug', async () => {
planRepo.findOne.mockResolvedValue(mockPlan as Plan);
const result = await service.findBySlug('professional');
expect(result).toBeDefined();
expect(result.slug).toBe('professional');
expect(planRepo.findOne).toHaveBeenCalledWith({
where: { slug: 'professional' },
});
});
it('should throw NotFoundException when plan not found by slug', async () => {
planRepo.findOne.mockResolvedValue(null);
await expect(service.findBySlug('non-existent')).rejects.toThrow(
NotFoundException,
);
await expect(service.findBySlug('non-existent')).rejects.toThrow(
'Plan with slug "non-existent" not found',
);
});
});
describe('feature extraction', () => {
it('should handle empty features array', async () => {
const planWithNoFeatures = {
...mockPlan,
features: [],
included_features: [],
};
planRepo.find.mockResolvedValue([planWithNoFeatures as Plan]);
const result = await service.findAll();
expect(result[0].features).toEqual([]);
});
it('should handle boolean feature values', async () => {
const planWithBooleanFeatures = {
...mockPlan,
features: [
{ name: 'Feature enabled', value: true },
{ name: 'Feature disabled', value: false },
],
included_features: [],
};
planRepo.find.mockResolvedValue([planWithBooleanFeatures as Plan]);
const result = await service.findAll();
// Only true boolean features should be included
expect(result[0].features).toContain('Feature enabled');
expect(result[0].features).not.toContain('Feature disabled');
});
it('should handle string feature values', async () => {
const planWithStringFeatures = {
...mockPlan,
features: [{ name: 'Storage', value: '500 GB' }],
included_features: [],
};
planRepo.find.mockResolvedValue([planWithStringFeatures as Plan]);
const result = await service.findAll();
expect(result[0].features).toContain('Storage: 500 GB');
});
it('should handle null tagline', async () => {
planRepo.find.mockResolvedValue([mockFreePlan as Plan]);
const result = await service.findAll();
expect(result[0].tagline).toBeUndefined();
});
});
});

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