49 changed files with 6724 additions and 511 deletions
@ -0,0 +1,353 @@
|
||||
# NestJS Implementation Plan for AutoStore |
||||
|
||||
## Overview |
||||
Implementation of AutoStore system using NestJS with TypeScript, following Clean Architecture principles. The system stores items with expiration dates and automatically orders new items when they expire. |
||||
|
||||
## Architecture Approach |
||||
- **Clean Architecture** with clear separation of concerns |
||||
- **Domain-Driven Design** with rich domain models |
||||
- **Hexagonal Architecture** with dependency inversion |
||||
- **Repository Pattern** for data persistence |
||||
- **CQRS-like** command/query separation |
||||
- **Dependency Injection** leveraging NestJS IoC container |
||||
|
||||
## Core Domain Logic |
||||
|
||||
### ItemExpirationSpec - Single Source of Truth for Expiration |
||||
|
||||
**File**: `src/domain/specifications/item-expiration.spec.ts` |
||||
**Purpose**: Centralized expiration checking logic - the single source of truth for determining if items are expired |
||||
|
||||
**Key Methods**: |
||||
- `isExpired(item: ItemEntity, currentTime: Date): boolean` - Checks if item expired |
||||
- `getSpec(currentTime: Date): Specification<ItemEntity>` - Returns specification for repository queries |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` to check newly created items for immediate expiration |
||||
- Called by `HandleExpiredItemsCommand.execute()` to find expired items for processing |
||||
- Used by `ItemRepository.findWhere()` to query database for expired items |
||||
|
||||
## Detailed Implementation Plan |
||||
|
||||
### Domain Layer |
||||
|
||||
#### 1. Entities |
||||
|
||||
**File**: `src/domain/entities/item.entity.ts` |
||||
**Purpose**: Core business entity representing an item |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: ItemId, name: string, expirationDate: ExpirationDate, orderUrl: string, userId: UserId): void` - Creates item with validation |
||||
- Getters for all properties |
||||
|
||||
**Place in the flow**: |
||||
- Created by `AddItemCommand.execute()` |
||||
- Retrieved by `ItemRepository` methods |
||||
- Passed to `ItemExpirationSpec.isExpired()` for expiration checking |
||||
|
||||
**File**: `src/domain/entities/user.entity.ts` |
||||
**Purpose**: User entity for item ownership and authentication purposes |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: UserId, username: string, passwordHash: string): void` - Creates user with validation |
||||
- Getters for all properties |
||||
|
||||
#### 2. Value Objects |
||||
|
||||
**File**: `src/domain/value-objects/item-id.vo.ts` |
||||
**Purpose**: Strong typing for item identifiers |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: string): void` - Validates UUID format |
||||
- `getValue(): string` - Returns string value |
||||
- `equals(other: ItemId): boolean` - Compares with another ItemId |
||||
|
||||
**File**: `src/domain/value-objects/expiration-date.vo.ts` |
||||
**Purpose**: Immutable expiration date with validation |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: Date): void` - Validates date is in future |
||||
- `getValue(): Date` - Returns Date object |
||||
- `format(): string` - Returns ISO string format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemEntity` constructor for type-safe date handling |
||||
- Validated by `ItemExpirationSpec.isExpired()` for expiration logic |
||||
|
||||
#### 3. Specifications |
||||
|
||||
**File**: `src/domain/specifications/specification.interface.ts` |
||||
**Purpose**: Generic specification pattern interface |
||||
|
||||
**Key Methods**: |
||||
- `isSatisfiedBy(candidate: T): boolean` - Evaluates specification |
||||
- `getSpec(): object` - Returns specification object for repository implementation |
||||
|
||||
**Place in the flow**: |
||||
- Implemented by `ItemExpirationSpec` for type-safe specifications |
||||
- Used by `ItemRepository.findWhere()` for database queries |
||||
|
||||
### Application Layer |
||||
|
||||
#### 4. Commands |
||||
|
||||
**File**: `src/application/commands/add-item.command.ts` |
||||
**Purpose**: Use case for creating new items with expiration handling |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(name: string, expirationDate: string, orderUrl: string, userId: string): Promise<string | null>` - Creates item, handles expired items immediately |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.createItem()` calls `AddItemCommand.execute()` |
||||
2. Creates `ItemEntity` with validated data |
||||
3. Calls `ItemExpirationSpec.isExpired()` to check if item is expired |
||||
4. If expired: |
||||
- calls `OrderHttpService.orderItem()` |
||||
- **DO NOT save to repository** |
||||
- returns null |
||||
5. If not expired: calls `ItemRepository.save()` and returns item ID |
||||
|
||||
**File**: `src/application/commands/handle-expired-items.command.ts` |
||||
**Purpose**: Background command to process expired items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(): Promise<void>` - Finds and processes all expired items |
||||
|
||||
**Flow**: |
||||
1. `ExpiredItemsScheduler.handleCron()` calls `HandleExpiredItemsCommand.execute()` |
||||
2. Gets current time from `ITimeProvider` |
||||
3. Calls `ItemExpirationSpec.getSpec()` to get expiration specification |
||||
4. Calls `ItemRepository.findWhere()` to find expired items |
||||
5. For each expired item: calls `OrderHttpService.orderItem()` then `ItemRepository.delete()` |
||||
|
||||
**File**: `src/application/commands/delete-item.command.ts` |
||||
**Purpose**: Use case for deleting user items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<void>` - Validates ownership and deletes item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.deleteItem()` calls `DeleteItemCommand.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Calls `ItemRepository.delete()` to remove item |
||||
|
||||
**File**: `src/application/commands/login-user.command.ts` |
||||
**Purpose**: User authentication use case |
||||
|
||||
**Key Methods**: |
||||
- `constructor(authService: IAuthService, logger: Logger): void` - Dependency injection |
||||
- `execute(username: string, password: string): Promise<string>` - Authenticates and returns JWT token |
||||
|
||||
#### 5. Queries |
||||
|
||||
**File**: `src/application/queries/get-item.query.ts` |
||||
**Purpose**: Retrieves single item by ID with authorization |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<ItemEntity>` - Validates ownership and returns item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.getItem()` calls `GetItemQuery.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Returns item entity |
||||
|
||||
**File**: `src/application/queries/list-items.query.ts` |
||||
**Purpose**: Retrieves all items for authenticated user |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(userId: string): Promise<ItemEntity[]>` - Returns user's items |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.listItems()` calls `ListItemsQuery.execute()` |
||||
2. Calls `ItemRepository.findByUserId()` to retrieve user's items |
||||
3. Returns array of item entities |
||||
|
||||
#### 6. DTOs |
||||
|
||||
**File**: `src/application/dto/create-item.dto.ts` |
||||
**Purpose**: Request validation for item creation |
||||
|
||||
**Key Properties**: |
||||
- `name: string` - Item name (min: 1, max: 255) |
||||
- `expirationDate: string` - ISO date string (future date validation) |
||||
- `orderUrl: string` - Valid URL format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemsController.createItem()` for request body validation |
||||
|
||||
**File**: `src/application/dto/item-response.dto.ts` |
||||
**Purpose**: Standardized item response format |
||||
|
||||
**Key Properties**: |
||||
- `id: string` - Item ID |
||||
- `name: string` - Item name |
||||
- `expirationDate: string` - ISO date string |
||||
- `orderUrl: string` - Order URL |
||||
- `userId: string` - Owner user ID |
||||
- `createdAt: string` - Creation timestamp |
||||
|
||||
**Place in the flow**: |
||||
- Used by all item controller methods for response transformation |
||||
|
||||
### Infrastructure Layer |
||||
|
||||
#### 7. Repositories |
||||
|
||||
**File**: `src/infrastructure/persistence/repositories/item-repository.impl.ts` |
||||
**Purpose**: TypeORM implementation of item repository |
||||
|
||||
**Key Methods**: |
||||
- `save(item: ItemEntity): Promise<void>` - Persists item entity |
||||
- `findById(id: ItemId): Promise<ItemEntity | null>` - Finds by ID |
||||
- `findByUserId(userId: UserId): Promise<ItemEntity[]>` - Finds by user |
||||
- `findWhere(spec: Specification<ItemEntity>): Promise<ItemEntity[]>` - Finds by specification using `ItemExpirationSpec` |
||||
- `delete(id: ItemId): Promise<void>` - Deletes item |
||||
- `exists(id: ItemId): Promise<boolean>` - Checks existence |
||||
|
||||
**Place in the flow**: |
||||
- Called by all commands and queries for data persistence and retrieval |
||||
- Uses `ItemExpirationSpec` for finding expired items |
||||
|
||||
#### 8. HTTP Services |
||||
|
||||
**File**: `src/infrastructure/http/order-http.service.ts` |
||||
**Purpose**: HTTP implementation of order service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(httpService: HttpService, logger: Logger): void` - Dependency injection |
||||
- `orderItem(item: ItemEntity): Promise<void>` - Sends POST request to order URL |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` for expired items |
||||
- Called by `HandleExpiredItemsCommand.execute()` for batch processing |
||||
|
||||
#### 9. Authentication |
||||
|
||||
**File**: `src/infrastructure/auth/jwt-auth.service.ts` |
||||
**Purpose**: JWT implementation of authentication service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(userRepo: IUserRepository, jwtService: JwtService, configService: ConfigService, logger: Logger): void` - Dependency injection |
||||
- `authenticate(username: string, password: string): Promise<string | null>` - Validates credentials and generates JWT |
||||
- `validateToken(token: string): Promise<boolean>` - Validates JWT token |
||||
- `getUserIdFromToken(token: string): Promise<string | null>` - Extracts user ID from token |
||||
|
||||
**Place in the flow**: |
||||
- Called by `LoginUserCommand.execute()` for user authentication |
||||
- Used by `JwtAuthGuard` for route protection |
||||
|
||||
### Presentation Layer |
||||
|
||||
#### 10. Controllers |
||||
|
||||
**File**: `src/presentation/controllers/items.controller.ts` |
||||
**Purpose**: REST API endpoints for item management |
||||
|
||||
**Key Methods**: |
||||
- `constructor(addItemCmd: AddItemCommand, getItemQry: GetItemQuery, listItemsQry: ListItemsQuery, deleteItemCmd: DeleteItemCommand): void` - Dependency injection |
||||
- `createItem(@Body() dto: CreateItemDto, @Req() req: Request): Promise<ItemResponseDto>` - POST /items |
||||
- `getItem(@Param('id') id: string, @Req() req: Request): Promise<ItemResponseDto>` - GET /items/:id |
||||
- `listItems(@Req() req: Request): Promise<ItemResponseDto[]>` - GET /items |
||||
- `deleteItem(@Param('id') id: string, @Req() req: Request): Promise<void>` - DELETE /items/:id |
||||
|
||||
**Flow**: |
||||
- Receives HTTP requests and validates input |
||||
- Calls appropriate commands/queries based on HTTP method |
||||
- Returns standardized responses with DTOs |
||||
|
||||
**File**: `src/presentation/controllers/auth.controller.ts` |
||||
**Purpose**: Authentication endpoints |
||||
|
||||
**Key Methods**: |
||||
- `constructor(loginUserCmd: LoginUserCommand): void` - Dependency injection |
||||
- `login(@Body() dto: LoginDto): Promise<{ token: string }>` - POST /login |
||||
|
||||
#### 11. Guards |
||||
|
||||
**File**: `src/presentation/guards/jwt-auth.guard.ts` |
||||
**Purpose**: JWT authentication route protection |
||||
|
||||
**Key Methods**: |
||||
- `constructor(jwtAuthService: IJwtAuthService, logger: Logger): void` - Dependency injection |
||||
- `canActivate(context: ExecutionContext): Promise<boolean>` - Validates JWT and attaches user to request |
||||
|
||||
**Place in the flow**: |
||||
- Applied to all protected routes by NestJS Guard System |
||||
- Uses `JwtAuthService` for token validation |
||||
|
||||
## Background Processing |
||||
|
||||
**File**: `src/infrastructure/scheduler/expired-items.scheduler.ts` |
||||
**Purpose**: Scheduled job for processing expired items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(handleExpiredItemsCmd: HandleExpiredItemsCommand, logger: Logger): void` - Dependency injection |
||||
- `handleCron(): Promise<void>` - Runs every minute to check for expired items |
||||
|
||||
**Flow**: |
||||
1. Upon application startup: Immediately invoke `HandleExpiredItemsCommand.execute()` |
||||
2. Start cron scheduler (every minute) |
||||
3. NestJS Scheduler triggers `handleCron()` every minute |
||||
4. Calls `HandleExpiredItemsCommand.execute()` to process expired items |
||||
5. Logs processing results and errors |
||||
|
||||
## Complete Flow Summary |
||||
|
||||
### Item Creation Flow |
||||
``` |
||||
POST /items |
||||
├── JwtAuthGuard (authentication) |
||||
├── CreateItemDto validation |
||||
├── ItemsController.createItem() |
||||
│ ├── AddItemCommand.execute() |
||||
│ │ ├── ItemEntity constructor (validation) |
||||
│ │ ├── ItemExpirationSpec.isExpired() ← SINGLE SOURCE OF TRUTH |
||||
│ │ ├── If expired: OrderHttpService.orderItem() |
||||
│ │ └── If not expired: ItemRepository.save() |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
### Expired Items Processing Flow |
||||
``` |
||||
Cron Job (every minute) |
||||
└── ExpiredItemsScheduler.handleCron() |
||||
└── HandleExpiredItemsCommand.execute() |
||||
├── ITimeProvider.now() |
||||
├── ItemExpirationSpec.getSpec() ← SINGLE SOURCE OF TRUTH |
||||
├── ItemRepository.findWhere() (using spec) |
||||
├── For each expired item: |
||||
│ ├── OrderHttpService.orderItem() |
||||
│ └── ItemRepository.delete() |
||||
└── Logging |
||||
``` |
||||
|
||||
### Item Retrieval Flow |
||||
``` |
||||
GET /items/:id |
||||
├── JwtAuthGuard (authentication) |
||||
├── ItemsController.getItem() |
||||
│ ├── GetItemQuery.execute() |
||||
│ │ ├── ItemRepository.findById() |
||||
│ │ ├── Ownership validation |
||||
│ │ └── Return ItemEntity |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
## Key Design Principles |
||||
|
||||
1. **Single Source of Truth**: `ItemExpirationSpec` is the only component that determines expiration logic |
||||
2. **Clear Flow**: Each component has a well-defined place in the execution chain |
||||
3. **Dependency Inversion**: High-level modules don't depend on low-level modules |
||||
4. **Separation of Concerns**: Each layer has distinct responsibilities |
||||
5. **Testability**: All components can be tested in isolation |
||||
|
||||
This implementation plan ensures consistent development regardless of the implementer, providing clear flow definitions and emphasizing `ItemExpirationSpec` as the centralized source for expiration logic. |
||||
File diff suppressed because it is too large
Load Diff
@ -1,12 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common'; |
||||
import { AppService } from './app.service'; |
||||
|
||||
@Controller() |
||||
export class AppController { |
||||
constructor(private readonly appService: AppService) {} |
||||
|
||||
@Get() |
||||
getHello(): string { |
||||
return this.appService.getHello(); |
||||
} |
||||
} |
||||
@ -1,10 +1,93 @@
|
||||
import { Module } from '@nestjs/common'; |
||||
import { AppController } from './app.controller'; |
||||
import { APP_PIPE } from '@nestjs/core'; |
||||
import { ValidationPipe } from '@nestjs/common'; |
||||
import { ConfigModule, ConfigService } from '@nestjs/config'; |
||||
import { JwtModule } from '@nestjs/jwt'; |
||||
import { HttpModule } from '@nestjs/axios'; |
||||
import { AppService } from './app.service'; |
||||
import { AuthController } from './presentation/controllers/auth.controller'; |
||||
import { ItemsController } from './presentation/controllers/items.controller'; |
||||
import { LoginUserCommand } from './application/commands/login-user.command'; |
||||
import { AddItemCommand } from './application/commands/add-item.command'; |
||||
import { DeleteItemCommand } from './application/commands/delete-item.command'; |
||||
import { HandleExpiredItemsCommand } from './application/commands/handle-expired-items.command'; |
||||
import { GetItemQuery } from './application/queries/get-item.query'; |
||||
import { ListItemsQuery } from './application/queries/list-items.query'; |
||||
import { JwtAuthService } from './infrastructure/auth/jwt-auth.service'; |
||||
import { FileUserRepository } from './infrastructure/repositories/file-user-repository'; |
||||
import { FileItemRepository } from './infrastructure/repositories/file-item-repository'; |
||||
import { OrderHttpService } from './infrastructure/http/order-http.service'; |
||||
import { SystemTimeProvider } from './infrastructure/services/system-time.provider'; |
||||
import { UserInitializationService } from './infrastructure/services/user-initialization.service'; |
||||
import { ItemExpirationSpec } from './domain/specifications/item-expiration.spec'; |
||||
import { IAuthService } from './application/interfaces/auth-service.interface'; |
||||
import { IUserRepository } from './application/interfaces/user-repository.interface'; |
||||
import { IItemRepository } from './application/interfaces/item-repository.interface'; |
||||
import { IOrderService } from './application/interfaces/order-service.interface'; |
||||
import { ITimeProvider } from './application/interfaces/time-provider.interface'; |
||||
|
||||
@Module({ |
||||
imports: [], |
||||
controllers: [AppController], |
||||
providers: [AppService], |
||||
imports: [ |
||||
ConfigModule.forRoot({ |
||||
isGlobal: true, |
||||
}), |
||||
HttpModule, |
||||
JwtModule.registerAsync({ |
||||
imports: [ConfigModule], |
||||
useFactory: async (configService: ConfigService) => ({ |
||||
secret: configService.get<string>('JWT_SECRET') || 'default-secret-key', |
||||
signOptions: { |
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '1h', |
||||
}, |
||||
}), |
||||
inject: [ConfigService], |
||||
}), |
||||
], |
||||
controllers: [AuthController, ItemsController], |
||||
providers: [ |
||||
AppService, |
||||
LoginUserCommand, |
||||
AddItemCommand, |
||||
DeleteItemCommand, |
||||
HandleExpiredItemsCommand, |
||||
GetItemQuery, |
||||
ListItemsQuery, |
||||
JwtAuthService, |
||||
OrderHttpService, |
||||
SystemTimeProvider, |
||||
ItemExpirationSpec, |
||||
UserInitializationService, |
||||
{ |
||||
provide: 'IAuthService', |
||||
useExisting: JwtAuthService, |
||||
}, |
||||
{ |
||||
provide: 'IUserRepository', |
||||
useFactory: () => new FileUserRepository('./data'), |
||||
}, |
||||
{ |
||||
provide: 'IItemRepository', |
||||
useFactory: () => new FileItemRepository('./data'), |
||||
}, |
||||
{ |
||||
provide: 'IOrderService', |
||||
useExisting: OrderHttpService, |
||||
}, |
||||
{ |
||||
provide: 'ITimeProvider', |
||||
useExisting: SystemTimeProvider, |
||||
}, |
||||
{ |
||||
provide: APP_PIPE, |
||||
useValue: new ValidationPipe({ |
||||
whitelist: true, |
||||
forbidNonWhitelisted: true, |
||||
transform: true, |
||||
transformOptions: { |
||||
enableImplicitConversion: true, |
||||
}, |
||||
}), |
||||
}, |
||||
], |
||||
}) |
||||
export class AppModule {} |
||||
@ -0,0 +1,167 @@
|
||||
import { AddItemCommand } from '../add-item.command'; |
||||
import { ItemEntity } from '../../../domain/entities/item.entity'; |
||||
|
||||
// Mock implementations
|
||||
const mockItemRepository = { |
||||
save: jest.fn(), |
||||
}; |
||||
|
||||
const mockOrderService = { |
||||
orderItem: jest.fn(), |
||||
}; |
||||
|
||||
const mockTimeProvider = { |
||||
now: jest.fn(), |
||||
}; |
||||
|
||||
const mockExpirationSpec = { |
||||
isExpired: jest.fn(), |
||||
}; |
||||
|
||||
describe('AddItemCommand', () => { |
||||
let addItemCommand: AddItemCommand; |
||||
|
||||
const MOCKED_NOW = '2023-01-01T12:00:00Z'; |
||||
const NOT_EXPIRED_DATE = '2023-01-02T12:00:00Z'; |
||||
const EXPIRED_DATE = '2022-12-31T12:00:00Z'; |
||||
const ITEM_NAME = 'Test Item'; |
||||
const ORDER_URL = 'https://example.com/order'; |
||||
const USER_ID = '550e8400-e29b-41d4-a716-446655440001'; |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
|
||||
addItemCommand = new AddItemCommand( |
||||
mockItemRepository as any, |
||||
mockOrderService as any, |
||||
mockTimeProvider as any, |
||||
mockExpirationSpec as any, |
||||
); |
||||
|
||||
mockTimeProvider.now.mockReturnValue(new Date(MOCKED_NOW)); |
||||
}); |
||||
|
||||
describe('execute', () => { |
||||
describe('when item is not expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(false); |
||||
}); |
||||
|
||||
it('should save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return item ID', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
}); |
||||
|
||||
it('should validate expiration with ItemExpirationSpec', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledTimes(1); |
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledWith( |
||||
expect.any(ItemEntity), |
||||
new Date(MOCKED_NOW) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('when item is expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(true); |
||||
}); |
||||
|
||||
it('should call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return null', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeNull(); |
||||
}); |
||||
|
||||
it('should handle order service failure gracefully', async () => { |
||||
mockOrderService.orderItem.mockRejectedValue(new Error('Order service failed')); |
||||
|
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeNull(); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
describe('input validation', () => { |
||||
it('should throw error when name is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute('', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when name is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(' ', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, '', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, ' ', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, '', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ' ', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, '') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, ' ') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,108 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../../domain/value-objects/expiration-date.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
|
||||
@Injectable() |
||||
export class AddItemCommand { |
||||
private readonly logger = new Logger(AddItemCommand.name); |
||||
|
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): Promise<string | null> { |
||||
try { |
||||
this.logger.log(`Adding item: ${name} for user: ${userId}`); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(name, expirationDate, orderUrl, userId); |
||||
|
||||
// Parse expiration date and check if it's in the future
|
||||
const expirationDateObj = new Date(expirationDate); |
||||
const now = this.timeProvider.now(); |
||||
|
||||
// Business rule: Items with past expiration dates are allowed but trigger immediate ordering
|
||||
// Items with future expiration dates are saved normally
|
||||
|
||||
// Create domain entities
|
||||
const itemId = ItemId.generate(); |
||||
const userIdVo = UserId.create(userId); |
||||
const expirationDateVo = ExpirationDate.fromString(expirationDate); |
||||
|
||||
const item = new ItemEntity( |
||||
itemId, |
||||
name, |
||||
expirationDateVo, |
||||
orderUrl, |
||||
userIdVo, |
||||
); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
|
||||
// Check if item is expired
|
||||
if (this.expirationSpec.isExpired(item, currentTime)) { |
||||
this.logger.log(`Item ${name} is expired, ordering replacement`); |
||||
|
||||
try { |
||||
await this.orderService.orderItem(item); |
||||
this.logger.log(`Successfully ordered replacement for expired item: ${name}`); |
||||
// Return the item ID even for expired items to match API contract
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to place order for expired item ${itemId.getValue()}: ${error.message}`); |
||||
// Still return the ID even if ordering fails
|
||||
return itemId.getValue(); |
||||
} |
||||
} |
||||
|
||||
// Save item if not expired
|
||||
await this.itemRepository.save(item); |
||||
this.logger.log(`Successfully saved item: ${name} with ID: ${itemId.getValue()}`); |
||||
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to add item: ${error.message}`); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
private validateInput( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): void { |
||||
if (!name || name.trim().length === 0) { |
||||
throw new Error('Item name cannot be empty'); |
||||
} |
||||
|
||||
if (!expirationDate || expirationDate.trim().length === 0) { |
||||
throw new Error('Expiration date cannot be empty'); |
||||
} |
||||
|
||||
if (!orderUrl || orderUrl.trim().length === 0) { |
||||
throw new Error('Order URL cannot be empty'); |
||||
} |
||||
|
||||
if (!userId || userId.trim().length === 0) { |
||||
throw new Error('User ID cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger, NotFoundException, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
|
||||
@Injectable() |
||||
export class DeleteItemCommand { |
||||
private readonly logger = new Logger(DeleteItemCommand.name); |
||||
|
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
) {} |
||||
|
||||
async execute(itemId: string, userId: string): Promise<void> { |
||||
try { |
||||
this.logger.log(`Deleting item: ${itemId} for user: ${userId}`); |
||||
|
||||
const itemIdVo = ItemId.create(itemId); |
||||
const userIdVo = UserId.create(userId); |
||||
|
||||
const item = await this.itemRepository.findById(itemIdVo); |
||||
|
||||
if (!item) { |
||||
this.logger.warn(`Item not found: ${itemId}`); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
// Validate ownership
|
||||
if (!item.getUserId().equals(userIdVo)) { |
||||
this.logger.warn(`User ${userId} attempted to delete item ${itemId} owned by ${item.getUserId().getValue()}`); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
await this.itemRepository.delete(itemIdVo); |
||||
this.logger.log(`Successfully deleted item: ${itemId}`); |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException || error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to delete item ${itemId}: ${error.message}`); |
||||
throw new Error(`Failed to delete item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,53 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
|
||||
@Injectable() |
||||
export class HandleExpiredItemsCommand { |
||||
private readonly logger = new Logger(HandleExpiredItemsCommand.name); |
||||
|
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute(): Promise<void> { |
||||
try { |
||||
this.logger.log('Starting expired items processing'); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
const specification = this.expirationSpec.getSpec(currentTime); |
||||
|
||||
const expiredItems = await this.itemRepository.findWhere(specification); |
||||
|
||||
this.logger.log(`Found ${expiredItems.length} expired items to process`); |
||||
|
||||
for (const item of expiredItems) { |
||||
try { |
||||
this.logger.log(`Processing expired item: ${item.getId().getValue()}`); |
||||
|
||||
await this.orderService.orderItem(item); |
||||
await this.itemRepository.delete(item.getId()); |
||||
|
||||
this.logger.log(`Successfully processed and deleted expired item: ${item.getId().getValue()}`); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to process expired item ${item.getId().getValue()}: ${error.message}`); |
||||
// Continue processing other items even if one fails
|
||||
} |
||||
} |
||||
|
||||
this.logger.log('Completed expired items processing'); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to handle expired items: ${error.message}`); |
||||
throw new Error(`Failed to handle expired items: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@
|
||||
import { Injectable, Logger, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { IAuthService } from '../interfaces/auth-service.interface'; |
||||
|
||||
@Injectable() |
||||
export class LoginUserCommand { |
||||
private readonly logger = new Logger(LoginUserCommand.name); |
||||
|
||||
constructor( |
||||
@Inject('IAuthService') |
||||
private readonly authService: IAuthService, |
||||
) {} |
||||
|
||||
async execute(username: string, password: string): Promise<string> { |
||||
try { |
||||
this.logger.log(`Login attempt for user: ${username}`); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(username, password); |
||||
|
||||
const token = await this.authService.authenticate(username, password); |
||||
|
||||
if (!token) { |
||||
this.logger.warn(`Authentication failed for user: ${username}`); |
||||
throw new UnauthorizedException('Invalid username or password'); |
||||
} |
||||
|
||||
this.logger.log(`Successfully authenticated user: ${username}`); |
||||
return token; |
||||
} catch (error) { |
||||
if (error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to login user ${username}: ${error.message}`); |
||||
throw new Error(`Failed to login: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
private validateInput(username: string, password: string): void { |
||||
if (!username || username.trim().length === 0) { |
||||
throw new Error('Username cannot be empty'); |
||||
} |
||||
|
||||
if (!password || password.trim().length === 0) { |
||||
throw new Error('Password cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
|
||||
import { IsNotEmpty, IsString, IsUrl, IsDateString } from 'class-validator'; |
||||
|
||||
export class CreateItemDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
name: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsDateString() |
||||
expirationDate: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsUrl() |
||||
orderUrl: string; |
||||
} |
||||
|
||||
export class ItemResponseDto { |
||||
id: string; |
||||
name: string; |
||||
expirationDate: string; |
||||
orderUrl: string; |
||||
userId: string; |
||||
createdAt: string; |
||||
} |
||||
@ -0,0 +1,17 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'; |
||||
|
||||
export class LoginDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
username: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsString() |
||||
password: string; |
||||
} |
||||
|
||||
export class LoginResponseDto { |
||||
token: string; |
||||
tokenType: string; |
||||
expiresIn: number; |
||||
} |
||||
@ -0,0 +1,5 @@
|
||||
export interface IAuthService { |
||||
authenticate(username: string, password: string): Promise<string | null>; |
||||
validateToken(token: string): Promise<boolean>; |
||||
getUserIdFromToken(token: string): Promise<string | null>; |
||||
} |
||||
@ -0,0 +1,13 @@
|
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { ISpecification } from '../../domain/specifications/specification.interface'; |
||||
|
||||
export interface IItemRepository { |
||||
save(item: ItemEntity): Promise<void>; |
||||
findById(id: ItemId): Promise<ItemEntity | null>; |
||||
findByUserId(userId: UserId): Promise<ItemEntity[]>; |
||||
findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]>; |
||||
delete(id: ItemId): Promise<void>; |
||||
exists(id: ItemId): Promise<boolean>; |
||||
} |
||||
@ -0,0 +1,5 @@
|
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
|
||||
export interface IOrderService { |
||||
orderItem(item: ItemEntity): Promise<void>; |
||||
} |
||||
@ -0,0 +1,3 @@
|
||||
export interface ITimeProvider { |
||||
now(): Date; |
||||
} |
||||
@ -0,0 +1,10 @@
|
||||
import { UserEntity } from '../../domain/entities/user.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
|
||||
export interface IUserRepository { |
||||
save(user: UserEntity): Promise<void>; |
||||
findById(id: UserId): Promise<UserEntity | null>; |
||||
findByUsername(username: string): Promise<UserEntity | null>; |
||||
exists(id: UserId): Promise<boolean>; |
||||
existsByUsername(username: string): Promise<boolean>; |
||||
} |
||||
@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger, NotFoundException, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
|
||||
@Injectable() |
||||
export class GetItemQuery { |
||||
private readonly logger = new Logger(GetItemQuery.name); |
||||
|
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
) {} |
||||
|
||||
async execute(itemId: string, userId: string): Promise<ItemEntity> { |
||||
try { |
||||
this.logger.log(`Getting item: ${itemId} for user: ${userId}`); |
||||
|
||||
const itemIdVo = ItemId.create(itemId); |
||||
const userIdVo = UserId.create(userId); |
||||
|
||||
const item = await this.itemRepository.findById(itemIdVo); |
||||
|
||||
if (!item) { |
||||
this.logger.warn(`Item not found: ${itemId}`); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
// Validate ownership
|
||||
if (!item.getUserId().equals(userIdVo)) { |
||||
this.logger.warn(`User ${userId} attempted to access item ${itemId} owned by ${item.getUserId().getValue()}`); |
||||
// throw new UnauthorizedException('You do not have permission to access this item');
|
||||
// Go with 404 for safety reasons - it is till not found for that user, but the existence is not compromised
|
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
this.logger.log(`Successfully retrieved item: ${itemId}`); |
||||
return item; |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException || error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to get item ${itemId}: ${error.message}`); |
||||
throw new Error(`Failed to get item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
|
||||
@Injectable() |
||||
export class ListItemsQuery { |
||||
private readonly logger = new Logger(ListItemsQuery.name); |
||||
|
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
) {} |
||||
|
||||
async execute(userId: string): Promise<ItemEntity[]> { |
||||
try { |
||||
this.logger.log(`Listing items for user: ${userId}`); |
||||
|
||||
const userIdVo = UserId.create(userId); |
||||
const items = await this.itemRepository.findByUserId(userIdVo); |
||||
|
||||
this.logger.log(`Successfully retrieved ${items.length} items for user: ${userId}`); |
||||
return items; |
||||
} catch (error) { |
||||
this.logger.error(`Failed to list items for user ${userId}: ${error.message}`); |
||||
throw new Error(`Failed to list items: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@
|
||||
export interface JSendSuccess<T = any> { |
||||
status: 'success'; |
||||
data: T; |
||||
} |
||||
|
||||
export interface JSendError { |
||||
status: 'error'; |
||||
message: string; |
||||
code?: number; |
||||
data?: any; |
||||
} |
||||
|
||||
export interface JSendFail { |
||||
status: 'fail'; |
||||
data: any; |
||||
} |
||||
|
||||
export type JSendResponse<T = any> = JSendSuccess<T> | JSendError | JSendFail; |
||||
|
||||
export class JSendResponseUtil { |
||||
static success<T>(data: T): JSendSuccess<T> { |
||||
return { |
||||
status: 'success', |
||||
data, |
||||
}; |
||||
} |
||||
|
||||
static error(message: string, code?: number, data?: any): JSendError { |
||||
return { |
||||
status: 'error', |
||||
message, |
||||
code, |
||||
data, |
||||
}; |
||||
} |
||||
|
||||
static fail(data: any): JSendFail { |
||||
return { |
||||
status: 'fail', |
||||
data, |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,75 @@
|
||||
import { ItemId } from '../value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../value-objects/expiration-date.vo'; |
||||
import { UserId } from '../value-objects/user-id.vo'; |
||||
|
||||
export class ItemEntity { |
||||
private readonly id: ItemId; |
||||
private readonly name: string; |
||||
private readonly expirationDate: ExpirationDate; |
||||
private readonly orderUrl: string; |
||||
private readonly userId: UserId; |
||||
private readonly createdAt: Date; |
||||
|
||||
constructor( |
||||
id: ItemId, |
||||
name: string, |
||||
expirationDate: ExpirationDate, |
||||
orderUrl: string, |
||||
userId: UserId, |
||||
) { |
||||
this.validateName(name); |
||||
this.validateOrderUrl(orderUrl); |
||||
|
||||
this.id = id; |
||||
this.name = name; |
||||
this.expirationDate = expirationDate; |
||||
this.orderUrl = orderUrl; |
||||
this.userId = userId; |
||||
this.createdAt = new Date(); |
||||
} |
||||
|
||||
private validateName(name: string): void { |
||||
if (!name || name.trim().length === 0) { |
||||
throw new Error('Item name cannot be empty'); |
||||
} |
||||
if (name.length > 255) { |
||||
throw new Error('Item name cannot exceed 255 characters'); |
||||
} |
||||
} |
||||
|
||||
private validateOrderUrl(orderUrl: string): void { |
||||
if (!orderUrl || orderUrl.trim().length === 0) { |
||||
throw new Error('Order URL cannot be empty'); |
||||
} |
||||
|
||||
try { |
||||
new URL(orderUrl); |
||||
} catch { |
||||
throw new Error('Order URL must be a valid URL'); |
||||
} |
||||
} |
||||
|
||||
getId(): ItemId { |
||||
return this.id; |
||||
} |
||||
|
||||
getName(): string { |
||||
return this.name; |
||||
} |
||||
|
||||
getExpirationDate(): ExpirationDate { |
||||
return this.expirationDate; |
||||
} |
||||
|
||||
getOrderUrl(): string { |
||||
return this.orderUrl; |
||||
} |
||||
|
||||
getUserId(): UserId { |
||||
return this.userId; |
||||
} |
||||
|
||||
getCreatedAt(): Date { |
||||
return this.createdAt; |
||||
} |
||||
} |
||||
@ -0,0 +1,62 @@
|
||||
import { UserId } from '../value-objects/user-id.vo'; |
||||
|
||||
export class UserEntity { |
||||
private readonly id: UserId; |
||||
private readonly username: string; |
||||
private readonly passwordHash: string; |
||||
private readonly createdAt: Date; |
||||
|
||||
constructor( |
||||
id: UserId, |
||||
username: string, |
||||
passwordHash: string, |
||||
) { |
||||
this.validateUsername(username); |
||||
this.validatePasswordHash(passwordHash); |
||||
|
||||
this.id = id; |
||||
this.username = username; |
||||
this.passwordHash = passwordHash; |
||||
this.createdAt = new Date(); |
||||
} |
||||
|
||||
private validateUsername(username: string): void { |
||||
if (!username || username.trim().length === 0) { |
||||
throw new Error('Username cannot be empty'); |
||||
} |
||||
if (username.length < 3) { |
||||
throw new Error('Username must be at least 3 characters long'); |
||||
} |
||||
if (username.length > 50) { |
||||
throw new Error('Username cannot exceed 50 characters'); |
||||
} |
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { |
||||
throw new Error('Username can only contain letters, numbers, underscores, and hyphens'); |
||||
} |
||||
} |
||||
|
||||
private validatePasswordHash(passwordHash: string): void { |
||||
if (!passwordHash || passwordHash.trim().length === 0) { |
||||
throw new Error('Password hash cannot be empty'); |
||||
} |
||||
if (passwordHash.length < 8) { |
||||
throw new Error('Password hash must be at least 8 characters long'); |
||||
} |
||||
} |
||||
|
||||
getId(): UserId { |
||||
return this.id; |
||||
} |
||||
|
||||
getUsername(): string { |
||||
return this.username; |
||||
} |
||||
|
||||
getPasswordHash(): string { |
||||
return this.passwordHash; |
||||
} |
||||
|
||||
getCreatedAt(): Date { |
||||
return this.createdAt; |
||||
} |
||||
} |
||||
@ -0,0 +1,159 @@
|
||||
import { ItemEntity } from '../../entities/item.entity'; |
||||
import { ItemId } from '../../value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../../value-objects/expiration-date.vo'; |
||||
import { UserId } from '../../value-objects/user-id.vo'; |
||||
import { ItemExpirationSpec } from '../item-expiration.spec'; |
||||
|
||||
describe('ItemExpirationSpec', () => { |
||||
let spec: ItemExpirationSpec; |
||||
let currentTime: Date; |
||||
|
||||
beforeEach(() => { |
||||
spec = new ItemExpirationSpec(); |
||||
currentTime = new Date('2023-01-01T12:00:00Z'); |
||||
}); |
||||
|
||||
const createItemWithExpiration = (expirationDate: Date): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create('550e8400-e29b-41d4-a716-446655440000'), |
||||
'Test Item', |
||||
ExpirationDate.create(expirationDate), |
||||
'https://example.com/order', |
||||
UserId.create('550e8400-e29b-41d4-a716-446655440001'), |
||||
); |
||||
}; |
||||
|
||||
describe('isExpired', () => { |
||||
it('should return true when item is expired', () => { |
||||
const expiredDate = new Date('2022-12-31T12:00:00Z'); // 1 day before current time
|
||||
const item = createItemWithExpiration(expiredDate); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when item is not expired', () => { |
||||
const futureDate = new Date('2023-01-02T12:00:00Z'); // 1 day after current time
|
||||
const item = createItemWithExpiration(futureDate); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when expiration date equals current time', () => { |
||||
const sameTime = new Date(currentTime); |
||||
const item = createItemWithExpiration(sameTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when expiration date is one second in the future', () => { |
||||
const futureTime = new Date(currentTime); |
||||
futureTime.setSeconds(futureTime.getSeconds() + 1); |
||||
const item = createItemWithExpiration(futureTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when expiration date is one second in the past', () => { |
||||
const pastTime = new Date(currentTime); |
||||
pastTime.setSeconds(pastTime.getSeconds() - 1); |
||||
const item = createItemWithExpiration(pastTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('getSpec', () => { |
||||
it('should return a specification for finding expired items', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
expect(specification).toBeDefined(); |
||||
expect(specification.getSpec).toBeDefined(); |
||||
expect(typeof specification.getSpec()).toBe('object'); |
||||
}); |
||||
|
||||
it('should return specification that matches expired items', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
const expiredItem = createItemWithExpiration(new Date('2022-12-31T12:00:00Z')); |
||||
const validItem = createItemWithExpiration(new Date('2023-01-02T12:00:00Z')); |
||||
|
||||
expect(specification.isSatisfiedBy(expiredItem)).toBe(true); |
||||
expect(specification.isSatisfiedBy(validItem)).toBe(false); |
||||
}); |
||||
|
||||
it('should return specification that matches item with exact current time', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
const itemWithCurrentTime = createItemWithExpiration(currentTime); |
||||
|
||||
expect(specification.isSatisfiedBy(itemWithCurrentTime)).toBe(true); |
||||
}); |
||||
|
||||
it('should return specification with correct expiration criteria', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
const specObject = specification.getSpec(); |
||||
|
||||
expect(specObject).toEqual(['expirationDate', '<=', currentTime.toISOString()]); |
||||
}); |
||||
}); |
||||
|
||||
describe('time scenarios', () => { |
||||
it('should correctly identify items expired by different time units', () => { |
||||
// Test past dates (should be expired)
|
||||
const fiveMinutesAgo = new Date(currentTime); |
||||
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5); |
||||
expect(spec.isExpired(createItemWithExpiration(fiveMinutesAgo), currentTime)).toBe(true); |
||||
|
||||
const twoHoursAgo = new Date(currentTime); |
||||
twoHoursAgo.setHours(twoHoursAgo.getHours() - 2); |
||||
expect(spec.isExpired(createItemWithExpiration(twoHoursAgo), currentTime)).toBe(true); |
||||
|
||||
const threeDaysAgo = new Date(currentTime); |
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); |
||||
expect(spec.isExpired(createItemWithExpiration(threeDaysAgo), currentTime)).toBe(true); |
||||
|
||||
// Test future dates (should not be expired)
|
||||
const fiveMinutesFuture = new Date(currentTime); |
||||
fiveMinutesFuture.setMinutes(fiveMinutesFuture.getMinutes() + 5); |
||||
expect(spec.isExpired(createItemWithExpiration(fiveMinutesFuture), currentTime)).toBe(false); |
||||
|
||||
const twoHoursFuture = new Date(currentTime); |
||||
twoHoursFuture.setHours(twoHoursFuture.getHours() + 2); |
||||
expect(spec.isExpired(createItemWithExpiration(twoHoursFuture), currentTime)).toBe(false); |
||||
|
||||
const threeDaysFuture = new Date(currentTime); |
||||
threeDaysFuture.setDate(threeDaysFuture.getDate() + 3); |
||||
expect(spec.isExpired(createItemWithExpiration(threeDaysFuture), currentTime)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle special date boundaries correctly', () => { |
||||
// Midnight boundary
|
||||
const midnight = new Date('2023-01-01T00:00:00Z'); |
||||
const itemExpiredAtMidnight = createItemWithExpiration(midnight); |
||||
const currentTimeAtMidnight = new Date('2023-01-01T00:00:00Z'); |
||||
expect(spec.isExpired(itemExpiredAtMidnight, currentTimeAtMidnight)).toBe(true); |
||||
|
||||
// Year boundary
|
||||
const endOfYear = new Date('2022-12-31T23:59:59Z'); |
||||
const itemExpiredAtEndOfYear = createItemWithExpiration(endOfYear); |
||||
const currentTimeNewYear = new Date('2023-01-01T00:00:01Z'); |
||||
expect(spec.isExpired(itemExpiredAtEndOfYear, currentTimeNewYear)).toBe(true); |
||||
|
||||
// Leap year
|
||||
const leapYearDate = new Date('2020-02-29T12:00:00Z'); |
||||
const itemWithLeapYearDate = createItemWithExpiration(leapYearDate); |
||||
const currentTimeAfterLeapYear = new Date('2020-03-01T12:00:00Z'); |
||||
expect(spec.isExpired(itemWithLeapYearDate, currentTimeAfterLeapYear)).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,580 @@
|
||||
import { SimpleSpecification, Spec } from '../spec.helper'; |
||||
|
||||
describe('Spec Helper', () => { |
||||
describe('Spec static methods', () => { |
||||
describe('eq', () => { |
||||
it('should create equality condition', () => { |
||||
const condition = Spec.eq('name', 'test'); |
||||
expect(condition).toEqual(['name', '=', 'test']); |
||||
}); |
||||
}); |
||||
|
||||
describe('neq', () => { |
||||
it('should create not equal condition', () => { |
||||
const condition = Spec.neq('status', 'inactive'); |
||||
expect(condition).toEqual(['status', '!=', 'inactive']); |
||||
}); |
||||
}); |
||||
|
||||
describe('gt', () => { |
||||
it('should create greater than condition', () => { |
||||
const condition = Spec.gt('age', 18); |
||||
expect(condition).toEqual(['age', '>', 18]); |
||||
}); |
||||
}); |
||||
|
||||
describe('gte', () => { |
||||
it('should create greater than or equal condition', () => { |
||||
const condition = Spec.gte('score', 80); |
||||
expect(condition).toEqual(['score', '>=', 80]); |
||||
}); |
||||
}); |
||||
|
||||
describe('lt', () => { |
||||
it('should create less than condition', () => { |
||||
const condition = Spec.lt('price', 100); |
||||
expect(condition).toEqual(['price', '<', 100]); |
||||
}); |
||||
}); |
||||
|
||||
describe('lte', () => { |
||||
it('should create less than or equal condition', () => { |
||||
const condition = Spec.lte('expirationDate', '2023-01-01'); |
||||
expect(condition).toEqual(['expirationDate', '<=', '2023-01-01']); |
||||
}); |
||||
}); |
||||
|
||||
describe('in', () => { |
||||
it('should create IN condition', () => { |
||||
const condition = Spec.in('role', ['admin', 'user']); |
||||
expect(condition).toEqual(['role', 'IN', ['admin', 'user']]); |
||||
}); |
||||
}); |
||||
|
||||
describe('nin', () => { |
||||
it('should create NOT IN condition', () => { |
||||
const condition = Spec.nin('status', ['banned', 'suspended']); |
||||
expect(condition).toEqual(['status', 'NOT IN', ['banned', 'suspended']]); |
||||
}); |
||||
}); |
||||
|
||||
describe('and', () => { |
||||
it('should create AND group', () => { |
||||
const conditions = [Spec.eq('active', true), Spec.gt('score', 80)]; |
||||
const group = Spec.and(conditions); |
||||
expect(group).toEqual({ AND: [['active', '=', true], ['score', '>', 80]] }); |
||||
}); |
||||
}); |
||||
|
||||
describe('or', () => { |
||||
it('should create OR group', () => { |
||||
const conditions = [Spec.eq('role', 'admin'), Spec.eq('role', 'moderator')]; |
||||
const group = Spec.or(conditions); |
||||
expect(group).toEqual({ OR: [['role', '=', 'admin'], ['role', '=', 'moderator']] }); |
||||
}); |
||||
}); |
||||
|
||||
describe('not', () => { |
||||
it('should create NOT group', () => { |
||||
const condition = Spec.eq('deleted', true); |
||||
const group = Spec.not(condition); |
||||
expect(group).toEqual({ NOT: ['deleted', '=', true] }); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('SimpleSpecification', () => { |
||||
describe('basic operators', () => { |
||||
describe('EQ operator', () => { |
||||
it('should match equal string values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'test' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match different string values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'different' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match equal integer values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('age', 25)); |
||||
const object = { age: 25 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match different integer values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('age', 25)); |
||||
const object = { age: 30 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match equal float values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('price', 19.99)); |
||||
const object = { price: 19.99 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should work with getter methods', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { |
||||
getName: () => 'test', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should work with direct property access', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { |
||||
name: 'test', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('NEQ operator', () => { |
||||
it('should match when values are not equal', () => { |
||||
const spec = new SimpleSpecification(Spec.neq('status', 'inactive')); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when values are equal', () => { |
||||
const spec = new SimpleSpecification(Spec.neq('status', 'inactive')); |
||||
const object = { status: 'inactive' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('GT operator', () => { |
||||
it('should match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('GTE operator', () => { |
||||
it('should match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('LT operator', () => { |
||||
it('should match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('LTE operator', () => { |
||||
it('should match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('IN operator', () => { |
||||
it('should match when value is in array', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', ['admin', 'moderator'])); |
||||
const object = { role: 'admin' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is not in array', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', ['admin', 'moderator'])); |
||||
const object = { role: 'user' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when array is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', [])); |
||||
const object = { role: 'admin' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('NIN operator', () => { |
||||
it('should match when value is not in array', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', ['banned', 'suspended'])); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is in array', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', ['banned', 'suspended'])); |
||||
const object = { status: 'banned' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match when array is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', [])); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('logical groups', () => { |
||||
describe('AND group', () => { |
||||
it('should match when all conditions are met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]) |
||||
); |
||||
|
||||
const object = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when any condition is not met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]) |
||||
); |
||||
|
||||
const object1 = { |
||||
status: 'inactive', |
||||
score: 85, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const object2 = { |
||||
status: 'active', |
||||
score: 70, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const object3 = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object1)).toBe(false); |
||||
expect(spec.isSatisfiedBy(object2)).toBe(false); |
||||
expect(spec.isSatisfiedBy(object3)).toBe(false); |
||||
}); |
||||
|
||||
it('should match when group is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.and([])); |
||||
const object = { any: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('OR group', () => { |
||||
it('should match when any condition is met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.or([ |
||||
Spec.eq('role', 'admin'), |
||||
Spec.gte('score', 90), |
||||
Spec.in('department', ['IT', 'HR']), |
||||
]) |
||||
); |
||||
|
||||
const object1 = { |
||||
role: 'admin', |
||||
score: 70, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
const object2 = { |
||||
role: 'user', |
||||
score: 95, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
const object3 = { |
||||
role: 'user', |
||||
score: 70, |
||||
department: 'IT', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object1)).toBe(true); |
||||
expect(spec.isSatisfiedBy(object2)).toBe(true); |
||||
expect(spec.isSatisfiedBy(object3)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when no conditions are met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.or([ |
||||
Spec.eq('role', 'admin'), |
||||
Spec.gte('score', 90), |
||||
Spec.in('department', ['IT', 'HR']), |
||||
]) |
||||
); |
||||
|
||||
const object = { |
||||
role: 'user', |
||||
score: 70, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when group is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.or([])); |
||||
const object = { any: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('NOT group', () => { |
||||
it('should match when condition is not met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.not(Spec.eq('status', 'banned')) |
||||
); |
||||
|
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when condition is met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.not(Spec.eq('status', 'banned')) |
||||
); |
||||
|
||||
const object = { status: 'banned' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('nested groups', () => { |
||||
it('should handle nested AND-OR groups', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.or([ |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]), |
||||
]) |
||||
); |
||||
|
||||
const matchingObject1 = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
const matchingObject2 = { |
||||
status: 'active', |
||||
score: 70, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const nonMatchingObject = { |
||||
status: 'inactive', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject1)).toBe(true); |
||||
expect(spec.isSatisfiedBy(matchingObject2)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle triple nested groups', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('active', true), |
||||
Spec.not( |
||||
Spec.or([ |
||||
Spec.eq('role', 'banned'), |
||||
Spec.eq('status', 'suspended'), |
||||
]) |
||||
), |
||||
]) |
||||
); |
||||
|
||||
const matchingObject = { |
||||
active: true, |
||||
role: 'user', |
||||
status: 'active', |
||||
}; |
||||
|
||||
const nonMatchingObject1 = { |
||||
active: false, |
||||
role: 'user', |
||||
status: 'active', |
||||
}; |
||||
|
||||
const nonMatchingObject2 = { |
||||
active: true, |
||||
role: 'banned', |
||||
status: 'active', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject1)).toBe(false); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject2)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('edge cases', () => { |
||||
it('should return false when field does not exist', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('nonExistentField', 'value')); |
||||
const object = { existingField: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle null values correctly', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('optionalField', null)); |
||||
|
||||
const matchingObject = { optionalField: null }; |
||||
const nonMatchingObject = { optionalField: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject)).toBe(false); |
||||
}); |
||||
|
||||
it('should return false for unknown operators', () => { |
||||
const spec = new SimpleSpecification(['field', 'INVALID_OP', 'value']); |
||||
const object = { field: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle invalid date strings gracefully', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('dateField', 'invalid-date')); |
||||
const object = { dateField: 'invalid-date' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); // Falls back to regular comparison
|
||||
}); |
||||
|
||||
it('should handle Date objects in objects', () => { |
||||
const testDate = new Date('2023-01-15T10:30:00Z'); |
||||
const spec = new SimpleSpecification(Spec.eq('createdAt', testDate)); |
||||
const object = { createdAt: testDate }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should handle date string comparisons with Date objects', () => { |
||||
const testDate = new Date('2023-01-15T10:30:00Z'); |
||||
const spec = new SimpleSpecification(Spec.eq('createdAt', testDate.toISOString())); |
||||
const object = { createdAt: testDate }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('getSpec', () => { |
||||
it('should return the original specification', () => { |
||||
const originalSpec = Spec.eq('field', 'value'); |
||||
const specification = new SimpleSpecification(originalSpec); |
||||
|
||||
expect(specification.getSpec()).toEqual(originalSpec); |
||||
}); |
||||
}); |
||||
|
||||
describe('match method', () => { |
||||
it('should work as alias for isSatisfiedBy', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'test' }; |
||||
|
||||
expect(spec.match(object)).toBe(true); |
||||
expect(spec.match(object)).toBe(spec.isSatisfiedBy(object)); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,14 @@
|
||||
import { ItemEntity } from '../entities/item.entity'; |
||||
import { SimpleSpecification, Spec } from './spec.helper'; |
||||
|
||||
export class ItemExpirationSpec { |
||||
isExpired(item: ItemEntity, currentTime: Date): boolean { |
||||
return this.getSpec(currentTime).match(item); |
||||
} |
||||
|
||||
getSpec(currentTime: Date): SimpleSpecification<ItemEntity> { |
||||
return new SimpleSpecification<ItemEntity>( |
||||
Spec.lte('expirationDate', currentTime.toISOString()) |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,192 @@
|
||||
import { ISpecification } from './specification.interface'; |
||||
|
||||
export class Spec { |
||||
// Logical group operators
|
||||
static readonly GROUP_AND = 'AND'; |
||||
static readonly GROUP_OR = 'OR'; |
||||
static readonly GROUP_NOT = 'NOT'; |
||||
|
||||
// Comparison operators
|
||||
static readonly OP_EQ = '='; |
||||
static readonly OP_NEQ = '!='; |
||||
static readonly OP_GT = '>'; |
||||
static readonly OP_GTE = '>='; |
||||
static readonly OP_LT = '<'; |
||||
static readonly OP_LTE = '<='; |
||||
static readonly OP_IN = 'IN'; |
||||
static readonly OP_NIN = 'NOT IN'; |
||||
|
||||
// Logical group helpers
|
||||
static and(conditions: any[]): any { |
||||
return { [this.GROUP_AND]: conditions }; |
||||
} |
||||
|
||||
static or(conditions: any[]): any { |
||||
return { [this.GROUP_OR]: conditions }; |
||||
} |
||||
|
||||
static not(condition: any): any { |
||||
return { [this.GROUP_NOT]: condition }; |
||||
} |
||||
|
||||
// Condition helpers
|
||||
static eq(field: string, value: any): any { |
||||
return [field, this.OP_EQ, value]; |
||||
} |
||||
|
||||
static neq(field: string, value: any): any { |
||||
return [field, this.OP_NEQ, value]; |
||||
} |
||||
|
||||
static gt(field: string, value: any): any { |
||||
return [field, this.OP_GT, value]; |
||||
} |
||||
|
||||
static gte(field: string, value: any): any { |
||||
return [field, this.OP_GTE, value]; |
||||
} |
||||
|
||||
static lt(field: string, value: any): any { |
||||
return [field, this.OP_LT, value]; |
||||
} |
||||
|
||||
static lte(field: string, value: any): any { |
||||
return [field, this.OP_LTE, value]; |
||||
} |
||||
|
||||
static in(field: string, values: any[]): any { |
||||
return [field, this.OP_IN, values]; |
||||
} |
||||
|
||||
static nin(field: string, values: any[]): any { |
||||
return [field, this.OP_NIN, values]; |
||||
} |
||||
} |
||||
|
||||
export class SimpleSpecification<T> implements ISpecification<T> { |
||||
private readonly spec: any; |
||||
|
||||
constructor(spec: any) { |
||||
this.spec = spec; |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.evaluateSpec(this.spec, candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return this.spec; |
||||
} |
||||
|
||||
match(object: any): boolean { |
||||
return this.isSatisfiedBy(object); |
||||
} |
||||
|
||||
private evaluateSpec(spec: any, object: any): boolean { |
||||
// Handle logical groups
|
||||
if (spec[Spec.GROUP_AND]) { |
||||
for (const subSpec of spec[Spec.GROUP_AND]) { |
||||
if (!this.evaluateSpec(subSpec, object)) { |
||||
return false; |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
if (spec[Spec.GROUP_OR]) { |
||||
for (const subSpec of spec[Spec.GROUP_OR]) { |
||||
if (this.evaluateSpec(subSpec, object)) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
if (spec[Spec.GROUP_NOT]) { |
||||
return !this.evaluateSpec(spec[Spec.GROUP_NOT], object); |
||||
} |
||||
|
||||
// Handle simple conditions [field, op, value]
|
||||
const [field, op, value] = spec; |
||||
|
||||
// Check if field exists in the object
|
||||
const getterMethod = 'get' + this.capitalizeFirst(field); |
||||
let fieldValue: any; |
||||
|
||||
if (typeof object === 'object' && object !== null) { |
||||
if (typeof object[getterMethod] === 'function') { |
||||
fieldValue = object[getterMethod](); |
||||
} else if (field in object) { |
||||
fieldValue = object[field]; |
||||
} else { |
||||
return false; |
||||
} |
||||
} else { |
||||
return false; |
||||
} |
||||
|
||||
// Handle Date comparison
|
||||
if (fieldValue instanceof Date && (typeof value === 'string' || value instanceof Date)) { |
||||
return this.compareDates(fieldValue, op, value); |
||||
} |
||||
|
||||
// Evaluate based on operator
|
||||
switch (op) { |
||||
case Spec.OP_EQ: |
||||
return fieldValue == value; |
||||
case Spec.OP_NEQ: |
||||
return fieldValue != value; |
||||
case Spec.OP_GT: |
||||
return fieldValue > value; |
||||
case Spec.OP_GTE: |
||||
return fieldValue >= value; |
||||
case Spec.OP_LT: |
||||
return fieldValue < value; |
||||
case Spec.OP_LTE: |
||||
return fieldValue <= value; |
||||
case Spec.OP_IN: |
||||
return Array.isArray(value) && value.includes(fieldValue); |
||||
case Spec.OP_NIN: |
||||
return Array.isArray(value) && !value.includes(fieldValue); |
||||
default: |
||||
return false; // Unknown operator
|
||||
} |
||||
} |
||||
|
||||
private compareDates(fieldValue: Date, op: string, value: Date | string): boolean { |
||||
let compareValue: Date; |
||||
|
||||
if (typeof value === 'string') { |
||||
compareValue = new Date(value); |
||||
if (isNaN(compareValue.getTime())) { |
||||
return false; // Invalid date string
|
||||
} |
||||
} else { |
||||
compareValue = value; |
||||
} |
||||
|
||||
const fieldTime = fieldValue.getTime(); |
||||
const compareTime = compareValue.getTime(); |
||||
|
||||
switch (op) { |
||||
case Spec.OP_EQ: |
||||
return fieldTime === compareTime; |
||||
case Spec.OP_NEQ: |
||||
return fieldTime !== compareTime; |
||||
case Spec.OP_GT: |
||||
return fieldTime > compareTime; |
||||
case Spec.OP_GTE: |
||||
return fieldTime >= compareTime; |
||||
case Spec.OP_LT: |
||||
return fieldTime < compareTime; |
||||
case Spec.OP_LTE: |
||||
return fieldTime <= compareTime; |
||||
default: |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
private capitalizeFirst(str: string): string { |
||||
return str.charAt(0).toUpperCase() + str.slice(1); |
||||
} |
||||
} |
||||
@ -0,0 +1,75 @@
|
||||
export interface ISpecification<T> { |
||||
isSatisfiedBy(candidate: T): boolean; |
||||
getSpec(): object; |
||||
} |
||||
|
||||
export abstract class Specification<T> implements ISpecification<T> { |
||||
abstract isSatisfiedBy(candidate: T): boolean; |
||||
abstract getSpec(): object; |
||||
|
||||
and(other: Specification<T>): Specification<T> { |
||||
return new AndSpecification(this, other); |
||||
} |
||||
|
||||
or(other: Specification<T>): Specification<T> { |
||||
return new OrSpecification(this, other); |
||||
} |
||||
|
||||
not(): Specification<T> { |
||||
return new NotSpecification(this); |
||||
} |
||||
} |
||||
|
||||
class AndSpecification<T> extends Specification<T> { |
||||
constructor( |
||||
private readonly left: Specification<T>, |
||||
private readonly right: Specification<T>, |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
AND: [this.left.getSpec(), this.right.getSpec()], |
||||
}; |
||||
} |
||||
} |
||||
|
||||
class OrSpecification<T> extends Specification<T> { |
||||
constructor( |
||||
private readonly left: Specification<T>, |
||||
private readonly right: Specification<T>, |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
OR: [this.left.getSpec(), this.right.getSpec()], |
||||
}; |
||||
} |
||||
} |
||||
|
||||
class NotSpecification<T> extends Specification<T> { |
||||
constructor(private readonly spec: Specification<T>) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return !this.spec.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
NOT: this.spec.getSpec(), |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,147 @@
|
||||
// This is a base test class for UUID value objects to avoid code duplication
|
||||
export abstract class BaseUuidValueObjectSpec<T> { |
||||
protected abstract createValueObject(uuid: string): T; |
||||
protected abstract generateValueObject(): T; |
||||
protected abstract getValidUuid(): string; |
||||
protected abstract getInvalidUuids(): string[]; |
||||
protected abstract getClassName(): string; |
||||
protected abstract getStaticCreateMethod(): (uuid: string) => T; |
||||
|
||||
// Type assertion methods to ensure the value object has the required methods
|
||||
protected getValue(vo: T): string { |
||||
return (vo as any).getValue(); |
||||
} |
||||
|
||||
protected equals(vo1: T, vo2: T): boolean { |
||||
return (vo1 as any).equals(vo2); |
||||
} |
||||
|
||||
protected toString(vo: T): string { |
||||
return (vo as any).toString(); |
||||
} |
||||
|
||||
public runTests() { |
||||
describe(`${this.getClassName()}`, () => { |
||||
describe('constructor', () => { |
||||
it('should create with valid UUID', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
|
||||
it('should throw error when UUID is empty', () => { |
||||
expect(() => { |
||||
this.createValueObject(''); |
||||
}).toThrow(`${this.getClassName()} cannot be empty`); |
||||
}); |
||||
|
||||
it('should throw error when UUID is only whitespace', () => { |
||||
expect(() => { |
||||
this.createValueObject(' '); |
||||
}).toThrow(`${this.getClassName()} cannot be empty`); |
||||
}); |
||||
|
||||
it('should throw error when UUID is invalid', () => { |
||||
this.getInvalidUuids().forEach(uuid => { |
||||
expect(() => { |
||||
this.createValueObject(uuid); |
||||
}).toThrow(`${this.getClassName()} must be a valid UUID`); |
||||
}); |
||||
}); |
||||
|
||||
it('should accept UUID with uppercase letters', () => { |
||||
const upperUuid = this.getValidUuid().toUpperCase(); |
||||
const valueObject = this.createValueObject(upperUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(upperUuid); |
||||
}); |
||||
|
||||
it('should accept UUID with mixed case', () => { |
||||
const mixedUuid = '550e8400-E29b-41D4-a716-446655440000'; |
||||
const valueObject = this.createValueObject(mixedUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(mixedUuid); |
||||
}); |
||||
}); |
||||
|
||||
describe('static methods', () => { |
||||
describe('generate', () => { |
||||
it('should generate a valid UUID', () => { |
||||
const valueObject = this.generateValueObject(); |
||||
const sampleValueObject = this.createValueObject(this.getValidUuid()); |
||||
|
||||
expect(valueObject).toBeInstanceOf(Object.getPrototypeOf(sampleValueObject).constructor); |
||||
expect(this.getValue(valueObject)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
}); |
||||
|
||||
it('should generate unique UUIDs', () => { |
||||
const valueObject1 = this.generateValueObject(); |
||||
const valueObject2 = this.generateValueObject(); |
||||
|
||||
expect(this.getValue(valueObject1)).not.toBe(this.getValue(valueObject2)); |
||||
}); |
||||
}); |
||||
|
||||
describe('create', () => { |
||||
it('should create from valid UUID string', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.getStaticCreateMethod()(validUuid); |
||||
const sampleValueObject = this.createValueObject(this.getValidUuid()); |
||||
|
||||
expect(valueObject).toBeInstanceOf(Object.getPrototypeOf(sampleValueObject).constructor); |
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
|
||||
it('should throw error for invalid UUID', () => { |
||||
expect(() => { |
||||
this.getStaticCreateMethod()('invalid-uuid'); |
||||
}).toThrow(`${this.getClassName()} must be a valid UUID`); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('getValue', () => { |
||||
it('should return the UUID value', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
}); |
||||
|
||||
describe('equals', () => { |
||||
it('should return true for equal value objects', () => { |
||||
const uuid = this.getValidUuid(); |
||||
const valueObject1 = this.createValueObject(uuid); |
||||
const valueObject2 = this.createValueObject(uuid); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for different value objects', () => { |
||||
const valueObject1 = this.createValueObject('550e8400-e29b-41d4-a716-446655440000'); |
||||
const valueObject2 = this.createValueObject('550e8400-e29b-41d4-a716-446655440001'); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(false); |
||||
}); |
||||
|
||||
it('should be case sensitive', () => { |
||||
const valueObject1 = this.createValueObject('550e8400-e29b-41d4-a716-446655440000'); |
||||
const valueObject2 = this.createValueObject('550E8400-E29B-41D4-A716-446655440000'); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('toString', () => { |
||||
it('should return string representation of UUID', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.toString(valueObject)).toBe(validUuid); |
||||
}); |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
@ -0,0 +1,149 @@
|
||||
import { ExpirationDate } from '../expiration-date.vo'; |
||||
|
||||
describe('ExpirationDate', () => { |
||||
const MOCKED_NOW = new Date('2023-01-01T12:00:00Z'); |
||||
|
||||
describe('constructor', () => { |
||||
it('should create ExpirationDate with valid future date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); // 7 days in the future
|
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
|
||||
it('should throw error when date is not a Date object', () => { |
||||
expect(() => { |
||||
new ExpirationDate('not-a-date' as any); |
||||
}).toThrow('Expiration date must be a Date object'); |
||||
}); |
||||
|
||||
it('should throw error when date is invalid', () => { |
||||
expect(() => { |
||||
new ExpirationDate(new Date('invalid-date')); |
||||
}).toThrow('Expiration date must be a valid date'); |
||||
}); |
||||
|
||||
}); |
||||
|
||||
describe('static create', () => { |
||||
it('should create ExpirationDate from valid Date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = ExpirationDate.create(futureDate); |
||||
|
||||
expect(expirationDate).toBeInstanceOf(ExpirationDate); |
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
}); |
||||
|
||||
describe('static fromString', () => { |
||||
it('should create ExpirationDate from valid ISO date string', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const dateString = futureDate.toISOString(); |
||||
|
||||
const expirationDate = ExpirationDate.fromString(dateString); |
||||
|
||||
expect(expirationDate).toBeInstanceOf(ExpirationDate); |
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
|
||||
it('should throw error for invalid date string', () => { |
||||
expect(() => { |
||||
ExpirationDate.fromString('invalid-date'); |
||||
}).toThrow('Invalid date string format'); |
||||
}); |
||||
}); |
||||
|
||||
describe('getValue', () => { |
||||
it('should return a copy of the date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
const returnedDate = expirationDate.getValue(); |
||||
|
||||
expect(returnedDate).toEqual(futureDate); |
||||
expect(returnedDate).not.toBe(futureDate); // Should be a different object
|
||||
}); |
||||
|
||||
it('should return immutable date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
const returnedDate = expirationDate.getValue(); |
||||
returnedDate.setDate(returnedDate.getDate() + 1); // Try to modify
|
||||
|
||||
// Original should remain unchanged
|
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
}); |
||||
|
||||
describe('format', () => { |
||||
it('should return ISO string format', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.format()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('toISOString', () => { |
||||
it('should return ISO string format', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.toISOString()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('toString', () => { |
||||
it('should return string representation', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.toString()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('equals', () => { |
||||
it('should return true for equal dates', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate1 = new ExpirationDate(futureDate); |
||||
const expirationDate2 = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for different dates', () => { |
||||
const futureDate1 = new Date(MOCKED_NOW); |
||||
futureDate1.setDate(futureDate1.getDate() + 7); |
||||
|
||||
const futureDate2 = new Date(MOCKED_NOW); |
||||
futureDate2.setDate(futureDate2.getDate() + 8); |
||||
|
||||
const expirationDate1 = new ExpirationDate(futureDate1); |
||||
const expirationDate2 = new ExpirationDate(futureDate2); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(false); |
||||
}); |
||||
|
||||
it('should return true for dates with same timestamp', () => { |
||||
const timestamp = MOCKED_NOW.getTime() + 86400000; // 1 day in the future
|
||||
const date1 = new Date(timestamp); |
||||
const date2 = new Date(timestamp); |
||||
|
||||
const expirationDate1 = new ExpirationDate(date1); |
||||
const expirationDate2 = new ExpirationDate(date2); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
}); |
||||
@ -0,0 +1,37 @@
|
||||
import { ItemId } from '../item-id.vo'; |
||||
import { BaseUuidValueObjectSpec } from './base-uuid-value-object'; |
||||
|
||||
class ItemIdSpec extends BaseUuidValueObjectSpec<ItemId> { |
||||
protected createValueObject(uuid: string): ItemId { |
||||
return new ItemId(uuid); |
||||
} |
||||
|
||||
protected generateValueObject(): ItemId { |
||||
return ItemId.generate(); |
||||
} |
||||
|
||||
protected getValidUuid(): string { |
||||
return '550e8400-e29b-41d4-a716-446655440000'; |
||||
} |
||||
|
||||
protected getInvalidUuids(): string[] { |
||||
return [ |
||||
'not-a-uuid', |
||||
'550e8400-e29b-41d4-a716', // too short
|
||||
'550e8400-e29b-41d4-a716-446655440000-extra', // too long
|
||||
'550e8400-e29b-41d4-a716-44665544000g', // invalid character 'g'
|
||||
'550e8400e29b41d4a716446655440000', // missing hyphens
|
||||
]; |
||||
} |
||||
|
||||
protected getClassName(): string { |
||||
return 'Item ID'; |
||||
} |
||||
|
||||
protected getStaticCreateMethod(): (uuid: string) => ItemId { |
||||
return ItemId.create; |
||||
} |
||||
} |
||||
|
||||
// Run the tests
|
||||
new ItemIdSpec().runTests(); |
||||
@ -0,0 +1,52 @@
|
||||
import { UserId } from '../user-id.vo'; |
||||
import { BaseUuidValueObjectSpec } from './base-uuid-value-object'; |
||||
|
||||
class UserIdSpec extends BaseUuidValueObjectSpec<UserId> { |
||||
protected createValueObject(uuid: string): UserId { |
||||
return new UserId(uuid); |
||||
} |
||||
|
||||
protected generateValueObject(): UserId { |
||||
return UserId.generate(); |
||||
} |
||||
|
||||
protected getValidUuid(): string { |
||||
return '550e8400-e29b-41d4-a716-446655440000'; |
||||
} |
||||
|
||||
protected getInvalidUuids(): string[] { |
||||
return [ |
||||
'not-a-uuid', |
||||
'550e8400-e29b-41d4-a716', // too short
|
||||
'550e8400-e29b-41d4-a716-446655440000-extra', // too long
|
||||
'550e8400-e29b-41d4-a716-44665544000g', // invalid character 'g'
|
||||
'550e8400e29b41d4a716446655440000', // missing hyphens
|
||||
]; |
||||
} |
||||
|
||||
protected getClassName(): string { |
||||
return 'User ID'; |
||||
} |
||||
|
||||
protected getStaticCreateMethod(): (uuid: string) => UserId { |
||||
return UserId.create; |
||||
} |
||||
} |
||||
|
||||
// Run the tests
|
||||
new UserIdSpec().runTests(); |
||||
|
||||
// Additional UserId-specific tests
|
||||
describe('UserId', () => { |
||||
describe('comparison with ItemId', () => { |
||||
it('should not be equal to ItemId with same UUID value', () => { |
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000'; |
||||
const userId = new UserId(uuid); |
||||
const itemId = { getValue: () => uuid, equals: (other: any) => userId.getValue() === other.getValue() }; |
||||
|
||||
// This test demonstrates that UserId and ItemId are different types
|
||||
// even if they contain the same UUID value
|
||||
expect(userId.equals(itemId as any)).toBe(false); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,55 @@
|
||||
export class ExpirationDate { |
||||
private readonly value: Date; |
||||
|
||||
constructor(value: Date) { |
||||
this.validateDate(value); |
||||
this.value = new Date(value); // Create a copy to ensure immutability
|
||||
} |
||||
|
||||
static create(value: Date): ExpirationDate { |
||||
return new ExpirationDate(value); |
||||
} |
||||
|
||||
static fromString(dateString: string): ExpirationDate { |
||||
const date = new Date(dateString); |
||||
if (isNaN(date.getTime())) { |
||||
throw new Error('Invalid date string format'); |
||||
} |
||||
return new ExpirationDate(date); |
||||
} |
||||
|
||||
private validateDate(date: Date): void { |
||||
if (!(date instanceof Date)) { |
||||
throw new Error('Expiration date must be a Date object'); |
||||
} |
||||
|
||||
if (isNaN(date.getTime())) { |
||||
throw new Error('Expiration date must be a valid date'); |
||||
} |
||||
|
||||
// Note: We don't validate against current time here because:
|
||||
// 1. Business logic allows creating expired items (they trigger ordering)
|
||||
// 2. Validation should happen at the application layer based on business rules
|
||||
// 3. This allows for more flexibility in testing and edge cases
|
||||
} |
||||
|
||||
getValue(): Date { |
||||
return new Date(this.value); // Return a copy to maintain immutability
|
||||
} |
||||
|
||||
format(): string { |
||||
return this.value.toISOString(); |
||||
} |
||||
|
||||
toISOString(): string { |
||||
return this.value.toISOString(); |
||||
} |
||||
|
||||
toString(): string { |
||||
return this.format(); |
||||
} |
||||
|
||||
equals(other: ExpirationDate): boolean { |
||||
return this.value.getTime() === other.value.getTime(); |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
|
||||
import { randomUUID } from 'crypto'; |
||||
|
||||
export class ItemId { |
||||
private readonly value: string; |
||||
|
||||
constructor(value: string) { |
||||
this.validateUuid(value); |
||||
this.value = value; |
||||
} |
||||
|
||||
static generate(): ItemId { |
||||
return new ItemId(randomUUID()); |
||||
} |
||||
|
||||
static create(value: string): ItemId { |
||||
return new ItemId(value); |
||||
} |
||||
|
||||
private validateUuid(value: string): void { |
||||
if (!value || value.trim().length === 0) { |
||||
throw new Error('Item ID cannot be empty'); |
||||
} |
||||
|
||||
// Simple UUID validation (8-4-4-4-12 format)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
||||
if (!uuidRegex.test(value)) { |
||||
throw new Error('Item ID must be a valid UUID'); |
||||
} |
||||
} |
||||
|
||||
getValue(): string { |
||||
return this.value; |
||||
} |
||||
|
||||
equals(other: ItemId): boolean { |
||||
return this.value === other.value; |
||||
} |
||||
|
||||
toString(): string { |
||||
return this.value; |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
|
||||
import { randomUUID } from 'crypto'; |
||||
|
||||
export class UserId { |
||||
private readonly value: string; |
||||
|
||||
constructor(value: string) { |
||||
this.validateUuid(value); |
||||
this.value = value; |
||||
} |
||||
|
||||
static generate(): UserId { |
||||
return new UserId(randomUUID()); |
||||
} |
||||
|
||||
static create(value: string): UserId { |
||||
return new UserId(value); |
||||
} |
||||
|
||||
private validateUuid(value: string): void { |
||||
if (!value || value.trim().length === 0) { |
||||
throw new Error('User ID cannot be empty'); |
||||
} |
||||
|
||||
// Simple UUID validation (8-4-4-4-12 format)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
||||
if (!uuidRegex.test(value)) { |
||||
throw new Error('User ID must be a valid UUID'); |
||||
} |
||||
} |
||||
|
||||
getValue(): string { |
||||
return this.value; |
||||
} |
||||
|
||||
equals(other: UserId): boolean { |
||||
return this.value === other.value; |
||||
} |
||||
|
||||
toString(): string { |
||||
return this.value; |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { JwtService } from '@nestjs/jwt'; |
||||
import { ConfigService } from '@nestjs/config'; |
||||
import * as bcrypt from 'bcrypt'; |
||||
import { IAuthService } from '../../application/interfaces/auth-service.interface'; |
||||
import { IUserRepository } from '../../application/interfaces/user-repository.interface'; |
||||
import { UserEntity } from '../../domain/entities/user.entity'; |
||||
|
||||
@Injectable() |
||||
export class JwtAuthService implements IAuthService { |
||||
private readonly logger = new Logger(JwtAuthService.name); |
||||
private readonly jwtSecret: string; |
||||
private readonly jwtExpiration: string; |
||||
|
||||
constructor( |
||||
@Inject('IUserRepository') |
||||
private readonly userRepository: IUserRepository, |
||||
private readonly jwtService: JwtService, |
||||
private readonly configService: ConfigService, |
||||
) { |
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || 'default-secret-key'; |
||||
this.jwtExpiration = this.configService.get<string>('JWT_EXPIRATION') || '1h'; |
||||
} |
||||
|
||||
async authenticate(username: string, password: string): Promise<string | null> { |
||||
try { |
||||
const user = await this.userRepository.findByUsername(username); |
||||
|
||||
if (!user) { |
||||
this.logger.warn(`User not found: ${username}`); |
||||
return null; |
||||
} |
||||
|
||||
const isPasswordValid = await this.verifyPassword(password, user.getPasswordHash()); |
||||
|
||||
if (!isPasswordValid) { |
||||
this.logger.warn(`Invalid password for user: ${username}`); |
||||
return null; |
||||
} |
||||
|
||||
const payload = { |
||||
sub: user.getId().getValue(), |
||||
username: user.getUsername(), |
||||
}; |
||||
|
||||
return this.jwtService.sign(payload, { |
||||
secret: this.jwtSecret, |
||||
expiresIn: this.jwtExpiration, |
||||
}); |
||||
} catch (error) { |
||||
this.logger.error(`Authentication error for user ${username}: ${error.message}`); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
async validateToken(token: string): Promise<boolean> { |
||||
try { |
||||
await this.jwtService.verifyAsync(token, { |
||||
secret: this.jwtSecret, |
||||
}); |
||||
return true; |
||||
} catch (error) { |
||||
this.logger.warn(`Invalid token: ${error.message}`); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
async getUserIdFromToken(token: string): Promise<string | null> { |
||||
try { |
||||
const payload = await this.jwtService.verifyAsync(token, { |
||||
secret: this.jwtSecret, |
||||
}); |
||||
return payload.sub || null; |
||||
} catch (error) { |
||||
this.logger.warn(`Failed to extract user ID from token: ${error.message}`); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
private async verifyPassword(password: string, hash: string): Promise<boolean> { |
||||
return bcrypt.compare(password, hash); |
||||
} |
||||
} |
||||
@ -0,0 +1,56 @@
|
||||
import { Injectable, Logger, HttpStatus } from '@nestjs/common'; |
||||
import { HttpService } from '@nestjs/axios'; |
||||
import { firstValueFrom } from 'rxjs'; |
||||
import { AxiosError } from 'axios'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { IOrderService } from '../../application/interfaces/order-service.interface'; |
||||
|
||||
@Injectable() |
||||
export class OrderHttpService implements IOrderService { |
||||
private readonly logger = new Logger(OrderHttpService.name); |
||||
|
||||
constructor(private readonly httpService: HttpService) {} |
||||
|
||||
async orderItem(item: ItemEntity): Promise<void> { |
||||
try { |
||||
this.logger.log(`Ordering item: ${item.getId().getValue()} at URL: ${item.getOrderUrl()}`); |
||||
|
||||
const orderData = { |
||||
itemId: item.getId().getValue(), |
||||
itemName: item.getName(), |
||||
expirationDate: item.getExpirationDate().toISOString(), |
||||
orderUrl: item.getOrderUrl(), |
||||
userId: item.getUserId().getValue(), |
||||
timestamp: new Date().toISOString(), |
||||
}; |
||||
|
||||
const response = await firstValueFrom( |
||||
this.httpService.post(item.getOrderUrl(), orderData, { |
||||
timeout: 5000, // 5 seconds timeout
|
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
'User-Agent': 'AutoStore/1.0', |
||||
}, |
||||
}), |
||||
); |
||||
|
||||
this.logger.log( |
||||
`Successfully ordered item ${item.getId().getValue()}. Status: ${response.status}`, |
||||
); |
||||
} catch (error) { |
||||
if (error instanceof AxiosError) { |
||||
const status = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR; |
||||
const message = error.response?.data?.message || error.message; |
||||
|
||||
this.logger.error( |
||||
`Failed to order item ${item.getId().getValue()}. Status: ${status}, Error: ${message}`, |
||||
); |
||||
|
||||
throw new Error(`Order service returned status ${status}: ${message}`); |
||||
} |
||||
|
||||
this.logger.error(`Failed to order item ${item.getId().getValue()}: ${error.message}`); |
||||
throw new Error(`Failed to order item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,349 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'; |
||||
import { FileItemRepository } from '../file-item-repository'; |
||||
import { ItemEntity } from '../../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../../domain/value-objects/user-id.vo'; |
||||
import { ExpirationDate } from '../../../domain/value-objects/expiration-date.vo'; |
||||
import { SimpleSpecification } from '../../../domain/specifications/spec.helper'; |
||||
import { Spec } from '../../../domain/specifications/spec.helper'; |
||||
import { existsSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
|
||||
describe('FileItemRepository', () => { |
||||
let repository: FileItemRepository; |
||||
let testStoragePath: string; |
||||
|
||||
// Test constants - using valid UUIDs
|
||||
const ITEM_ID_1 = '00000000-0000-0000-0000-000000000001'; |
||||
const ITEM_ID_2 = '00000000-0000-0000-0000-000000000002'; |
||||
const ITEM_ID_3 = '00000000-0000-0000-0000-000000000003'; |
||||
const EXPIRED_ID = '00000000-0000-0000-0000-000000000004'; |
||||
const VALID_ID = '00000000-0000-0000-0000-000000000005'; |
||||
const NON_EXISTENT_ID = '00000000-0000-0000-0000-000000000099'; |
||||
const ITEM_NAME_1 = 'Test Item 1'; |
||||
const ITEM_NAME_2 = 'Test Item 2'; |
||||
const ITEM_NAME_3 = 'Test Item 3'; |
||||
const EXPIRED_NAME = 'Expired Item'; |
||||
const VALID_NAME = 'Valid Item'; |
||||
const ORDER_URL_1 = 'http://example.com/order1'; |
||||
const ORDER_URL_2 = 'http://example.com/order2'; |
||||
const ORDER_URL_3 = 'http://example.com/order3'; |
||||
const EXPIRED_ORDER_URL = 'http://example.com/expired-order'; |
||||
const VALID_ORDER_URL = 'http://example.com/valid-order'; |
||||
const USER_ID_1 = '10000000-0000-0000-0000-000000000001'; |
||||
const USER_ID_2 = '10000000-0000-0000-0000-000000000002'; |
||||
const USER_ID = '10000000-0000-0000-0000-000000000003'; |
||||
const MOCKED_NOW = '2023-01-01T12:00:00.000Z'; |
||||
|
||||
beforeEach(async () => { |
||||
testStoragePath = join(__dirname, '../../../test-data'); |
||||
|
||||
// Create test directory if it doesn't exist
|
||||
if (!existsSync(testStoragePath)) { |
||||
mkdirSync(testStoragePath, { recursive: true }); |
||||
} |
||||
|
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
providers: [ |
||||
{ |
||||
provide: FileItemRepository, |
||||
useFactory: () => new FileItemRepository(testStoragePath), |
||||
}, |
||||
], |
||||
}).compile(); |
||||
|
||||
repository = module.get<FileItemRepository>(FileItemRepository); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
// Clean up test files
|
||||
const filePath = join(testStoragePath, 'items.json'); |
||||
if (existsSync(filePath)) { |
||||
unlinkSync(filePath); |
||||
} |
||||
|
||||
// Remove storage dir if empty
|
||||
try { |
||||
rmdirSync(testStoragePath); |
||||
} catch (e) { |
||||
// Directory not empty, ignore
|
||||
} |
||||
}); |
||||
|
||||
const createTestItem1 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_1), |
||||
ITEM_NAME_1, |
||||
ExpirationDate.create(new Date(Date.now() + 24 * 60 * 60 * 1000)), // +1 day
|
||||
ORDER_URL_1, |
||||
UserId.create(USER_ID_1), |
||||
); |
||||
}; |
||||
|
||||
const createTestItem2 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_2), |
||||
ITEM_NAME_2, |
||||
ExpirationDate.create(new Date(MOCKED_NOW)), |
||||
ORDER_URL_2, |
||||
UserId.create(USER_ID_2), |
||||
); |
||||
}; |
||||
|
||||
const createTestItem3 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_3), |
||||
ITEM_NAME_3, |
||||
ExpirationDate.create(new Date(MOCKED_NOW)), |
||||
ORDER_URL_3, |
||||
UserId.create(USER_ID_1), |
||||
); |
||||
}; |
||||
|
||||
const createExpiredItem = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(EXPIRED_ID), |
||||
EXPIRED_NAME, |
||||
ExpirationDate.create(new Date('2022-01-01T12:00:00.000Z')), // Past date
|
||||
EXPIRED_ORDER_URL, |
||||
UserId.create(USER_ID), |
||||
); |
||||
}; |
||||
|
||||
const createValidItem = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(VALID_ID), |
||||
VALID_NAME, |
||||
ExpirationDate.create(new Date('2024-01-01T12:00:00.000Z')), // Future date
|
||||
VALID_ORDER_URL, |
||||
UserId.create(USER_ID), |
||||
); |
||||
}; |
||||
|
||||
const createExpiredItemForUser1 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_1), |
||||
ITEM_NAME_1, |
||||
ExpirationDate.create(new Date('2022-01-01T12:00:00.000Z')), // Past date
|
||||
ORDER_URL_1, |
||||
UserId.create(USER_ID_1), |
||||
); |
||||
}; |
||||
|
||||
const createValidItemForUser1 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_2), |
||||
ITEM_NAME_2, |
||||
ExpirationDate.create(new Date('2024-01-01T12:00:00.000Z')), // Future date
|
||||
ORDER_URL_2, |
||||
UserId.create(USER_ID_1), |
||||
); |
||||
}; |
||||
|
||||
const createExpiredItemForUser2 = (): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create(ITEM_ID_3), |
||||
ITEM_NAME_3, |
||||
ExpirationDate.create(new Date('2022-01-01T12:00:00.000Z')), // Past date
|
||||
ORDER_URL_3, |
||||
UserId.create(USER_ID_2), |
||||
); |
||||
}; |
||||
|
||||
it('should create file when item is saved', async () => { |
||||
// Given
|
||||
const item = createTestItem1(); |
||||
|
||||
// When
|
||||
await repository.save(item); |
||||
|
||||
// Then
|
||||
const filePath = join(testStoragePath, 'items.json'); |
||||
expect(existsSync(filePath)).toBe(true); |
||||
}); |
||||
|
||||
it('should return item when finding by existing id', async () => { |
||||
// Given
|
||||
const item = createTestItem1(); |
||||
await repository.save(item); |
||||
|
||||
// When
|
||||
const foundItem = await repository.findById(ItemId.create(ITEM_ID_1)); |
||||
|
||||
// Then
|
||||
expect(foundItem).not.toBeNull(); |
||||
expect(foundItem?.getId().getValue()).toBe(item.getId().getValue()); |
||||
expect(foundItem?.getName()).toBe(item.getName()); |
||||
}); |
||||
|
||||
it('should return null when finding by non-existent id', async () => { |
||||
// When
|
||||
const foundItem = await repository.findById(ItemId.create(NON_EXISTENT_ID)); |
||||
|
||||
// Then
|
||||
expect(foundItem).toBeNull(); |
||||
}); |
||||
|
||||
it('should return all user items when finding by user id', async () => { |
||||
// Given
|
||||
const item1 = createTestItem1(); |
||||
const item2 = createTestItem2(); |
||||
const item3 = createTestItem3(); |
||||
|
||||
await repository.save(item1); |
||||
await repository.save(item2); |
||||
await repository.save(item3); |
||||
|
||||
// When
|
||||
const userItems = await repository.findByUserId(UserId.create(USER_ID_1)); |
||||
|
||||
// Then
|
||||
expect(userItems).toHaveLength(2); |
||||
const itemIds = userItems.map(i => i.getId().getValue()); |
||||
expect(itemIds).toContain(ITEM_ID_1); |
||||
expect(itemIds).toContain(ITEM_ID_3); |
||||
}); |
||||
|
||||
it('should return all items when finding all', async () => { |
||||
// Given
|
||||
const item1 = createTestItem1(); |
||||
const item2 = createTestItem2(); |
||||
|
||||
await repository.save(item1); |
||||
await repository.save(item2); |
||||
|
||||
// When
|
||||
const allItems = await repository.findByUserId(UserId.create('20000000-0000-0000-0000-000000000001')); // Non-existent user
|
||||
|
||||
// Then
|
||||
expect(allItems).toHaveLength(0); // This will be 0 since we're filtering by a non-existent user
|
||||
}); |
||||
|
||||
it('should delete item when delete is called', async () => { |
||||
// Given
|
||||
const item = createTestItem1(); |
||||
await repository.save(item); |
||||
|
||||
// When
|
||||
await repository.delete(ItemId.create(ITEM_ID_1)); |
||||
|
||||
// Then
|
||||
const foundItem = await repository.findById(ItemId.create(ITEM_ID_1)); |
||||
expect(foundItem).toBeNull(); |
||||
}); |
||||
|
||||
it('should throw exception when deleting non-existent item', async () => { |
||||
// Given & When & Then
|
||||
await expect(repository.delete(ItemId.create(NON_EXISTENT_ID))) |
||||
.rejects.toThrow(`Item '${NON_EXISTENT_ID}' not found`); |
||||
}); |
||||
|
||||
it('should return only expired items when filtering by expiration', async () => { |
||||
// Given
|
||||
const expiredItem = createExpiredItem(); |
||||
const validItem = createValidItem(); |
||||
|
||||
await repository.save(expiredItem); |
||||
await repository.save(validItem); |
||||
|
||||
// When
|
||||
const now = new Date(MOCKED_NOW); |
||||
const specification = new SimpleSpecification<ItemEntity>( |
||||
Spec.lte('expirationDate', now.toISOString()) |
||||
); |
||||
|
||||
const expiredItems = await repository.findWhere(specification); |
||||
|
||||
// Then
|
||||
expect(expiredItems).toHaveLength(1); |
||||
expect(expiredItems[0].getId().getValue()).toBe(expiredItem.getId().getValue()); |
||||
}); |
||||
|
||||
it('should return only user items when filtering by user id', async () => { |
||||
// Given
|
||||
const item1 = createTestItem1(); |
||||
const item2 = createTestItem2(); |
||||
const item3 = createTestItem3(); |
||||
|
||||
await repository.save(item1); |
||||
await repository.save(item2); |
||||
await repository.save(item3); |
||||
|
||||
// When
|
||||
const specification = new SimpleSpecification<ItemEntity>( |
||||
Spec.eq('userId', USER_ID_1) |
||||
); |
||||
|
||||
const userItems = await repository.findWhere(specification); |
||||
|
||||
// Then
|
||||
expect(userItems).toHaveLength(2); |
||||
const itemIds = userItems.map(i => i.getId().getValue()); |
||||
expect(itemIds).toContain(ITEM_ID_1); |
||||
expect(itemIds).toContain(ITEM_ID_3); |
||||
}); |
||||
|
||||
it('should return only matching items when using complex filter', async () => { |
||||
// Given
|
||||
const item1 = createExpiredItemForUser1(); |
||||
const item2 = createValidItemForUser1(); |
||||
const item3 = createExpiredItemForUser2(); |
||||
|
||||
await repository.save(item1); |
||||
await repository.save(item2); |
||||
await repository.save(item3); |
||||
|
||||
// When
|
||||
const now = new Date(MOCKED_NOW); |
||||
const specification = new SimpleSpecification<ItemEntity>( |
||||
Spec.and([ |
||||
Spec.eq('userId', USER_ID_1), |
||||
Spec.lte('expirationDate', now.toISOString()) |
||||
]) |
||||
); |
||||
|
||||
const filteredItems = await repository.findWhere(specification); |
||||
|
||||
// Then
|
||||
expect(filteredItems).toHaveLength(1); |
||||
expect(filteredItems[0].getId().getValue()).toBe(item1.getId().getValue()); |
||||
}); |
||||
|
||||
it('should return true when checking existence of existing item', async () => { |
||||
// Given
|
||||
const item = createTestItem1(); |
||||
await repository.save(item); |
||||
|
||||
// When
|
||||
const exists = await repository.exists(ItemId.create(ITEM_ID_1)); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when checking existence of non-existent item', async () => { |
||||
// When
|
||||
const exists = await repository.exists(ItemId.create(NON_EXISTENT_ID)); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(false); |
||||
}); |
||||
|
||||
it('should share the same underlying file between different repository instances', async () => { |
||||
// Given
|
||||
const repository1 = new FileItemRepository(testStoragePath); |
||||
const repository2 = new FileItemRepository(testStoragePath); |
||||
const item = createTestItem1(); |
||||
|
||||
// When
|
||||
await repository1.save(item); |
||||
const foundItem = await repository2.findById(ItemId.create(ITEM_ID_1)); |
||||
|
||||
// Then
|
||||
expect(foundItem).not.toBeNull(); |
||||
expect(foundItem?.getId().getValue()).toBe(item.getId().getValue()); |
||||
expect(foundItem?.getName()).toBe(item.getName()); |
||||
expect(foundItem?.getOrderUrl()).toBe(item.getOrderUrl()); |
||||
expect(foundItem?.getUserId().getValue()).toBe(item.getUserId().getValue()); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,253 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'; |
||||
import { FileUserRepository } from '../file-user-repository'; |
||||
import { UserEntity } from '../../../domain/entities/user.entity'; |
||||
import { UserId } from '../../../domain/value-objects/user-id.vo'; |
||||
import { existsSync, unlinkSync, mkdirSync, rmdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
|
||||
describe('FileUserRepository', () => { |
||||
let repository: FileUserRepository; |
||||
let testStoragePath: string; |
||||
|
||||
// Test constants - using valid UUIDs
|
||||
const USER_ID_1 = '10000000-0000-0000-0000-000000000001'; |
||||
const USER_ID_2 = '10000000-0000-0000-0000-000000000002'; |
||||
const NON_EXISTENT_ID = '10000000-0000-0000-0000-000000000099'; |
||||
const USERNAME_1 = 'testuser1'; |
||||
const USERNAME_2 = 'testuser2'; |
||||
const PASSWORD_HASH_1 = 'hashedpassword123'; |
||||
const PASSWORD_HASH_2 = 'hashedpassword456'; |
||||
|
||||
beforeEach(async () => { |
||||
testStoragePath = join(__dirname, '../../../test-data'); |
||||
|
||||
// Create test directory if it doesn't exist
|
||||
if (!existsSync(testStoragePath)) { |
||||
mkdirSync(testStoragePath, { recursive: true }); |
||||
} |
||||
|
||||
const module: TestingModule = await Test.createTestingModule({ |
||||
providers: [ |
||||
{ |
||||
provide: FileUserRepository, |
||||
useFactory: () => new FileUserRepository(testStoragePath), |
||||
}, |
||||
], |
||||
}).compile(); |
||||
|
||||
repository = module.get<FileUserRepository>(FileUserRepository); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
// Clean up test files
|
||||
const filePath = join(testStoragePath, 'users.json'); |
||||
if (existsSync(filePath)) { |
||||
unlinkSync(filePath); |
||||
} |
||||
|
||||
// Remove storage dir if empty
|
||||
try { |
||||
rmdirSync(testStoragePath); |
||||
} catch (e) { |
||||
// Directory not empty, ignore
|
||||
} |
||||
}); |
||||
|
||||
const createTestUser1 = (): UserEntity => { |
||||
return new UserEntity( |
||||
UserId.create(USER_ID_1), |
||||
USERNAME_1, |
||||
PASSWORD_HASH_1, |
||||
); |
||||
}; |
||||
|
||||
const createTestUser2 = (): UserEntity => { |
||||
return new UserEntity( |
||||
UserId.create(USER_ID_2), |
||||
USERNAME_2, |
||||
PASSWORD_HASH_2, |
||||
); |
||||
}; |
||||
|
||||
it('should create file when user is saved', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
|
||||
// When
|
||||
await repository.save(user); |
||||
|
||||
// Then
|
||||
const filePath = join(testStoragePath, 'users.json'); |
||||
expect(existsSync(filePath)).toBe(true); |
||||
}); |
||||
|
||||
it('should return user when finding by existing id', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
await repository.save(user); |
||||
|
||||
// When
|
||||
const foundUser = await repository.findById(UserId.create(USER_ID_1)); |
||||
|
||||
// Then
|
||||
expect(foundUser).not.toBeNull(); |
||||
expect(foundUser?.getId().getValue()).toBe(user.getId().getValue()); |
||||
expect(foundUser?.getUsername()).toBe(user.getUsername()); |
||||
expect(foundUser?.getPasswordHash()).toBe(user.getPasswordHash()); |
||||
}); |
||||
|
||||
it('should return null when finding by non-existent id', async () => { |
||||
// When
|
||||
const foundUser = await repository.findById(UserId.create(NON_EXISTENT_ID)); |
||||
|
||||
// Then
|
||||
expect(foundUser).toBeNull(); |
||||
}); |
||||
|
||||
it('should return user when finding by existing username', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
await repository.save(user); |
||||
|
||||
// When
|
||||
const foundUser = await repository.findByUsername(USERNAME_1); |
||||
|
||||
// Then
|
||||
expect(foundUser).not.toBeNull(); |
||||
expect(foundUser?.getId().getValue()).toBe(user.getId().getValue()); |
||||
expect(foundUser?.getUsername()).toBe(user.getUsername()); |
||||
expect(foundUser?.getPasswordHash()).toBe(user.getPasswordHash()); |
||||
}); |
||||
|
||||
it('should return null when finding by non-existent username', async () => { |
||||
// When
|
||||
const foundUser = await repository.findByUsername('nonexistent'); |
||||
|
||||
// Then
|
||||
expect(foundUser).toBeNull(); |
||||
}); |
||||
|
||||
it('should throw exception when saving user with duplicate username', async () => { |
||||
// Given
|
||||
const user1 = createTestUser1(); |
||||
const user2 = new UserEntity( |
||||
UserId.create(USER_ID_2), |
||||
USERNAME_1, // Same username as user1
|
||||
PASSWORD_HASH_2, |
||||
); |
||||
|
||||
await repository.save(user1); |
||||
|
||||
// When & Then
|
||||
await expect(repository.save(user2)) |
||||
.rejects.toThrow(`Username '${USERNAME_1}' is already taken`); |
||||
}); |
||||
|
||||
it('should allow saving user with same username if it\'s the same user', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
await repository.save(user); |
||||
|
||||
// When - saving the same user again (updating)
|
||||
await repository.save(user); |
||||
|
||||
// Then - should not throw an exception
|
||||
const foundUser = await repository.findById(UserId.create(USER_ID_1)); |
||||
expect(foundUser).not.toBeNull(); |
||||
expect(foundUser?.getUsername()).toBe(USERNAME_1); |
||||
}); |
||||
|
||||
it('should return true when checking existence of existing user by id', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
await repository.save(user); |
||||
|
||||
// When
|
||||
const exists = await repository.exists(UserId.create(USER_ID_1)); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when checking existence of non-existent user by id', async () => { |
||||
// When
|
||||
const exists = await repository.exists(UserId.create(NON_EXISTENT_ID)); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when checking existence of existing user by username', async () => { |
||||
// Given
|
||||
const user = createTestUser1(); |
||||
await repository.save(user); |
||||
|
||||
// When
|
||||
const exists = await repository.existsByUsername(USERNAME_1); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when checking existence of non-existent user by username', async () => { |
||||
// When
|
||||
const exists = await repository.existsByUsername('nonexistent'); |
||||
|
||||
// Then
|
||||
expect(exists).toBe(false); |
||||
}); |
||||
|
||||
it('should share the same underlying file between different repository instances', async () => { |
||||
// Given
|
||||
const repository1 = new FileUserRepository(testStoragePath); |
||||
const repository2 = new FileUserRepository(testStoragePath); |
||||
const user = createTestUser1(); |
||||
|
||||
// When
|
||||
await repository1.save(user); |
||||
const foundUser = await repository2.findById(UserId.create(USER_ID_1)); |
||||
|
||||
// Then
|
||||
expect(foundUser).not.toBeNull(); |
||||
expect(foundUser?.getId().getValue()).toBe(user.getId().getValue()); |
||||
expect(foundUser?.getUsername()).toBe(user.getUsername()); |
||||
expect(foundUser?.getPasswordHash()).toBe(user.getPasswordHash()); |
||||
}); |
||||
|
||||
it('should handle multiple users correctly', async () => { |
||||
// Given
|
||||
const user1 = createTestUser1(); |
||||
const user2 = createTestUser2(); |
||||
|
||||
// When
|
||||
await repository.save(user1); |
||||
await repository.save(user2); |
||||
|
||||
// Then
|
||||
const foundUser1 = await repository.findById(UserId.create(USER_ID_1)); |
||||
const foundUser2 = await repository.findById(UserId.create(USER_ID_2)); |
||||
|
||||
expect(foundUser1).not.toBeNull(); |
||||
expect(foundUser2).not.toBeNull(); |
||||
expect(foundUser1?.getUsername()).toBe(USERNAME_1); |
||||
expect(foundUser2?.getUsername()).toBe(USERNAME_2); |
||||
}); |
||||
|
||||
it('should find correct user by username when multiple users exist', async () => { |
||||
// Given
|
||||
const user1 = createTestUser1(); |
||||
const user2 = createTestUser2(); |
||||
|
||||
await repository.save(user1); |
||||
await repository.save(user2); |
||||
|
||||
// When
|
||||
const foundUser = await repository.findByUsername(USERNAME_2); |
||||
|
||||
// Then
|
||||
expect(foundUser).not.toBeNull(); |
||||
expect(foundUser?.getId().getValue()).toBe(USER_ID_2); |
||||
expect(foundUser?.getUsername()).toBe(USERNAME_2); |
||||
expect(foundUser?.getPasswordHash()).toBe(PASSWORD_HASH_2); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,154 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'; |
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
import { IItemRepository } from '../../application/interfaces/item-repository.interface'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { ExpirationDate } from '../../domain/value-objects/expiration-date.vo'; |
||||
import { ISpecification } from '../../domain/specifications/specification.interface'; |
||||
|
||||
@Injectable() |
||||
export class FileItemRepository implements IItemRepository { |
||||
private readonly logger = new Logger(FileItemRepository.name); |
||||
private readonly filePath: string; |
||||
|
||||
constructor(storagePath: string = './data') { |
||||
// Ensure the storage directory exists
|
||||
if (!existsSync(storagePath)) { |
||||
mkdirSync(storagePath, { recursive: true }); |
||||
} |
||||
this.filePath = join(storagePath, 'items.json'); |
||||
|
||||
// Initialize the file if it doesn't exist
|
||||
if (!existsSync(this.filePath)) { |
||||
writeFileSync(this.filePath, JSON.stringify({})); |
||||
} |
||||
} |
||||
|
||||
private readItems(): Record<string, any> { |
||||
try { |
||||
const fileContent = readFileSync(this.filePath, 'utf-8'); |
||||
return JSON.parse(fileContent); |
||||
} catch (error) { |
||||
this.logger.error(`Error reading items file: ${error.message}`); |
||||
return {}; |
||||
} |
||||
} |
||||
|
||||
private writeItems(items: Record<string, any>): void { |
||||
try { |
||||
writeFileSync(this.filePath, JSON.stringify(items, null, 2)); |
||||
} catch (error) { |
||||
this.logger.error(`Error writing items file: ${error.message}`); |
||||
throw new Error(`Failed to save items: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
private entityToPlain(item: ItemEntity): any { |
||||
return { |
||||
id: item.getId().getValue(), |
||||
name: item.getName(), |
||||
expirationDate: item.getExpirationDate().toISOString(), |
||||
orderUrl: item.getOrderUrl(), |
||||
userId: item.getUserId().getValue(), |
||||
createdAt: item.getCreatedAt().toISOString(), |
||||
}; |
||||
} |
||||
|
||||
private plainToEntity(itemData: any): ItemEntity { |
||||
return new ItemEntity( |
||||
ItemId.create(itemData.id), |
||||
itemData.name, |
||||
ExpirationDate.fromString(itemData.expirationDate), |
||||
itemData.orderUrl, |
||||
UserId.create(itemData.userId), |
||||
); |
||||
} |
||||
|
||||
async save(item: ItemEntity): Promise<void> { |
||||
const items = this.readItems(); |
||||
const itemId = item.getId().getValue(); |
||||
|
||||
items[itemId] = this.entityToPlain(item); |
||||
this.writeItems(items); |
||||
|
||||
this.logger.log(`Item ${itemId} saved successfully`); |
||||
} |
||||
|
||||
async findById(id: ItemId): Promise<ItemEntity | null> { |
||||
const items = this.readItems(); |
||||
const itemId = id.getValue(); |
||||
|
||||
if (!items[itemId]) { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
return this.plainToEntity(items[itemId]); |
||||
} catch (error) { |
||||
this.logger.error(`Error parsing item ${itemId}: ${error.message}`); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
async findByUserId(userId: UserId): Promise<ItemEntity[]> { |
||||
const items = this.readItems(); |
||||
const userIdValue = userId.getValue(); |
||||
const userItems: ItemEntity[] = []; |
||||
|
||||
for (const itemId in items) { |
||||
try { |
||||
if (items[itemId].userId === userIdValue) { |
||||
userItems.push(this.plainToEntity(items[itemId])); |
||||
} |
||||
} catch (error) { |
||||
this.logger.error(`Error parsing item ${itemId}: ${error.message}`); |
||||
// Skip invalid items
|
||||
} |
||||
} |
||||
|
||||
return userItems; |
||||
} |
||||
|
||||
async findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]> { |
||||
const items = this.readItems(); |
||||
const matchingItems: ItemEntity[] = []; |
||||
|
||||
for (const itemId in items) { |
||||
try { |
||||
const item = this.plainToEntity(items[itemId]); |
||||
|
||||
if (specification.isSatisfiedBy(item)) { |
||||
matchingItems.push(item); |
||||
} |
||||
} catch (error) { |
||||
this.logger.error(`Error parsing item ${itemId}: ${error.message}`); |
||||
// Skip invalid items
|
||||
} |
||||
} |
||||
|
||||
return matchingItems; |
||||
} |
||||
|
||||
async delete(id: ItemId): Promise<void> { |
||||
const items = this.readItems(); |
||||
const itemId = id.getValue(); |
||||
|
||||
if (!items[itemId]) { |
||||
throw new Error(`Item '${itemId}' not found`); |
||||
} |
||||
|
||||
delete items[itemId]; |
||||
this.writeItems(items); |
||||
|
||||
this.logger.log(`Item ${itemId} deleted successfully`); |
||||
} |
||||
|
||||
async exists(id: ItemId): Promise<boolean> { |
||||
const items = this.readItems(); |
||||
const itemId = id.getValue(); |
||||
|
||||
return !!items[itemId]; |
||||
} |
||||
} |
||||
@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'; |
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
import { IUserRepository } from '../../application/interfaces/user-repository.interface'; |
||||
import { UserEntity } from '../../domain/entities/user.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
|
||||
@Injectable() |
||||
export class FileUserRepository implements IUserRepository { |
||||
private readonly logger = new Logger(FileUserRepository.name); |
||||
private readonly filePath: string; |
||||
|
||||
constructor(storagePath: string = './data') { |
||||
// Ensure the storage directory exists
|
||||
if (!existsSync(storagePath)) { |
||||
mkdirSync(storagePath, { recursive: true }); |
||||
} |
||||
this.filePath = join(storagePath, 'users.json'); |
||||
|
||||
// Initialize the file if it doesn't exist
|
||||
if (!existsSync(this.filePath)) { |
||||
writeFileSync(this.filePath, JSON.stringify({})); |
||||
} |
||||
} |
||||
|
||||
private readUsers(): Record<string, any> { |
||||
try { |
||||
const fileContent = readFileSync(this.filePath, 'utf-8'); |
||||
return JSON.parse(fileContent); |
||||
} catch (error) { |
||||
this.logger.error(`Error reading users file: ${error.message}`); |
||||
return {}; |
||||
} |
||||
} |
||||
|
||||
private writeUsers(users: Record<string, any>): void { |
||||
try { |
||||
writeFileSync(this.filePath, JSON.stringify(users, null, 2)); |
||||
} catch (error) { |
||||
this.logger.error(`Error writing users file: ${error.message}`); |
||||
throw new Error(`Failed to save users: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
private entityToPlain(user: UserEntity): any { |
||||
return { |
||||
id: user.getId().getValue(), |
||||
username: user.getUsername(), |
||||
passwordHash: user.getPasswordHash(), |
||||
createdAt: user.getCreatedAt().toISOString(), |
||||
}; |
||||
} |
||||
|
||||
private plainToEntity(userData: any): UserEntity { |
||||
return new UserEntity( |
||||
UserId.create(userData.id), |
||||
userData.username, |
||||
userData.passwordHash, |
||||
); |
||||
} |
||||
|
||||
async save(user: UserEntity): Promise<void> { |
||||
const users = this.readUsers(); |
||||
const userId = user.getId().getValue(); |
||||
const username = user.getUsername(); |
||||
|
||||
// Check if username already exists for a different user
|
||||
for (const existingUserId in users) { |
||||
if (existingUserId !== userId && users[existingUserId].username === username) { |
||||
throw new Error(`Username '${username}' is already taken`); |
||||
} |
||||
} |
||||
|
||||
users[userId] = this.entityToPlain(user); |
||||
this.writeUsers(users); |
||||
|
||||
this.logger.log(`User ${userId} saved successfully`); |
||||
} |
||||
|
||||
async findById(id: UserId): Promise<UserEntity | null> { |
||||
const users = this.readUsers(); |
||||
const userId = id.getValue(); |
||||
|
||||
if (!users[userId]) { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
return this.plainToEntity(users[userId]); |
||||
} catch (error) { |
||||
this.logger.error(`Error parsing user ${userId}: ${error.message}`); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
async findByUsername(username: string): Promise<UserEntity | null> { |
||||
const users = this.readUsers(); |
||||
|
||||
for (const userId in users) { |
||||
try { |
||||
if (users[userId].username === username) { |
||||
return this.plainToEntity(users[userId]); |
||||
} |
||||
} catch (error) { |
||||
this.logger.error(`Error parsing user ${userId}: ${error.message}`); |
||||
// Skip invalid users
|
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
async exists(id: UserId): Promise<boolean> { |
||||
const users = this.readUsers(); |
||||
const userId = id.getValue(); |
||||
|
||||
return !!users[userId]; |
||||
} |
||||
|
||||
async existsByUsername(username: string): Promise<boolean> { |
||||
const users = this.readUsers(); |
||||
|
||||
for (const userId in users) { |
||||
if (users[userId].username === username) { |
||||
return true; |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
import { ITimeProvider } from '../../application/interfaces/time-provider.interface'; |
||||
|
||||
@Injectable() |
||||
export class SystemTimeProvider implements ITimeProvider { |
||||
now(): Date { |
||||
return new Date(); |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger, OnModuleInit, Inject } from '@nestjs/common'; |
||||
import * as bcrypt from 'bcrypt'; |
||||
import { IUserRepository } from '../../application/interfaces/user-repository.interface'; |
||||
import { UserEntity } from '../../domain/entities/user.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
|
||||
@Injectable() |
||||
export class UserInitializationService implements OnModuleInit { |
||||
private readonly logger = new Logger(UserInitializationService.name); |
||||
|
||||
constructor( |
||||
@Inject('IUserRepository') |
||||
private readonly userRepository: IUserRepository, |
||||
) {} |
||||
|
||||
async onModuleInit(): Promise<void> { |
||||
await this.createDefaultUsers(); |
||||
} |
||||
|
||||
async createDefaultUsers(): Promise<void> { |
||||
const defaultUsers = [ |
||||
{ username: 'admin', password: 'admin' }, |
||||
{ username: 'user', password: 'user' } |
||||
]; |
||||
|
||||
for (const userData of defaultUsers) { |
||||
const userExists = await this.userRepository.existsByUsername(userData.username); |
||||
|
||||
if (!userExists) { |
||||
const passwordHash = await bcrypt.hash(userData.password, 10); |
||||
const user = new UserEntity( |
||||
UserId.generate(), |
||||
userData.username, |
||||
passwordHash, |
||||
); |
||||
|
||||
await this.userRepository.save(user); |
||||
this.logger.log(`Created default user: ${userData.username}`); |
||||
} else { |
||||
this.logger.log(`Default user already exists: ${userData.username}`); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -1,8 +1,27 @@
|
||||
import { NestFactory } from '@nestjs/core'; |
||||
import { ValidationPipe } from '@nestjs/common'; |
||||
import { AppModule } from './app.module'; |
||||
|
||||
async function bootstrap() { |
||||
const app = await NestFactory.create(AppModule); |
||||
|
||||
// Enable CORS
|
||||
app.enableCors(); |
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(new ValidationPipe({ |
||||
whitelist: true, |
||||
forbidNonWhitelisted: true, |
||||
transform: true, |
||||
transformOptions: { |
||||
enableImplicitConversion: true, |
||||
}, |
||||
})); |
||||
|
||||
// API versioning
|
||||
app.setGlobalPrefix('api/v1'); |
||||
|
||||
await app.listen(3000); |
||||
console.log('Application is running on: http://localhost:3000/api/v1'); |
||||
} |
||||
bootstrap(); |
||||
@ -0,0 +1,39 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; |
||||
import { LoginDto, LoginResponseDto } from '../../application/dto/login.dto'; |
||||
import { JSendResponseUtil } from '../../common/utils/jsend-response.util'; |
||||
import { LoginUserCommand } from '../../application/commands/login-user.command'; |
||||
|
||||
@Controller() |
||||
export class AuthController { |
||||
constructor( |
||||
private readonly loginUserCommand: LoginUserCommand, |
||||
) {} |
||||
|
||||
@Post('login') |
||||
@HttpCode(HttpStatus.OK) |
||||
async login(@Body() loginDto: LoginDto): Promise<any> { |
||||
try { |
||||
const token = await this.loginUserCommand.execute(loginDto.username, loginDto.password); |
||||
|
||||
const response: LoginResponseDto = { |
||||
token, |
||||
tokenType: 'Bearer', |
||||
expiresIn: 3600, // 1 hour in seconds
|
||||
}; |
||||
|
||||
return JSendResponseUtil.success(response); |
||||
} catch (error) { |
||||
if (error.constructor.name === 'UnauthorizedException') { |
||||
return JSendResponseUtil.error( |
||||
error.message, |
||||
HttpStatus.UNAUTHORIZED, |
||||
); |
||||
} |
||||
|
||||
return JSendResponseUtil.error( |
||||
'Authentication failed', |
||||
HttpStatus.INTERNAL_SERVER_ERROR, |
||||
); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,158 @@
|
||||
import { |
||||
Controller, |
||||
Get, |
||||
Post, |
||||
Delete, |
||||
Body, |
||||
Param, |
||||
HttpCode, |
||||
HttpStatus, |
||||
Req, |
||||
UseGuards, |
||||
HttpException, |
||||
NotFoundException, |
||||
UnauthorizedException, |
||||
} from '@nestjs/common'; |
||||
import { Request } from 'express'; |
||||
import { CreateItemDto, ItemResponseDto } from '../../application/dto/create-item.dto'; |
||||
import { JSendResponseUtil } from '../../common/utils/jsend-response.util'; |
||||
import { AddItemCommand } from '../../application/commands/add-item.command'; |
||||
import { DeleteItemCommand } from '../../application/commands/delete-item.command'; |
||||
import { GetItemQuery } from '../../application/queries/get-item.query'; |
||||
import { ListItemsQuery } from '../../application/queries/list-items.query'; |
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; |
||||
|
||||
@Controller('items') |
||||
@UseGuards(JwtAuthGuard) |
||||
export class ItemsController { |
||||
constructor( |
||||
private readonly addItemCommand: AddItemCommand, |
||||
private readonly deleteItemCommand: DeleteItemCommand, |
||||
private readonly getItemQuery: GetItemQuery, |
||||
private readonly listItemsQuery: ListItemsQuery, |
||||
) { } |
||||
|
||||
@Post() |
||||
@HttpCode(HttpStatus.CREATED) |
||||
async createItem(@Body() createItemDto: CreateItemDto, @Req() req: Request): Promise<any> { |
||||
try { |
||||
const userId = req['userId']; |
||||
|
||||
const itemId = await this.addItemCommand.execute( |
||||
createItemDto.name, |
||||
createItemDto.expirationDate, |
||||
createItemDto.orderUrl, |
||||
userId, |
||||
); |
||||
|
||||
const response: ItemResponseDto = { |
||||
id: itemId, |
||||
name: createItemDto.name, |
||||
expirationDate: createItemDto.expirationDate, |
||||
orderUrl: createItemDto.orderUrl, |
||||
userId, |
||||
createdAt: new Date().toISOString(), |
||||
}; |
||||
|
||||
return JSendResponseUtil.success(response); |
||||
} catch (error) { |
||||
return JSendResponseUtil.error( |
||||
error.message || 'Failed to create item', |
||||
HttpStatus.INTERNAL_SERVER_ERROR, |
||||
); |
||||
} |
||||
} |
||||
|
||||
@Get() |
||||
@HttpCode(HttpStatus.OK) |
||||
async listItems(@Req() req: Request): Promise<any> { |
||||
try { |
||||
const userId = req['userId']; |
||||
const items = await this.listItemsQuery.execute(userId); |
||||
|
||||
const response: ItemResponseDto[] = items.map(item => ({ |
||||
id: item.getId().getValue(), |
||||
name: item.getName(), |
||||
expirationDate: item.getExpirationDate().toISOString(), |
||||
orderUrl: item.getOrderUrl(), |
||||
userId: item.getUserId().getValue(), |
||||
createdAt: item.getCreatedAt().toISOString(), |
||||
})); |
||||
|
||||
return JSendResponseUtil.success(response); |
||||
} catch (error) { |
||||
return JSendResponseUtil.error( |
||||
error.message || 'Failed to list items', |
||||
HttpStatus.INTERNAL_SERVER_ERROR, |
||||
); |
||||
} |
||||
} |
||||
@Get(':id') |
||||
async getItem(@Param('id') id: string, @Req() req: Request): Promise<any> { |
||||
try { |
||||
const userId = req['userId']; |
||||
const item = await this.getItemQuery.execute(id, userId); |
||||
|
||||
const response: ItemResponseDto = { |
||||
id: item.getId().getValue(), |
||||
name: item.getName(), |
||||
expirationDate: item.getExpirationDate().toISOString(), |
||||
orderUrl: item.getOrderUrl(), |
||||
userId: item.getUserId().getValue(), |
||||
createdAt: item.getCreatedAt().toISOString(), |
||||
}; |
||||
|
||||
return JSendResponseUtil.success(response); |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException) { |
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message, HttpStatus.NOT_FOUND), |
||||
HttpStatus.NOT_FOUND |
||||
); |
||||
} |
||||
|
||||
if (error instanceof UnauthorizedException) { |
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message, HttpStatus.UNAUTHORIZED), |
||||
HttpStatus.UNAUTHORIZED |
||||
); |
||||
} |
||||
|
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message || 'Failed to get item', HttpStatus.INTERNAL_SERVER_ERROR), |
||||
HttpStatus.INTERNAL_SERVER_ERROR |
||||
); |
||||
} |
||||
} |
||||
|
||||
|
||||
@Delete(':id') |
||||
@HttpCode(HttpStatus.NO_CONTENT) |
||||
async deleteItem(@Param('id') id: string, @Req() req: Request): Promise<any> { |
||||
try { |
||||
const userId = req['userId']; |
||||
await this.deleteItemCommand.execute(id, userId); |
||||
|
||||
return JSendResponseUtil.success(null); |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException) { |
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message, HttpStatus.NOT_FOUND), |
||||
HttpStatus.NOT_FOUND |
||||
); |
||||
} |
||||
|
||||
if (error instanceof UnauthorizedException) { |
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message, HttpStatus.UNAUTHORIZED), |
||||
HttpStatus.UNAUTHORIZED |
||||
); |
||||
} |
||||
|
||||
throw new HttpException( |
||||
JSendResponseUtil.error(error.message || 'Failed to delete item', HttpStatus.INTERNAL_SERVER_ERROR), |
||||
HttpStatus.INTERNAL_SERVER_ERROR |
||||
); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,57 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { Request } from 'express'; |
||||
import { IAuthService } from '../../application/interfaces/auth-service.interface'; |
||||
|
||||
@Injectable() |
||||
export class JwtAuthGuard implements CanActivate { |
||||
constructor( |
||||
@Inject('IAuthService') |
||||
private readonly authService: IAuthService, |
||||
) {} |
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> { |
||||
const request = context.switchToHttp().getRequest<Request>(); |
||||
const token = this.extractTokenFromHeader(request); |
||||
|
||||
if (!token) { |
||||
throw new UnauthorizedException('JWT token is missing'); |
||||
} |
||||
|
||||
try { |
||||
const isValid = await this.authService.validateToken(token); |
||||
|
||||
if (!isValid) { |
||||
throw new UnauthorizedException('Invalid JWT token'); |
||||
} |
||||
|
||||
const userId = await this.authService.getUserIdFromToken(token); |
||||
|
||||
if (!userId) { |
||||
throw new UnauthorizedException('Unable to extract user ID from token'); |
||||
} |
||||
|
||||
// Attach user ID to request for use in controllers
|
||||
request['userId'] = userId; |
||||
|
||||
return true; |
||||
} catch (error) { |
||||
if (error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
throw new UnauthorizedException('Authentication failed'); |
||||
} |
||||
} |
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined { |
||||
const authHeader = request.headers.authorization; |
||||
|
||||
if (!authHeader) { |
||||
return undefined; |
||||
} |
||||
|
||||
const [type, token] = authHeader.split(' ') ?? []; |
||||
|
||||
return type === 'Bearer' ? token : undefined; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue