This commit is contained in:
@@ -18,6 +18,7 @@ import { User } from '../../modules/user/entities/user.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity'; // [P2-2]
|
||||
import { CaslModule } from './casl/casl.module';
|
||||
import { PermissionsGuard } from './guards/permissions.guard';
|
||||
import type { StringValue } from 'ms';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -31,7 +32,7 @@ import { PermissionsGuard } from './guards/permissions.guard';
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: (configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
'15m') as StringValue,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Repository } from 'typeorm';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import type { StringValue } from 'ms';
|
||||
|
||||
import { UserService } from '../../modules/user/user.service';
|
||||
import { User } from '../../modules/user/entities/user.entity';
|
||||
@@ -83,7 +84,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// 2. Login: สร้าง Access & Refresh Token และบันทึกลง DB
|
||||
async login(user: any) {
|
||||
async login(user: User) {
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.user_id,
|
||||
@@ -93,20 +94,20 @@ export class AuthService {
|
||||
const isBot = user.username === 'migration_bot';
|
||||
const accessTokenExpiresIn = isBot
|
||||
? '100y'
|
||||
: (this.configService.get<string>('JWT_EXPIRATION') || '15m');
|
||||
: this.configService.get<string>('JWT_EXPIRATION') || '15m';
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: accessTokenExpiresIn as any,
|
||||
expiresIn: accessTokenExpiresIn as StringValue,
|
||||
});
|
||||
|
||||
const refreshTokenExpiresIn = isBot
|
||||
? '100y'
|
||||
: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d');
|
||||
: this.configService.get<string>('JWT_REFRESH_EXPIRATION') || '7d';
|
||||
|
||||
const refreshToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: refreshTokenExpiresIn as any,
|
||||
expiresIn: refreshTokenExpiresIn as StringValue,
|
||||
});
|
||||
|
||||
// [P2-2] Store Refresh Token in DB
|
||||
@@ -189,13 +190,13 @@ export class AuthService {
|
||||
const newAccessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_SECRET'),
|
||||
expiresIn: (this.configService.get<string>('JWT_EXPIRATION') ||
|
||||
'15m') as any,
|
||||
'15m') as StringValue,
|
||||
});
|
||||
|
||||
const newRefreshToken = await this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: (this.configService.get<string>('JWT_REFRESH_EXPIRATION') ||
|
||||
'7d') as any,
|
||||
'7d') as StringValue,
|
||||
});
|
||||
|
||||
// Revoke OLD token and point to NEW one
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AbilityFactory, ScopeContext } from './ability.factory';
|
||||
import { User } from '../../../modules/user/entities/user.entity';
|
||||
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
|
||||
import { Role } from '../../../modules/auth/entities/role.entity';
|
||||
|
||||
describe('AbilityFactory', () => {
|
||||
let factory: AbilityFactory;
|
||||
@@ -158,7 +159,7 @@ function createMockAssignment(props: {
|
||||
permissions: props.permissionNames.map((name) => ({
|
||||
permissionName: name,
|
||||
})),
|
||||
} as any;
|
||||
} as Partial<Role> as Role;
|
||||
|
||||
return assignment;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { User } from '../../../modules/user/entities/user.entity';
|
||||
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
|
||||
|
||||
// Define action types
|
||||
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
|
||||
export type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
|
||||
|
||||
// Define subject types (resources)
|
||||
type Subjects =
|
||||
export type Subjects =
|
||||
| 'correspondence'
|
||||
| 'rfa'
|
||||
| 'drawing'
|
||||
@@ -65,9 +65,10 @@ export class AbilityFactory {
|
||||
|
||||
return build({
|
||||
// Detect subject type (for future use with objects)
|
||||
detectSubjectType: (item: any) => {
|
||||
detectSubjectType: (item: object) => {
|
||||
if (typeof item === 'string') return item;
|
||||
return item.constructor;
|
||||
return (item as Record<string, unknown>)
|
||||
.constructor as unknown as Subjects;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AbilityFactory, ScopeContext } from '../casl/ability.factory';
|
||||
import {
|
||||
AbilityFactory,
|
||||
ScopeContext,
|
||||
Actions,
|
||||
Subjects,
|
||||
} from '../casl/ability.factory';
|
||||
import { PERMISSIONS_KEY } from '../../decorators/require-permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
@@ -43,7 +48,7 @@ export class PermissionsGuard implements CanActivate {
|
||||
// Check if user has ALL required permissions
|
||||
const hasPermission = requiredPermissions.every((permission) => {
|
||||
const [action, subject] = this.parsePermission(permission);
|
||||
return ability.can(action as any, subject as any);
|
||||
return ability.can(action as Actions, subject as Subjects);
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
@@ -59,23 +64,31 @@ export class PermissionsGuard implements CanActivate {
|
||||
* Extract scope context from request
|
||||
* Priority: params > body > query
|
||||
*/
|
||||
private extractScope(request: any): ScopeContext {
|
||||
return {
|
||||
private extractScope(request: {
|
||||
params: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}): ScopeContext {
|
||||
const raw = {
|
||||
organizationId:
|
||||
request.params.organizationId ||
|
||||
request.body.organizationId ||
|
||||
request.query.organizationId ||
|
||||
undefined,
|
||||
request.query.organizationId,
|
||||
projectId:
|
||||
request.params.projectId ||
|
||||
request.body.projectId ||
|
||||
request.query.projectId ||
|
||||
undefined,
|
||||
request.query.projectId,
|
||||
contractId:
|
||||
request.params.contractId ||
|
||||
request.body.contractId ||
|
||||
request.query.contractId ||
|
||||
undefined,
|
||||
request.query.contractId,
|
||||
};
|
||||
return {
|
||||
organizationId: raw.organizationId
|
||||
? Number(raw.organizationId)
|
||||
: undefined,
|
||||
projectId: raw.projectId ? Number(raw.projectId) : undefined,
|
||||
contractId: raw.contractId ? Number(raw.contractId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CryptoService } from './services/crypto.service';
|
||||
import { RequestContextService } from './services/request-context.service';
|
||||
import { UuidResolverService } from './services/uuid-resolver.service';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
|
||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
@@ -16,6 +17,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
providers: [
|
||||
CryptoService,
|
||||
RequestContextService,
|
||||
UuidResolverService,
|
||||
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
@@ -26,6 +28,6 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [CryptoService, RequestContextService],
|
||||
exports: [CryptoService, RequestContextService, UuidResolverService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface CircuitBreakerOptions {
|
||||
timeout?: number;
|
||||
errorThresholdPercentage?: number;
|
||||
resetTimeout?: number;
|
||||
fallback?: (...args: any[]) => any;
|
||||
fallback?: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
const logger = new Logger('CircuitBreakerDecorator');
|
||||
@@ -31,7 +31,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
|
||||
|
||||
breaker.on('open', () => logger.warn(`Circuit OPEN for ${propertyKey}`));
|
||||
breaker.on('halfOpen', () =>
|
||||
logger.log(`Circuit HALF-OPEN for ${propertyKey}`),
|
||||
logger.log(`Circuit HALF-OPEN for ${propertyKey}`)
|
||||
);
|
||||
breaker.on('close', () => logger.log(`Circuit CLOSED for ${propertyKey}`));
|
||||
|
||||
@@ -39,7 +39,7 @@ export function UseCircuitBreaker(options: CircuitBreakerOptions = {}) {
|
||||
breaker.fallback(options.fallback);
|
||||
}
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
descriptor.value = async function (...args: unknown[]) {
|
||||
// ✅ ใช้ .fire โดยส่ง this context ให้ถูกต้อง
|
||||
return breaker.fire.apply(breaker, [this, ...args]);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface RetryOptions {
|
||||
factor?: number;
|
||||
minTimeout?: number;
|
||||
maxTimeout?: number;
|
||||
onRetry?: (e: Error, attempt: number) => any;
|
||||
onRetry?: (e: Error, attempt: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,12 +18,12 @@ export function Retry(options: RetryOptions = {}) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
const logger = new Logger('RetryDecorator');
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
descriptor.value = async function (...args: unknown[]) {
|
||||
return retry(
|
||||
// ✅ ระบุ Type ให้กับ bail และ attempt เพื่อแก้ Implicit any
|
||||
async (bail: (e: Error) => void, attempt: number) => {
|
||||
@@ -38,7 +38,7 @@ export function Retry(options: RetryOptions = {}) {
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}`, // ✅ ใช้ err.message
|
||||
`Attempt ${attempt} failed for ${propertyKey}. Error: ${err.message}` // ✅ ใช้ err.message
|
||||
);
|
||||
|
||||
// ถ้าต้องการให้หยุด Retry ทันทีในบางเงื่อนไข สามารถเรียก bail(err) ได้ที่นี่
|
||||
@@ -51,7 +51,7 @@ export function Retry(options: RetryOptions = {}) {
|
||||
minTimeout: options.minTimeout || 1000,
|
||||
maxTimeout: options.maxTimeout || 5000,
|
||||
...options,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class AuditLog {
|
||||
entityId?: string;
|
||||
|
||||
@Column({ name: 'details_json', type: 'json', nullable: true })
|
||||
detailsJson?: any;
|
||||
detailsJson?: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress?: string;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Mock uuid module to avoid ESM import issue with uuid@13
|
||||
jest.mock('uuid', () => ({
|
||||
validate: (str: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
str
|
||||
),
|
||||
v7: () => '01912345-6789-7abc-8def-0123456789ab',
|
||||
}));
|
||||
|
||||
import { UuidBaseEntity } from './uuid-base.entity';
|
||||
|
||||
// Concrete subclass for testing the abstract base
|
||||
class TestEntity extends UuidBaseEntity {
|
||||
id!: number;
|
||||
}
|
||||
|
||||
describe('UuidBaseEntity', () => {
|
||||
// ==========================================================
|
||||
// generateUuid() — @BeforeInsert hook
|
||||
// ==========================================================
|
||||
|
||||
describe('generateUuid()', () => {
|
||||
it('should generate a UUIDv7 when uuid is not set', () => {
|
||||
const entity = new TestEntity();
|
||||
expect(entity.uuid).toBeUndefined();
|
||||
|
||||
entity.generateUuid();
|
||||
|
||||
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab');
|
||||
});
|
||||
|
||||
it('should not overwrite an existing uuid', () => {
|
||||
const entity = new TestEntity();
|
||||
entity.uuid = 'existing-uuid-value-should-be-kept';
|
||||
|
||||
entity.generateUuid();
|
||||
|
||||
expect(entity.uuid).toBe('existing-uuid-value-should-be-kept');
|
||||
});
|
||||
|
||||
it('should not overwrite a pre-set UUIDv1 from DB default', () => {
|
||||
const entity = new TestEntity();
|
||||
entity.uuid = '550e8400-e29b-11d4-a716-446655440000';
|
||||
|
||||
entity.generateUuid();
|
||||
|
||||
expect(entity.uuid).toBe('550e8400-e29b-11d4-a716-446655440000');
|
||||
});
|
||||
|
||||
it('should generate uuid when uuid is empty string', () => {
|
||||
const entity = new TestEntity();
|
||||
entity.uuid = '';
|
||||
|
||||
entity.generateUuid();
|
||||
|
||||
// Empty string is falsy, so generateUuid should assign a new value
|
||||
expect(entity.uuid).toBe('01912345-6789-7abc-8def-0123456789ab');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
// Inheritance
|
||||
// ==========================================================
|
||||
|
||||
describe('inheritance', () => {
|
||||
it('should be an instance of UuidBaseEntity', () => {
|
||||
const entity = new TestEntity();
|
||||
expect(entity).toBeInstanceOf(UuidBaseEntity);
|
||||
});
|
||||
|
||||
it('should have uuid property accessible from subclass', () => {
|
||||
const entity = new TestEntity();
|
||||
entity.uuid = 'test-uuid';
|
||||
entity.id = 42;
|
||||
|
||||
expect(entity.uuid).toBe('test-uuid');
|
||||
expect(entity.id).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileStorageController } from './file-storage.controller';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
import { RequestWithUser } from '../interfaces/request-with-user.interface';
|
||||
|
||||
describe('FileStorageController', () => {
|
||||
let controller: FileStorageController;
|
||||
@@ -44,8 +45,10 @@ describe('FileStorageController', () => {
|
||||
mockResult
|
||||
);
|
||||
|
||||
const mockReq = { user: { userId: 1, username: 'testuser' } };
|
||||
const result = await controller.uploadFile(mockFile, mockReq as any);
|
||||
const mockReq = {
|
||||
user: { user_id: 1, username: 'testuser' },
|
||||
} as unknown as RequestWithUser;
|
||||
const result = await controller.uploadFile(mockFile, mockReq);
|
||||
|
||||
expect(mockFileStorageService.upload).toHaveBeenCalledWith(mockFile, 1);
|
||||
});
|
||||
|
||||
@@ -145,8 +145,8 @@ export class FileStorageService {
|
||||
// อัปเดตข้อมูลใน DB
|
||||
att.filePath = newPath;
|
||||
att.isTemporary = false;
|
||||
att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable)
|
||||
att.expiresAt = null as any; // เคลียร์วันหมดอายุ
|
||||
att.tempId = undefined; // เคลียร์ tempId
|
||||
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
|
||||
att.referenceDate = effectiveDate; // Save reference date
|
||||
|
||||
committedAttachments.push(await this.attachmentRepository.save(att));
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ParseUuidPipe } from './parse-uuid.pipe';
|
||||
|
||||
// Mock uuid module to avoid ESM import issue with uuid@13
|
||||
jest.mock('uuid', () => ({
|
||||
validate: (str: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
str
|
||||
),
|
||||
v7: () => '01912345-6789-7abc-8def-0123456789ab',
|
||||
}));
|
||||
|
||||
describe('ParseUuidPipe', () => {
|
||||
let pipe: ParseUuidPipe;
|
||||
|
||||
beforeEach(() => {
|
||||
pipe = new ParseUuidPipe();
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
// Valid UUIDs
|
||||
// ==========================================================
|
||||
|
||||
describe('valid UUIDs', () => {
|
||||
it('should accept a valid UUIDv4 and return lowercase', () => {
|
||||
const uuid = 'a1b2c3d4-e5f6-4789-abcd-ef0123456789';
|
||||
expect(pipe.transform(uuid)).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should accept a valid UUIDv7 and return lowercase', () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
expect(pipe.transform(uuid)).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should accept a valid UUIDv1 (MariaDB DEFAULT)', () => {
|
||||
const uuid = '550e8400-e29b-11d4-a716-446655440000';
|
||||
expect(pipe.transform(uuid)).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should normalize uppercase UUID to lowercase', () => {
|
||||
const uuid = 'A1B2C3D4-E5F6-4789-ABCD-EF0123456789';
|
||||
expect(pipe.transform(uuid)).toBe(uuid.toLowerCase());
|
||||
});
|
||||
|
||||
it('should normalize mixed case UUID to lowercase', () => {
|
||||
const uuid = 'a1B2c3D4-e5F6-4789-AbCd-eF0123456789';
|
||||
expect(pipe.transform(uuid)).toBe(uuid.toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
// Invalid inputs
|
||||
// ==========================================================
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should throw BadRequestException for empty string', () => {
|
||||
expect(() => pipe.transform('')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for random string', () => {
|
||||
expect(() => pipe.transform('not-a-uuid')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for numeric string', () => {
|
||||
expect(() => pipe.transform('12345')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for UUID without hyphens', () => {
|
||||
expect(() => pipe.transform('a1b2c3d4e5f64789abcdef0123456789')).toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for UUID with extra characters', () => {
|
||||
expect(() =>
|
||||
pipe.transform('a1b2c3d4-e5f6-4789-abcd-ef0123456789-extra')
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should include the invalid value in error message', () => {
|
||||
try {
|
||||
pipe.transform('bad-value');
|
||||
fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect((error as BadRequestException).message).toContain('bad-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,9 +30,10 @@ export class CryptoService {
|
||||
let encrypted = cipher.update(stringValue, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return `${iv.toString('hex')}:${encrypted}`;
|
||||
} catch (error: any) {
|
||||
// Fix TS18046: Cast error to any or Error to access .message
|
||||
this.logger.error(`Encryption failed: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Encryption failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -49,10 +50,9 @@ export class CryptoService {
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (error: any) {
|
||||
// Fix TS18046: Cast error to any or Error to access .message
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Decryption failed for value. Returning original text. Error: ${error.message}`,
|
||||
`Decryption failed for value. Returning original text. Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
// กรณี Decrypt ไม่ได้ ให้คืนค่าเดิมเพื่อป้องกัน App Crash
|
||||
return text;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class RequestContextService {
|
||||
this.cls.run(new Map(), fn);
|
||||
}
|
||||
|
||||
static set(key: string, value: any) {
|
||||
static set(key: string, value: unknown) {
|
||||
const store = this.cls.getStore();
|
||||
if (store) {
|
||||
store.set(key, value);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UuidResolverService } from './uuid-resolver.service';
|
||||
|
||||
// Mock uuid module to avoid ESM import issue with uuid@13
|
||||
jest.mock('uuid', () => ({
|
||||
validate: (str: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
str
|
||||
),
|
||||
v7: () => '01912345-6789-7abc-8def-0123456789ab',
|
||||
}));
|
||||
|
||||
describe('UuidResolverService', () => {
|
||||
let service: UuidResolverService;
|
||||
let mockQuery: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockQuery = jest.fn();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UuidResolverService,
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: {
|
||||
manager: { query: mockQuery },
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UuidResolverService>(UuidResolverService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// ==========================================================
|
||||
// resolve() — Core generic resolver
|
||||
// ==========================================================
|
||||
|
||||
describe('resolve()', () => {
|
||||
it('should return number directly when value is a number', async () => {
|
||||
const result = await service.resolve('Project', 'projects', 'id', 42);
|
||||
expect(result).toBe(42);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse numeric string and return number', async () => {
|
||||
const result = await service.resolve('Project', 'projects', 'id', '99');
|
||||
expect(result).toBe(99);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should look up UUID string and return PK from DB', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ id: 7 }]);
|
||||
|
||||
const result = await service.resolve('Project', 'projects', 'id', uuid);
|
||||
expect(result).toBe(7);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `id` FROM `projects` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for invalid UUID string', async () => {
|
||||
await expect(
|
||||
service.resolve('Project', 'projects', 'id', 'not-a-uuid')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when UUID not found in DB', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.resolve('Project', 'projects', 'id', uuid)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
// Named convenience resolvers
|
||||
// ==========================================================
|
||||
|
||||
describe('resolveProjectId()', () => {
|
||||
it('should delegate to resolve with projects table', async () => {
|
||||
const result = await service.resolveProjectId(5);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should look up UUID for project', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ id: 5 }]);
|
||||
|
||||
const result = await service.resolveProjectId(uuid);
|
||||
expect(result).toBe(5);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `id` FROM `projects` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrganizationId()', () => {
|
||||
it('should delegate to resolve with organizations table', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ id: 3 }]);
|
||||
|
||||
const result = await service.resolveOrganizationId(uuid);
|
||||
expect(result).toBe(3);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `id` FROM `organizations` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCorrespondenceId()', () => {
|
||||
it('should delegate to resolve with correspondences table', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ id: 10 }]);
|
||||
|
||||
const result = await service.resolveCorrespondenceId(uuid);
|
||||
expect(result).toBe(10);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `id` FROM `correspondences` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUserId()', () => {
|
||||
it('should use user_id as PK column', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ user_id: 8 }]);
|
||||
|
||||
const result = await service.resolveUserId(uuid);
|
||||
expect(result).toBe(8);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `user_id` FROM `users` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContractId()', () => {
|
||||
it('should delegate to resolve with contracts table', async () => {
|
||||
const uuid = '01912345-6789-7abc-8def-0123456789ab';
|
||||
mockQuery.mockResolvedValue([{ id: 2 }]);
|
||||
|
||||
const result = await service.resolveContractId(uuid);
|
||||
expect(result).toBe(2);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
'SELECT `id` FROM `contracts` WHERE `uuid` = ? LIMIT 1',
|
||||
[uuid]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================
|
||||
// Edge cases
|
||||
// ==========================================================
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero as a valid number', async () => {
|
||||
const result = await service.resolve('Project', 'projects', 'id', 0);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle string "0" as numeric', async () => {
|
||||
const result = await service.resolve('Project', 'projects', 'id', '0');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle negative number', async () => {
|
||||
const result = await service.resolve('Project', 'projects', 'id', -1);
|
||||
expect(result).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
/**
|
||||
* Shared service to resolve hybrid identifiers (INT | UUID string) to internal INT IDs.
|
||||
* Eliminates duplicated resolveId helpers across 8+ services.
|
||||
*
|
||||
* @see ADR-019 Hybrid Identifier Strategy
|
||||
*/
|
||||
@Injectable()
|
||||
export class UuidResolverService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Checks if a string value is a numeric string (not UUID).
|
||||
* Returns the parsed number or null if not numeric.
|
||||
*/
|
||||
private tryParseInt(value: string): number | null {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level UUID lookup: find entity by uuid column, return pkColumn value.
|
||||
*/
|
||||
private async lookupByUuid(
|
||||
entityName: string,
|
||||
tableName: string,
|
||||
pkColumn: string,
|
||||
uuid: string
|
||||
): Promise<number> {
|
||||
if (!uuidValidate(uuid)) {
|
||||
throw new NotFoundException(
|
||||
`Invalid identifier for ${entityName}: ${uuid}`
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Record<string, number>[] = await this.dataSource.manager.query(
|
||||
`SELECT \`${pkColumn}\` FROM \`${tableName}\` WHERE \`uuid\` = ? LIMIT 1`,
|
||||
[uuid]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
throw new NotFoundException(`${entityName} with UUID ${uuid} not found`);
|
||||
}
|
||||
|
||||
return rows[0][pkColumn];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic resolver: accepts INT or UUID string, returns internal INT ID.
|
||||
* - If value is a number, returns it directly.
|
||||
* - If value is a numeric string, parses and returns it.
|
||||
* - If value is a UUID string, looks up the entity by uuid column.
|
||||
*/
|
||||
async resolve(
|
||||
entityName: string,
|
||||
tableName: string,
|
||||
pkColumn: string,
|
||||
value: number | string
|
||||
): Promise<number> {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
const num = this.tryParseInt(value);
|
||||
if (num !== null) return num;
|
||||
|
||||
return this.lookupByUuid(entityName, tableName, pkColumn, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve projectId (INT or UUID string) to internal INT ID.
|
||||
*/
|
||||
async resolveProjectId(projectId: number | string): Promise<number> {
|
||||
return this.resolve('Project', 'projects', 'id', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve organizationId (INT or UUID string) to internal INT ID.
|
||||
*/
|
||||
async resolveOrganizationId(orgId: number | string): Promise<number> {
|
||||
return this.resolve('Organization', 'organizations', 'id', orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve correspondenceId (INT or UUID string) to internal INT ID.
|
||||
*/
|
||||
async resolveCorrespondenceId(corrId: number | string): Promise<number> {
|
||||
return this.resolve('Correspondence', 'correspondences', 'id', corrId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve userId (INT or UUID string) to internal user_id.
|
||||
*/
|
||||
async resolveUserId(userId: number | string): Promise<number> {
|
||||
return this.resolve('User', 'users', 'user_id', userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve contractId (INT or UUID string) to internal INT ID.
|
||||
*/
|
||||
async resolveContractId(contractId: number | string): Promise<number> {
|
||||
return this.resolve('Contract', 'contracts', 'id', contractId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user