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:
parent
6a750c0408
commit
dfe6a715f0
39
.dockerignore
Normal file
39
.dockerignore
Normal 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
56
.env.example
Normal 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
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal 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
16
__mocks__/@nestjs/websockets.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
31
__mocks__/@nestjs/websockets.ts
Normal file
31
__mocks__/@nestjs/websockets.ts
Normal 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
34
__mocks__/socket.io.d.ts
vendored
Normal 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
33
__mocks__/socket.io.ts
Normal 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
41
__mocks__/web-push.d.ts
vendored
Normal 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
18
__mocks__/web-push.ts
Normal 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
71
eslint.config.js
Normal 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
35
jest.config.cjs
Normal 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
11790
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
Normal file
87
package.json
Normal 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
93
src/app.module.ts
Normal 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 {}
|
||||
17
src/config/database.config.ts
Normal file
17
src/config/database.config.ts
Normal 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
164
src/config/env.config.ts
Normal 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
2
src/config/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './env.config';
|
||||
export * from './database.config';
|
||||
74
src/main.ts
Normal file
74
src/main.ts
Normal 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();
|
||||
756
src/modules/ai/__tests__/ai.controller.spec.ts
Normal file
756
src/modules/ai/__tests__/ai.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
336
src/modules/ai/__tests__/ai.service.spec.ts
Normal file
336
src/modules/ai/__tests__/ai.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/modules/ai/ai.controller.ts
Normal file
120
src/modules/ai/ai.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
src/modules/ai/ai.module.ts
Normal file
18
src/modules/ai/ai.module.ts
Normal 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 {}
|
||||
1
src/modules/ai/clients/index.ts
Normal file
1
src/modules/ai/clients/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { OpenRouterClient } from './openrouter.client';
|
||||
234
src/modules/ai/clients/openrouter.client.ts
Normal file
234
src/modules/ai/clients/openrouter.client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
src/modules/ai/dto/chat.dto.ts
Normal file
100
src/modules/ai/dto/chat.dto.ts
Normal 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;
|
||||
}
|
||||
149
src/modules/ai/dto/config.dto.ts
Normal file
149
src/modules/ai/dto/config.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
14
src/modules/ai/dto/index.ts
Normal file
14
src/modules/ai/dto/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export {
|
||||
ChatMessageDto,
|
||||
ChatRequestDto,
|
||||
ChatChoiceDto,
|
||||
UsageDto,
|
||||
ChatResponseDto,
|
||||
} from './chat.dto';
|
||||
|
||||
export {
|
||||
UpdateAIConfigDto,
|
||||
AIConfigResponseDto,
|
||||
UsageStatsDto,
|
||||
AIModelDto,
|
||||
} from './config.dto';
|
||||
77
src/modules/ai/entities/ai-config.entity.ts
Normal file
77
src/modules/ai/entities/ai-config.entity.ts
Normal 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;
|
||||
}
|
||||
90
src/modules/ai/entities/ai-usage.entity.ts
Normal file
90
src/modules/ai/entities/ai-usage.entity.ts
Normal 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;
|
||||
}
|
||||
2
src/modules/ai/entities/index.ts
Normal file
2
src/modules/ai/entities/index.ts
Normal 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
6
src/modules/ai/index.ts
Normal 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';
|
||||
193
src/modules/ai/services/ai.service.ts
Normal file
193
src/modules/ai/services/ai.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1
src/modules/ai/services/index.ts
Normal file
1
src/modules/ai/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { AIService } from './ai.service';
|
||||
111
src/modules/analytics/analytics.controller.ts
Normal file
111
src/modules/analytics/analytics.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
18
src/modules/analytics/analytics.module.ts
Normal file
18
src/modules/analytics/analytics.module.ts
Normal 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 {}
|
||||
513
src/modules/analytics/analytics.service.ts
Normal file
513
src/modules/analytics/analytics.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
15
src/modules/analytics/dto/analytics-query.dto.ts
Normal file
15
src/modules/analytics/dto/analytics-query.dto.ts
Normal 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';
|
||||
}
|
||||
27
src/modules/analytics/dto/analytics-summary.dto.ts
Normal file
27
src/modules/analytics/dto/analytics-summary.dto.ts
Normal 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;
|
||||
}
|
||||
33
src/modules/analytics/dto/billing-metrics.dto.ts
Normal file
33
src/modules/analytics/dto/billing-metrics.dto.ts
Normal 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;
|
||||
}
|
||||
6
src/modules/analytics/dto/index.ts
Normal file
6
src/modules/analytics/dto/index.ts
Normal 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';
|
||||
17
src/modules/analytics/dto/trend-data.dto.ts
Normal file
17
src/modules/analytics/dto/trend-data.dto.ts
Normal 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[];
|
||||
}
|
||||
30
src/modules/analytics/dto/usage-metrics.dto.ts
Normal file
30
src/modules/analytics/dto/usage-metrics.dto.ts
Normal 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 }>;
|
||||
}
|
||||
30
src/modules/analytics/dto/user-metrics.dto.ts
Normal file
30
src/modules/analytics/dto/user-metrics.dto.ts
Normal 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;
|
||||
}
|
||||
4
src/modules/analytics/index.ts
Normal file
4
src/modules/analytics/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './analytics.module';
|
||||
export * from './analytics.controller';
|
||||
export * from './analytics.service';
|
||||
export * from './dto';
|
||||
919
src/modules/audit/__tests__/audit.controller.spec.ts
Normal file
919
src/modules/audit/__tests__/audit.controller.spec.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
567
src/modules/audit/__tests__/audit.service.spec.ts
Normal file
567
src/modules/audit/__tests__/audit.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/modules/audit/audit.controller.ts
Normal file
145
src/modules/audit/audit.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/modules/audit/audit.module.ts
Normal file
23
src/modules/audit/audit.module.ts
Normal 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 {}
|
||||
29
src/modules/audit/dto/create-activity.dto.ts
Normal file
29
src/modules/audit/dto/create-activity.dto.ts
Normal 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>;
|
||||
}
|
||||
3
src/modules/audit/dto/index.ts
Normal file
3
src/modules/audit/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './query-audit.dto';
|
||||
export * from './query-activity.dto';
|
||||
export * from './create-activity.dto';
|
||||
60
src/modules/audit/dto/query-activity.dto.ts
Normal file
60
src/modules/audit/dto/query-activity.dto.ts
Normal 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;
|
||||
}
|
||||
60
src/modules/audit/dto/query-audit.dto.ts
Normal file
60
src/modules/audit/dto/query-audit.dto.ts
Normal 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;
|
||||
}
|
||||
64
src/modules/audit/entities/activity-log.entity.ts
Normal file
64
src/modules/audit/entities/activity-log.entity.ts
Normal 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;
|
||||
}
|
||||
81
src/modules/audit/entities/audit-log.entity.ts
Normal file
81
src/modules/audit/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
2
src/modules/audit/entities/index.ts
Normal file
2
src/modules/audit/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './audit-log.entity';
|
||||
export * from './activity-log.entity';
|
||||
6
src/modules/audit/index.ts
Normal file
6
src/modules/audit/index.ts
Normal 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';
|
||||
179
src/modules/audit/interceptors/audit.interceptor.ts
Normal file
179
src/modules/audit/interceptors/audit.interceptor.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/modules/audit/interceptors/index.ts
Normal file
9
src/modules/audit/interceptors/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export {
|
||||
AuditInterceptor,
|
||||
AuditActionDecorator,
|
||||
AuditEntity,
|
||||
SkipAudit,
|
||||
AUDIT_ACTION_KEY,
|
||||
AUDIT_ENTITY_KEY,
|
||||
SKIP_AUDIT_KEY
|
||||
} from './audit.interceptor';
|
||||
320
src/modules/audit/services/audit.service.ts
Normal file
320
src/modules/audit/services/audit.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/modules/audit/services/index.ts
Normal file
1
src/modules/audit/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './audit.service';
|
||||
240
src/modules/auth/__tests__/auth.controller.spec.ts
Normal file
240
src/modules/auth/__tests__/auth.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
845
src/modules/auth/__tests__/auth.service.spec.ts
Normal file
845
src/modules/auth/__tests__/auth.service.spec.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/modules/auth/__tests__/jwt.strategy.spec.ts
Normal file
81
src/modules/auth/__tests__/jwt.strategy.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1394
src/modules/auth/__tests__/oauth.controller.spec.ts
Normal file
1394
src/modules/auth/__tests__/oauth.controller.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
281
src/modules/auth/auth.controller.ts
Normal file
281
src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/modules/auth/auth.module.ts
Normal file
46
src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
1
src/modules/auth/controllers/index.ts
Normal file
1
src/modules/auth/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './oauth.controller';
|
||||
278
src/modules/auth/controllers/oauth.controller.ts
Normal file
278
src/modules/auth/controllers/oauth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/modules/auth/decorators/current-user.decorator.ts
Normal file
15
src/modules/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
4
src/modules/auth/decorators/index.ts
Normal file
4
src/modules/auth/decorators/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './public.decorator';
|
||||
export * from './current-user.decorator';
|
||||
export * from './tenant.decorator';
|
||||
export * from './roles.decorator';
|
||||
4
src/modules/auth/decorators/public.decorator.ts
Normal file
4
src/modules/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
9
src/modules/auth/decorators/roles.decorator.ts
Normal file
9
src/modules/auth/decorators/roles.decorator.ts
Normal 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);
|
||||
27
src/modules/auth/decorators/tenant.decorator.ts
Normal file
27
src/modules/auth/decorators/tenant.decorator.ts
Normal 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 '';
|
||||
},
|
||||
);
|
||||
3
src/modules/auth/dto/index.ts
Normal file
3
src/modules/auth/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './login.dto';
|
||||
export * from './register.dto';
|
||||
export * from './reset-password.dto';
|
||||
15
src/modules/auth/dto/login.dto.ts
Normal file
15
src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
94
src/modules/auth/dto/mfa.dto.ts
Normal file
94
src/modules/auth/dto/mfa.dto.ts
Normal 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;
|
||||
}
|
||||
41
src/modules/auth/dto/register.dto.ts
Normal file
41
src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
43
src/modules/auth/dto/reset-password.dto.ts
Normal file
43
src/modules/auth/dto/reset-password.dto.ts
Normal 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;
|
||||
}
|
||||
5
src/modules/auth/entities/index.ts
Normal file
5
src/modules/auth/entities/index.ts
Normal 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';
|
||||
76
src/modules/auth/entities/oauth-connection.entity.ts
Normal file
76
src/modules/auth/entities/oauth-connection.entity.ts
Normal 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;
|
||||
}
|
||||
9
src/modules/auth/entities/oauth-provider.enum.ts
Normal file
9
src/modules/auth/entities/oauth-provider.enum.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Enum for supported OAuth 2.0 providers
|
||||
*/
|
||||
export enum OAuthProvider {
|
||||
GOOGLE = 'google',
|
||||
MICROSOFT = 'microsoft',
|
||||
GITHUB = 'github',
|
||||
APPLE = 'apple',
|
||||
}
|
||||
49
src/modules/auth/entities/session.entity.ts
Normal file
49
src/modules/auth/entities/session.entity.ts
Normal 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;
|
||||
}
|
||||
47
src/modules/auth/entities/token.entity.ts
Normal file
47
src/modules/auth/entities/token.entity.ts
Normal 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;
|
||||
}
|
||||
85
src/modules/auth/entities/user.entity.ts
Normal file
85
src/modules/auth/entities/user.entity.ts
Normal 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(' ');
|
||||
}
|
||||
}
|
||||
2
src/modules/auth/guards/index.ts
Normal file
2
src/modules/auth/guards/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
28
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
28
src/modules/auth/guards/jwt-auth.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
src/modules/auth/guards/roles.guard.ts
Normal file
27
src/modules/auth/guards/roles.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
src/modules/auth/index.ts
Normal file
8
src/modules/auth/index.ts
Normal 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';
|
||||
482
src/modules/auth/services/auth.service.ts
Normal file
482
src/modules/auth/services/auth.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
2
src/modules/auth/services/index.ts
Normal file
2
src/modules/auth/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './auth.service';
|
||||
export * from './oauth.service';
|
||||
351
src/modules/auth/services/mfa.service.ts
Normal file
351
src/modules/auth/services/mfa.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
521
src/modules/auth/services/oauth.service.ts
Normal file
521
src/modules/auth/services/oauth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/modules/auth/strategies/index.ts
Normal file
1
src/modules/auth/strategies/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './jwt.strategy';
|
||||
39
src/modules/auth/strategies/jwt.strategy.ts
Normal file
39
src/modules/auth/strategies/jwt.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
903
src/modules/billing/__tests__/billing-edge-cases.spec.ts
Normal file
903
src/modules/billing/__tests__/billing-edge-cases.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
src/modules/billing/__tests__/billing.controller.spec.ts
Normal file
268
src/modules/billing/__tests__/billing.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
552
src/modules/billing/__tests__/billing.service.spec.ts
Normal file
552
src/modules/billing/__tests__/billing.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
854
src/modules/billing/__tests__/plans.controller.spec.ts
Normal file
854
src/modules/billing/__tests__/plans.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
313
src/modules/billing/__tests__/plans.service.spec.ts
Normal file
313
src/modules/billing/__tests__/plans.service.spec.ts
Normal 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
Loading…
Reference in New Issue
Block a user