Browse Source

Initial TypeScript implementation and tests with NestJS

ts-nestjs
chodak166 3 months ago
parent
commit
800fb66a65
  1. 2
      nestjs/.devcontainer/devcontainer.json
  2. 1207
      nestjs/PLAN-DDD.md
  3. 353
      nestjs/PLAN.md
  4. 1898
      nestjs/package-lock.json
  5. 15
      nestjs/package.json
  6. 12
      nestjs/src/app.controller.ts
  7. 91
      nestjs/src/app.module.ts
  8. 167
      nestjs/src/application/commands/__tests__/add-item.command.spec.ts
  9. 108
      nestjs/src/application/commands/add-item.command.ts
  10. 46
      nestjs/src/application/commands/delete-item.command.ts
  11. 53
      nestjs/src/application/commands/handle-expired-items.command.ts
  12. 48
      nestjs/src/application/commands/login-user.command.ts
  13. 24
      nestjs/src/application/dto/create-item.dto.ts
  14. 17
      nestjs/src/application/dto/login.dto.ts
  15. 5
      nestjs/src/application/interfaces/auth-service.interface.ts
  16. 13
      nestjs/src/application/interfaces/item-repository.interface.ts
  17. 5
      nestjs/src/application/interfaces/order-service.interface.ts
  18. 3
      nestjs/src/application/interfaces/time-provider.interface.ts
  19. 10
      nestjs/src/application/interfaces/user-repository.interface.ts
  20. 49
      nestjs/src/application/queries/get-item.query.ts
  21. 29
      nestjs/src/application/queries/list-items.query.ts
  22. 43
      nestjs/src/common/utils/jsend-response.util.ts
  23. 75
      nestjs/src/domain/entities/item.entity.ts
  24. 62
      nestjs/src/domain/entities/user.entity.ts
  25. 159
      nestjs/src/domain/specifications/__tests__/item-expiration.spec.spec.ts
  26. 580
      nestjs/src/domain/specifications/__tests__/spec.helper.spec.ts
  27. 14
      nestjs/src/domain/specifications/item-expiration.spec.ts
  28. 192
      nestjs/src/domain/specifications/spec.helper.ts
  29. 75
      nestjs/src/domain/specifications/specification.interface.ts
  30. 147
      nestjs/src/domain/value-objects/__tests__/base-uuid-value-object.ts
  31. 149
      nestjs/src/domain/value-objects/__tests__/expiration-date.vo.spec.ts
  32. 37
      nestjs/src/domain/value-objects/__tests__/item-id.vo.spec.ts
  33. 52
      nestjs/src/domain/value-objects/__tests__/user-id.vo.spec.ts
  34. 55
      nestjs/src/domain/value-objects/expiration-date.vo.ts
  35. 42
      nestjs/src/domain/value-objects/item-id.vo.ts
  36. 42
      nestjs/src/domain/value-objects/user-id.vo.ts
  37. 83
      nestjs/src/infrastructure/auth/jwt-auth.service.ts
  38. 56
      nestjs/src/infrastructure/http/order-http.service.ts
  39. 349
      nestjs/src/infrastructure/repositories/__tests__/file-item-repository.spec.ts
  40. 253
      nestjs/src/infrastructure/repositories/__tests__/file-user-repository.spec.ts
  41. 154
      nestjs/src/infrastructure/repositories/file-item-repository.ts
  42. 131
      nestjs/src/infrastructure/repositories/file-user-repository.ts
  43. 9
      nestjs/src/infrastructure/services/system-time.provider.ts
  44. 44
      nestjs/src/infrastructure/services/user-initialization.service.ts
  45. 19
      nestjs/src/main.ts
  46. 39
      nestjs/src/presentation/controllers/auth.controller.ts
  47. 158
      nestjs/src/presentation/controllers/items.controller.ts
  48. 57
      nestjs/src/presentation/guards/jwt-auth.guard.ts
  49. 2
      testing/tavern/test_plans/items_api.tavern.yaml

2
nestjs/.devcontainer/devcontainer.json

@ -22,5 +22,5 @@
}, },
"forwardPorts": [3000], "forwardPorts": [3000],
"remoteUser": "developer", "remoteUser": "developer",
"postCreateCommand": "sudo chown -R developer:developer /usr/src/app && npm install" "postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && npm install"
} }

1207
nestjs/PLAN-DDD.md

File diff suppressed because it is too large Load Diff

353
nestjs/PLAN.md

@ -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.

1898
nestjs/package-lock.json generated

File diff suppressed because it is too large Load Diff

15
nestjs/package.json

@ -20,14 +20,25 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@types/bcrypt": "^6.0.0",
"axios": "^1.12.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^11.0.10",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
@ -57,7 +68,7 @@
"ts" "ts"
], ],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*__tests__.*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },

12
nestjs/src/app.controller.ts

@ -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();
}
}

91
nestjs/src/app.module.ts

@ -1,10 +1,93 @@
import { Module } from '@nestjs/common'; 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 { 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({ @Module({
imports: [], imports: [
controllers: [AppController], ConfigModule.forRoot({
providers: [AppService], 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 {} export class AppModule {}

167
nestjs/src/application/commands/__tests__/add-item.command.spec.ts

@ -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');
});
});
});
});

108
nestjs/src/application/commands/add-item.command.ts

@ -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');
}
}
}

46
nestjs/src/application/commands/delete-item.command.ts

@ -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}`);
}
}
}

53
nestjs/src/application/commands/handle-expired-items.command.ts

@ -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}`);
}
}
}

48
nestjs/src/application/commands/login-user.command.ts

@ -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');
}
}
}

24
nestjs/src/application/dto/create-item.dto.ts

@ -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;
}

17
nestjs/src/application/dto/login.dto.ts

@ -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;
}

5
nestjs/src/application/interfaces/auth-service.interface.ts

@ -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>;
}

13
nestjs/src/application/interfaces/item-repository.interface.ts

@ -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>;
}

5
nestjs/src/application/interfaces/order-service.interface.ts

@ -0,0 +1,5 @@
import { ItemEntity } from '../../domain/entities/item.entity';
export interface IOrderService {
orderItem(item: ItemEntity): Promise<void>;
}

3
nestjs/src/application/interfaces/time-provider.interface.ts

@ -0,0 +1,3 @@
export interface ITimeProvider {
now(): Date;
}

10
nestjs/src/application/interfaces/user-repository.interface.ts

@ -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>;
}

49
nestjs/src/application/queries/get-item.query.ts

@ -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}`);
}
}
}

29
nestjs/src/application/queries/list-items.query.ts

@ -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}`);
}
}
}

43
nestjs/src/common/utils/jsend-response.util.ts

@ -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,
};
}
}

75
nestjs/src/domain/entities/item.entity.ts

@ -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;
}
}

62
nestjs/src/domain/entities/user.entity.ts

@ -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;
}
}

159
nestjs/src/domain/specifications/__tests__/item-expiration.spec.spec.ts

@ -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);
});
});
});

580
nestjs/src/domain/specifications/__tests__/spec.helper.spec.ts

@ -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));
});
});
});

14
nestjs/src/domain/specifications/item-expiration.spec.ts

@ -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())
);
}
}

192
nestjs/src/domain/specifications/spec.helper.ts

@ -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);
}
}

75
nestjs/src/domain/specifications/specification.interface.ts

@ -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(),
};
}
}

147
nestjs/src/domain/value-objects/__tests__/base-uuid-value-object.ts

@ -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);
});
});
});
}
}

149
nestjs/src/domain/value-objects/__tests__/expiration-date.vo.spec.ts

@ -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);
});
});
});

37
nestjs/src/domain/value-objects/__tests__/item-id.vo.spec.ts

@ -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();

52
nestjs/src/domain/value-objects/__tests__/user-id.vo.spec.ts

@ -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);
});
});
});

55
nestjs/src/domain/value-objects/expiration-date.vo.ts

@ -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();
}
}

42
nestjs/src/domain/value-objects/item-id.vo.ts

@ -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;
}
}

42
nestjs/src/domain/value-objects/user-id.vo.ts

@ -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;
}
}

83
nestjs/src/infrastructure/auth/jwt-auth.service.ts

@ -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);
}
}

56
nestjs/src/infrastructure/http/order-http.service.ts

@ -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}`);
}
}
}

349
nestjs/src/infrastructure/repositories/__tests__/file-item-repository.spec.ts

@ -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());
});
});

253
nestjs/src/infrastructure/repositories/__tests__/file-user-repository.spec.ts

@ -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);
});
});

154
nestjs/src/infrastructure/repositories/file-item-repository.ts

@ -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];
}
}

131
nestjs/src/infrastructure/repositories/file-user-repository.ts

@ -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;
}
}

9
nestjs/src/infrastructure/services/system-time.provider.ts

@ -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();
}
}

44
nestjs/src/infrastructure/services/user-initialization.service.ts

@ -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}`);
}
}
}
}

19
nestjs/src/main.ts

@ -1,8 +1,27 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); 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); await app.listen(3000);
console.log('Application is running on: http://localhost:3000/api/v1');
} }
bootstrap(); bootstrap();

39
nestjs/src/presentation/controllers/auth.controller.ts

@ -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,
);
}
}
}

158
nestjs/src/presentation/controllers/items.controller.ts

@ -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
);
}
}
}

57
nestjs/src/presentation/guards/jwt-auth.guard.ts

@ -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;
}
}

2
testing/tavern/test_plans/items_api.tavern.yaml

@ -110,7 +110,7 @@ stages:
- name: "Get single non-existing item" - name: "Get single non-existing item"
request: request:
url: "http://{server_address}:{server_port}/{api_base}/items/9999" url: "http://{server_address}:{server_port}/{api_base}/items/00000000-0000-0000-0000-000000000000"
method: GET method: GET
headers: headers:
Authorization: "Bearer {user_token}" Authorization: "Bearer {user_token}"

Loading…
Cancel
Save