Add integration tests for blog workflow and supporting infrastructure
- Implemented comprehensive integration tests for user management, category management, content publishing, comment system, performance, scalability, and network resilience. - Created DockerNodeManager to manage Docker containers for testing. - Developed ApiClient for API interactions and health checks. - Introduced SyncWaiter for synchronizing node states and ensuring data consistency. - Enhanced test reliability with increased timeouts and detailed logging.
This commit is contained in:
parent
344a346fd6
commit
64ed9e82a7
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ network.txt
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
coverage/
|
269
docs/docs/api/network-api.md
Normal file
269
docs/docs/api/network-api.md
Normal file
@ -0,0 +1,269 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# API Reference Overview
|
||||
|
||||
The @debros/network API provides a comprehensive set of functions for building decentralized applications with familiar database operations built on OrbitDB and IPFS.
|
||||
|
||||
## Core Database Functions
|
||||
|
||||
### Primary Operations
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- | ----------------------------- |
|
||||
| `initDB(connectionId?)` | Initialize database connection | `connectionId?: string` | `Promise<string>` |
|
||||
| `create<T>(collection, id, data, options?)` | Create a new document | `collection: string, id: string, data: T, options?: CreateOptions` | `Promise<CreateResult>` |
|
||||
| `get<T>(collection, id, options?)` | Get document by ID | `collection: string, id: string, options?: GetOptions` | `Promise<T \| null>` |
|
||||
| `update<T>(collection, id, data, options?)` | Update existing document | `collection: string, id: string, data: Partial<T>, options?: UpdateOptions` | `Promise<UpdateResult>` |
|
||||
| `remove(collection, id, options?)` | Delete document | `collection: string, id: string, options?: RemoveOptions` | `Promise<boolean>` |
|
||||
| `list<T>(collection, options?)` | List documents with pagination | `collection: string, options?: ListOptions` | `Promise<PaginatedResult<T>>` |
|
||||
| `query<T>(collection, filter, options?)` | Query documents with filtering | `collection: string, filter: FilterFunction<T>, options?: QueryOptions` | `Promise<PaginatedResult<T>>` |
|
||||
| `stopDB()` | Stop database service | None | `Promise<void>` |
|
||||
|
||||
## File Operations
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ---------------------------- | ----------------------- | ----------------------------------------------------- | --------------------------- |
|
||||
| `uploadFile(data, options?)` | Upload file to IPFS | `data: Buffer \| Uint8Array, options?: UploadOptions` | `Promise<FileUploadResult>` |
|
||||
| `getFile(cid, options?)` | Retrieve file from IPFS | `cid: string, options?: FileGetOptions` | `Promise<FileResult>` |
|
||||
| `deleteFile(cid, options?)` | Delete file from IPFS | `cid: string, options?: FileDeleteOptions` | `Promise<boolean>` |
|
||||
|
||||
## Schema and Validation
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ---------------------------------- | ------------------------ | ---------------------------------------------- | ------- |
|
||||
| `defineSchema(collection, schema)` | Define validation schema | `collection: string, schema: SchemaDefinition` | `void` |
|
||||
|
||||
## Transaction System
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ---------------------------------- | ---------------------- | -------------------------- | ---------------------------- |
|
||||
| `createTransaction(connectionId?)` | Create new transaction | `connectionId?: string` | `Transaction` |
|
||||
| `commitTransaction(transaction)` | Execute transaction | `transaction: Transaction` | `Promise<TransactionResult>` |
|
||||
|
||||
## Event System
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ---------------------------- | ------------------- | ------------------------------------------- | --------------------- |
|
||||
| `subscribe(event, callback)` | Subscribe to events | `event: EventType, callback: EventCallback` | `UnsubscribeFunction` |
|
||||
|
||||
## Connection Management
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ------------------------------- | ------------------------- | ---------------------- | ------------------ |
|
||||
| `closeConnection(connectionId)` | Close specific connection | `connectionId: string` | `Promise<boolean>` |
|
||||
|
||||
## Performance and Indexing
|
||||
|
||||
| Function | Description | Parameters | Returns |
|
||||
| ------------------------------------------ | --------------------- | ----------------------------------------------------------- | ------------------ |
|
||||
| `createIndex(collection, field, options?)` | Create database index | `collection: string, field: string, options?: IndexOptions` | `Promise<boolean>` |
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### Core Types
|
||||
|
||||
```typescript
|
||||
interface CreateResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface UpdateResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
modified: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
documents: T[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface FileUploadResult {
|
||||
cid: string;
|
||||
size: number;
|
||||
filename?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface FileResult {
|
||||
data: Buffer;
|
||||
metadata?: Record<string, any>;
|
||||
size: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Options Types
|
||||
|
||||
```typescript
|
||||
interface CreateOptions {
|
||||
connectionId?: string;
|
||||
validate?: boolean;
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
interface GetOptions {
|
||||
connectionId?: string;
|
||||
includeMetadata?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateOptions {
|
||||
connectionId?: string;
|
||||
validate?: boolean;
|
||||
upsert?: boolean;
|
||||
}
|
||||
|
||||
interface ListOptions {
|
||||
connectionId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
interface QueryOptions extends ListOptions {
|
||||
includeScore?: boolean;
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
filename?: string;
|
||||
metadata?: Record<string, any>;
|
||||
connectionId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Store Types
|
||||
|
||||
```typescript
|
||||
enum StoreType {
|
||||
KEYVALUE = 'keyvalue',
|
||||
DOCSTORE = 'docstore',
|
||||
FEED = 'feed',
|
||||
EVENTLOG = 'eventlog',
|
||||
COUNTER = 'counter',
|
||||
}
|
||||
```
|
||||
|
||||
### Event Types
|
||||
|
||||
```typescript
|
||||
type EventType =
|
||||
| 'document:created'
|
||||
| 'document:updated'
|
||||
| 'document:deleted'
|
||||
| 'connection:established'
|
||||
| 'connection:lost';
|
||||
|
||||
type EventCallback = (data: EventData) => void;
|
||||
|
||||
interface EventData {
|
||||
collection: string;
|
||||
id: string;
|
||||
document?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
```typescript
|
||||
import { config } from '@debros/network';
|
||||
|
||||
// Available configuration options
|
||||
config.env.fingerprint = 'my-app-id';
|
||||
config.env.port = 9000;
|
||||
config.ipfs.blockstorePath = './blockstore';
|
||||
config.orbitdb.directory = './orbitdb';
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Types
|
||||
|
||||
```typescript
|
||||
// Network errors
|
||||
class NetworkError extends Error {
|
||||
code: 'NETWORK_ERROR';
|
||||
details: string;
|
||||
}
|
||||
|
||||
// Validation errors
|
||||
class ValidationError extends Error {
|
||||
code: 'VALIDATION_ERROR';
|
||||
field: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
// Not found errors
|
||||
class NotFoundError extends Error {
|
||||
code: 'NOT_FOUND';
|
||||
collection: string;
|
||||
id: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic CRUD Operations
|
||||
|
||||
```typescript
|
||||
import { initDB, create, get, update, remove } from '@debros/network';
|
||||
|
||||
// Initialize
|
||||
await initDB();
|
||||
|
||||
// Create
|
||||
const user = await create('users', 'user123', {
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
|
||||
// Read
|
||||
const retrieved = await get('users', 'user123');
|
||||
|
||||
// Update
|
||||
await update('users', 'user123', { email: 'newemail@example.com' });
|
||||
|
||||
// Delete
|
||||
await remove('users', 'user123');
|
||||
```
|
||||
|
||||
### File Operations
|
||||
|
||||
```typescript
|
||||
import { uploadFile, getFile } from '@debros/network';
|
||||
|
||||
// Upload file
|
||||
const fileData = Buffer.from('Hello World');
|
||||
const result = await uploadFile(fileData, { filename: 'hello.txt' });
|
||||
|
||||
// Get file
|
||||
const file = await getFile(result.cid);
|
||||
console.log(file.data.toString()); // "Hello World"
|
||||
```
|
||||
|
||||
### Transaction Example
|
||||
|
||||
```typescript
|
||||
import { createTransaction, commitTransaction } from '@debros/network';
|
||||
|
||||
const tx = createTransaction();
|
||||
tx.create('users', 'user1', { name: 'Alice' })
|
||||
.create('posts', 'post1', { title: 'Hello', authorId: 'user1' })
|
||||
.update('users', 'user1', { postCount: 1 });
|
||||
|
||||
const result = await commitTransaction(tx);
|
||||
```
|
||||
|
||||
This API reference covers all the actual functionality available in the @debros/network package. For detailed examples and guides, see the other documentation sections.
|
@ -25,10 +25,10 @@ cd my-debros-app
|
||||
npm init -y
|
||||
```
|
||||
|
||||
### 2. Install DebrosFramework
|
||||
### 2. Install @debros/network
|
||||
|
||||
```bash
|
||||
npm install debros-framework
|
||||
npm install @debros/network
|
||||
npm install --save-dev typescript @types/node
|
||||
```
|
||||
|
||||
|
140
docs/docs/updated-intro.md
Normal file
140
docs/docs/updated-intro.md
Normal file
@ -0,0 +1,140 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Welcome to @debros/network
|
||||
|
||||
**@debros/network** is a powerful Node.js library that provides a simple, database-like API over OrbitDB and IPFS, making it easy to build decentralized applications with familiar database operations.
|
||||
|
||||
## What is @debros/network?
|
||||
|
||||
@debros/network simplifies decentralized application development by providing:
|
||||
|
||||
- **Simple Database API**: Familiar CRUD operations for decentralized data
|
||||
- **Multiple Store Types**: KeyValue, Document, Feed, and Counter stores
|
||||
- **Schema Validation**: Built-in validation for data integrity
|
||||
- **Transaction Support**: Batch operations for consistency
|
||||
- **File Storage**: Built-in file upload and retrieval with IPFS
|
||||
- **Real-time Events**: Subscribe to data changes
|
||||
- **TypeScript Support**: Full TypeScript support with type safety
|
||||
- **Connection Management**: Handle multiple database connections
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🗄️ Database-like Operations
|
||||
|
||||
Perform familiar database operations on decentralized data:
|
||||
|
||||
```typescript
|
||||
import { initDB, create, get, query } from '@debros/network';
|
||||
|
||||
// Initialize the database
|
||||
await initDB();
|
||||
|
||||
// Create documents
|
||||
await create('users', 'user123', {
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
|
||||
// Query with filtering
|
||||
const activeUsers = await query('users', (user) => user.isActive === true, {
|
||||
limit: 10,
|
||||
sort: { field: 'createdAt', order: 'desc' },
|
||||
});
|
||||
```
|
||||
|
||||
### 📁 File Storage
|
||||
|
||||
Upload and manage files on IPFS:
|
||||
|
||||
```typescript
|
||||
import { uploadFile, getFile } from '@debros/network';
|
||||
|
||||
// Upload a file
|
||||
const fileData = Buffer.from('Hello World');
|
||||
const result = await uploadFile(fileData, {
|
||||
filename: 'hello.txt',
|
||||
metadata: { type: 'text' },
|
||||
});
|
||||
|
||||
// Retrieve file
|
||||
const file = await getFile(result.cid);
|
||||
```
|
||||
|
||||
### 🔄 Real-time Updates
|
||||
|
||||
Subscribe to data changes:
|
||||
|
||||
```typescript
|
||||
import { subscribe } from '@debros/network';
|
||||
|
||||
const unsubscribe = subscribe('document:created', (data) => {
|
||||
console.log(`New document created: ${data.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
### 📊 Schema Validation
|
||||
|
||||
Define schemas for data validation:
|
||||
|
||||
```typescript
|
||||
import { defineSchema } from '@debros/network';
|
||||
|
||||
defineSchema('users', {
|
||||
properties: {
|
||||
username: { type: 'string', required: true, min: 3 },
|
||||
email: { type: 'string', pattern: '^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$' },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@debros/network provides a clean abstraction layer over OrbitDB and IPFS:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Your Application │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ @debros/network API │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Database │ │ File │ │ Schema │ │
|
||||
│ │ Operations │ │ Storage │ │ Validation │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │Transaction │ │ Events │ │ Connection │ │
|
||||
│ │ System │ │ System │ │ Management │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ OrbitDB Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ IPFS Layer │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Who Should Use @debros/network?
|
||||
|
||||
@debros/network is perfect for developers who want to:
|
||||
|
||||
- Build decentralized applications with familiar database patterns
|
||||
- Store and query data in a distributed manner
|
||||
- Handle file storage on IPFS seamlessly
|
||||
- Create applications with real-time data synchronization
|
||||
- Use TypeScript for type-safe decentralized development
|
||||
- Avoid dealing with low-level OrbitDB and IPFS complexities
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ready to build your first decentralized application? Check out our [Getting Started Guide](./getting-started) to set up your development environment and start building.
|
||||
|
||||
## Community and Support
|
||||
|
||||
- 📖 [Documentation](./getting-started) - Comprehensive guides and examples
|
||||
- 💻 [GitHub Repository](https://github.com/debros/network) - Source code and issue tracking
|
||||
- 💬 [Discord Community](#) - Chat with other developers
|
||||
- 📧 [Support Email](#) - Get help from the core team
|
||||
|
||||
---
|
||||
|
||||
_@debros/network makes decentralized application development as simple as traditional database operations, while providing the benefits of distributed systems._
|
11
package.json
11
package.json
@ -25,10 +25,11 @@
|
||||
"test:unit": "jest tests/unit",
|
||||
"test:integration": "jest tests/integration",
|
||||
"test:e2e": "jest tests/e2e",
|
||||
"test:real": "jest --config jest.real.config.cjs",
|
||||
"test:real:debug": "REAL_TEST_DEBUG=true jest --config jest.real.config.cjs",
|
||||
"test:real:basic": "jest --config jest.real.config.cjs tests/real/real-integration.test.ts",
|
||||
"test:real:p2p": "jest --config jest.real.config.cjs tests/real/peer-discovery.test.ts"
|
||||
"test:blog-real": "cd tests/real-integration/blog-scenario && docker-compose -f docker/docker-compose.blog.yml up --build --abort-on-container-exit",
|
||||
"test:blog-integration": "jest tests/real-integration/blog-scenario/tests --detectOpenHandles --forceExit",
|
||||
"test:blog-build": "cd tests/real-integration/blog-scenario && docker-compose -f docker/docker-compose.blog.yml build",
|
||||
"test:blog-clean": "cd tests/real-integration/blog-scenario && docker-compose -f docker/docker-compose.blog.yml down -v --remove-orphans",
|
||||
"test:blog-runner": "ts-node tests/real-integration/blog-scenario/run-tests.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"ipfs",
|
||||
@ -76,7 +77,9 @@
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/node-fetch": "^2.6.7",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"node-fetch": "^2.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||
"@typescript-eslint/parser": "^8.29.0",
|
||||
"eslint": "^9.24.0",
|
||||
|
367
tests/real-integration/blog-scenario/README.md
Normal file
367
tests/real-integration/blog-scenario/README.md
Normal file
@ -0,0 +1,367 @@
|
||||
# Blog Scenario - Real Integration Tests
|
||||
|
||||
This directory contains comprehensive Docker-based integration tests for the DebrosFramework blog scenario. These tests validate real-world functionality including IPFS private swarm networking, cross-node data synchronization, and complete blog workflow operations.
|
||||
|
||||
## Overview
|
||||
|
||||
The blog scenario tests a complete blogging platform built on DebrosFramework, including:
|
||||
|
||||
- **User Management**: Registration, authentication, profile management
|
||||
- **Content Creation**: Categories, posts, drafts, publishing
|
||||
- **Comment System**: Comments, replies, moderation, engagement
|
||||
- **Cross-Node Sync**: Data consistency across multiple nodes
|
||||
- **Network Resilience**: Peer connections, private swarm functionality
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Blog Node 1 │ │ Blog Node 2 │ │ Blog Node 3 │
|
||||
│ Port: 3001 │◄──►│ Port: 3002 │◄──►│ Port: 3003 │
|
||||
│ IPFS: 4011 │ │ IPFS: 4012 │ │ IPFS: 4013 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┼───────────────────────┘
|
||||
│
|
||||
┌─────────────────┐
|
||||
│ Bootstrap Node │
|
||||
│ IPFS: 4001 │
|
||||
│ Private Swarm │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
blog-scenario/
|
||||
├── docker/
|
||||
│ ├── docker-compose.blog.yml # Docker orchestration
|
||||
│ ├── Dockerfile.blog-api # Blog API server image
|
||||
│ ├── Dockerfile.bootstrap # IPFS bootstrap node
|
||||
│ ├── Dockerfile.test-runner # Test execution environment
|
||||
│ ├── blog-api-server.ts # Blog API implementation
|
||||
│ ├── bootstrap-config.sh # Bootstrap node configuration
|
||||
│ └── swarm.key # Private IPFS swarm key
|
||||
├── models/
|
||||
│ ├── BlogModels.ts # User, Post, Comment, Category models
|
||||
│ └── BlogValidation.ts # Input validation and sanitization
|
||||
├── scenarios/
|
||||
│ └── BlogTestRunner.ts # Test execution utilities
|
||||
├── tests/
|
||||
│ └── blog-workflow.test.ts # Main test suite
|
||||
├── run-tests.ts # Test orchestration script
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Node.js 18+ for development
|
||||
- At least 8GB RAM (recommended for multiple nodes)
|
||||
- Available ports: 3001-3003, 4001, 4011-4013
|
||||
|
||||
### Running Tests
|
||||
|
||||
#### Option 1: Full Docker-based Test (Recommended)
|
||||
|
||||
```bash
|
||||
# Run complete integration tests
|
||||
npm run test:blog-real
|
||||
|
||||
# Or use the test runner for better control
|
||||
npm run test:blog-runner
|
||||
```
|
||||
|
||||
#### Option 2: Build and Run Manually
|
||||
|
||||
```bash
|
||||
# Build Docker images
|
||||
npm run test:blog-build
|
||||
|
||||
# Run tests
|
||||
npm run test:blog-real
|
||||
|
||||
# Clean up afterwards
|
||||
npm run test:blog-clean
|
||||
```
|
||||
|
||||
#### Option 3: Development Mode
|
||||
|
||||
```bash
|
||||
# Start services only (for debugging)
|
||||
cd tests/real-integration/blog-scenario
|
||||
docker-compose -f docker/docker-compose.blog.yml up blog-node-1 blog-node-2 blog-node-3
|
||||
|
||||
# Run tests against running services
|
||||
npm run test:blog-integration
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. User Management Workflow
|
||||
|
||||
- ✅ Cross-node user creation and synchronization
|
||||
- ✅ User profile updates across nodes
|
||||
- ✅ User authentication state management
|
||||
|
||||
### 2. Category Management
|
||||
|
||||
- ✅ Category creation and sync
|
||||
- ✅ Slug generation and uniqueness
|
||||
- ✅ Category hierarchy support
|
||||
|
||||
### 3. Content Publishing Workflow
|
||||
|
||||
- ✅ Draft post creation
|
||||
- ✅ Post publishing/unpublishing
|
||||
- ✅ Cross-node content synchronization
|
||||
- ✅ Post engagement (views, likes)
|
||||
- ✅ Content relationships (author, category)
|
||||
|
||||
### 4. Comment System
|
||||
|
||||
- ✅ Distributed comment creation
|
||||
- ✅ Nested comments (replies)
|
||||
- ✅ Comment moderation
|
||||
- ✅ Comment engagement
|
||||
|
||||
### 5. Performance & Scalability
|
||||
|
||||
- ✅ Concurrent operations across nodes
|
||||
- ✅ Data consistency under load
|
||||
- ✅ Network resilience testing
|
||||
|
||||
### 6. Network Tests
|
||||
|
||||
- ✅ Private IPFS swarm functionality
|
||||
- ✅ Peer discovery and connections
|
||||
- ✅ Data replication verification
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Each blog node exposes a REST API:
|
||||
|
||||
### Users
|
||||
|
||||
- `POST /api/users` - Create user
|
||||
- `GET /api/users/:id` - Get user by ID
|
||||
- `GET /api/users` - List users (with pagination)
|
||||
- `PUT /api/users/:id` - Update user
|
||||
- `POST /api/users/:id/login` - Record login
|
||||
|
||||
### Categories
|
||||
|
||||
- `POST /api/categories` - Create category
|
||||
- `GET /api/categories` - List categories
|
||||
- `GET /api/categories/:id` - Get category by ID
|
||||
|
||||
### Posts
|
||||
|
||||
- `POST /api/posts` - Create post
|
||||
- `GET /api/posts/:id` - Get post with relationships
|
||||
- `GET /api/posts` - List posts (with filters)
|
||||
- `PUT /api/posts/:id` - Update post
|
||||
- `POST /api/posts/:id/publish` - Publish post
|
||||
- `POST /api/posts/:id/unpublish` - Unpublish post
|
||||
- `POST /api/posts/:id/like` - Like post
|
||||
- `POST /api/posts/:id/view` - Increment views
|
||||
|
||||
### Comments
|
||||
|
||||
- `POST /api/comments` - Create comment
|
||||
- `GET /api/posts/:postId/comments` - Get post comments
|
||||
- `POST /api/comments/:id/approve` - Approve comment
|
||||
- `POST /api/comments/:id/like` - Like comment
|
||||
|
||||
### Metrics
|
||||
|
||||
- `GET /health` - Node health status
|
||||
- `GET /api/metrics/network` - Network metrics
|
||||
- `GET /api/metrics/data` - Data count metrics
|
||||
- `GET /api/metrics/framework` - Framework metrics
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Each node supports these environment variables:
|
||||
|
||||
```bash
|
||||
NODE_ID=blog-node-1 # Unique node identifier
|
||||
NODE_PORT=3000 # HTTP API port
|
||||
IPFS_PORT=4001 # IPFS swarm port
|
||||
BOOTSTRAP_PEER=blog-bootstrap # Bootstrap node hostname
|
||||
SWARM_KEY_FILE=/data/swarm.key # Private swarm key path
|
||||
NODE_ENV=test # Environment mode
|
||||
```
|
||||
|
||||
### Private IPFS Swarm
|
||||
|
||||
The tests use a private IPFS swarm with a shared key to ensure:
|
||||
|
||||
- ✅ Network isolation from public IPFS
|
||||
- ✅ Controlled peer discovery
|
||||
- ✅ Predictable network topology
|
||||
- ✅ Enhanced security for testing
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Follow all container logs
|
||||
docker-compose -f docker/docker-compose.blog.yml logs -f
|
||||
|
||||
# Follow specific service logs
|
||||
docker-compose -f docker/docker-compose.blog.yml logs -f blog-node-1
|
||||
```
|
||||
|
||||
### Check Node Status
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3001/health
|
||||
curl http://localhost:3002/health
|
||||
curl http://localhost:3003/health
|
||||
|
||||
# Network metrics
|
||||
curl http://localhost:3001/api/metrics/network
|
||||
|
||||
# Data metrics
|
||||
curl http://localhost:3001/api/metrics/data
|
||||
```
|
||||
|
||||
### Connect to Running Containers
|
||||
|
||||
```bash
|
||||
# Access blog node shell
|
||||
docker-compose -f docker/docker-compose.blog.yml exec blog-node-1 sh
|
||||
|
||||
# Check IPFS status
|
||||
docker-compose -f docker/docker-compose.blog.yml exec blog-bootstrap ipfs swarm peers
|
||||
```
|
||||
|
||||
## Test Data
|
||||
|
||||
The tests automatically generate realistic test data:
|
||||
|
||||
- **Users**: Various user roles (author, editor, user)
|
||||
- **Categories**: Technology, Design, Business, etc.
|
||||
- **Posts**: Different statuses (draft, published, archived)
|
||||
- **Comments**: Including nested replies and engagement
|
||||
|
||||
## Performance Expectations
|
||||
|
||||
Based on the test configuration:
|
||||
|
||||
- **Node Startup**: < 60 seconds for all nodes
|
||||
- **Peer Discovery**: < 30 seconds for full mesh
|
||||
- **Data Sync**: < 15 seconds for typical operations
|
||||
- **Concurrent Operations**: 20+ simultaneous requests
|
||||
- **Test Execution**: 5-10 minutes for full suite
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Ports Already in Use
|
||||
|
||||
```bash
|
||||
# Check port usage
|
||||
lsof -i :3001-3003
|
||||
lsof -i :4001
|
||||
lsof -i :4011-4013
|
||||
|
||||
# Clean up existing containers
|
||||
npm run test:blog-clean
|
||||
```
|
||||
|
||||
#### Docker Build Failures
|
||||
|
||||
```bash
|
||||
# Clean Docker cache
|
||||
docker system prune -f
|
||||
|
||||
# Rebuild without cache
|
||||
docker-compose -f docker/docker-compose.blog.yml build --no-cache
|
||||
```
|
||||
|
||||
#### Node Connection Issues
|
||||
|
||||
```bash
|
||||
# Check network connectivity
|
||||
docker network ls
|
||||
docker network inspect blog-scenario_blog-network
|
||||
|
||||
# Verify swarm key consistency
|
||||
docker-compose -f docker/docker-compose.blog.yml exec blog-node-1 cat /data/swarm.key
|
||||
```
|
||||
|
||||
#### Test Timeouts
|
||||
|
||||
```bash
|
||||
# Increase test timeout in jest.config.js or test files
|
||||
# Monitor resource usage
|
||||
docker stats
|
||||
|
||||
# Check available memory and CPU
|
||||
free -h
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
To run tests with additional debugging:
|
||||
|
||||
```bash
|
||||
# Set debug environment
|
||||
DEBUG=* npm run test:blog-real
|
||||
|
||||
# Run with increased verbosity
|
||||
LOG_LEVEL=debug npm run test:blog-real
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. Add test cases to `tests/blog-workflow.test.ts`
|
||||
2. Extend `BlogTestRunner` with new utilities
|
||||
3. Update models if needed in `models/`
|
||||
4. Test locally before CI integration
|
||||
|
||||
### Modifying API
|
||||
|
||||
1. Update `blog-api-server.ts`
|
||||
2. Add corresponding validation in `BlogValidation.ts`
|
||||
3. Update test scenarios
|
||||
4. Rebuild Docker images
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
1. Adjust timeouts in test configuration
|
||||
2. Modify Docker resource limits
|
||||
3. Optimize IPFS/OrbitDB configuration
|
||||
4. Scale node count as needed
|
||||
|
||||
## Next Steps
|
||||
|
||||
This blog scenario provides a foundation for:
|
||||
|
||||
1. **Social Scenario**: User relationships, feeds, messaging
|
||||
2. **E-commerce Scenario**: Products, orders, payments
|
||||
3. **Collaborative Scenario**: Real-time editing, conflict resolution
|
||||
4. **Performance Testing**: Load testing, stress testing
|
||||
5. **Security Testing**: Attack scenarios, validation testing
|
||||
|
||||
The modular design allows easy extension to new scenarios while reusing the infrastructure components.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the troubleshooting section above
|
||||
2. Review Docker and test logs
|
||||
3. Verify your environment meets prerequisites
|
||||
4. Open an issue with detailed logs and configuration
|
@ -0,0 +1,41 @@
|
||||
# Blog API Node
|
||||
FROM node:18-alpine
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
git
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Make the API server executable
|
||||
RUN chmod +x tests/real-integration/blog-scenario/docker/blog-api-server.ts
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# Start the blog API server
|
||||
CMD ["node", "dist/tests/real-integration/blog-scenario/docker/blog-api-server.js"]
|
@ -0,0 +1,30 @@
|
||||
# Bootstrap node for IPFS peer discovery
|
||||
FROM node:18-alpine
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache curl jq
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install IPFS
|
||||
RUN wget https://dist.ipfs.tech/kubo/v0.24.0/kubo_v0.24.0_linux-amd64.tar.gz \
|
||||
&& tar -xzf kubo_v0.24.0_linux-amd64.tar.gz \
|
||||
&& mv kubo/ipfs /usr/local/bin/ \
|
||||
&& rm -rf kubo kubo_v0.24.0_linux-amd64.tar.gz
|
||||
|
||||
# Copy swarm key
|
||||
COPY tests/real-integration/blog-scenario/docker/swarm.key /data/swarm.key
|
||||
|
||||
# Initialize IPFS
|
||||
RUN ipfs init --profile=test
|
||||
|
||||
# Copy configuration script
|
||||
COPY tests/real-integration/blog-scenario/docker/bootstrap-config.sh /app/bootstrap-config.sh
|
||||
RUN chmod +x /app/bootstrap-config.sh
|
||||
|
||||
# Expose IPFS ports
|
||||
EXPOSE 4001 5001 8080
|
||||
|
||||
# Start IPFS daemon
|
||||
CMD ["/app/bootstrap-config.sh"]
|
@ -0,0 +1,30 @@
|
||||
# Test Runner for Blog Integration Tests
|
||||
FROM node:18-alpine
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache curl jq
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including dev dependencies for testing)
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Create results directory
|
||||
RUN mkdir -p /app/results
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=test
|
||||
ENV TEST_SCENARIO=blog
|
||||
|
||||
# Default command (can be overridden)
|
||||
CMD ["npm", "run", "test:blog-integration"]
|
663
tests/real-integration/blog-scenario/docker/blog-api-server.ts
Normal file
663
tests/real-integration/blog-scenario/docker/blog-api-server.ts
Normal file
@ -0,0 +1,663 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import express from 'express';
|
||||
import { DebrosFramework } from '../../../../src/framework/DebrosFramework';
|
||||
import { User, UserProfile, Category, Post, Comment } from '../models/BlogModels';
|
||||
import { BlogValidation, ValidationError } from '../models/BlogValidation';
|
||||
|
||||
class BlogAPIServer {
|
||||
private app: express.Application;
|
||||
private framework: DebrosFramework;
|
||||
private nodeId: string;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.nodeId = process.env.NODE_ID || 'blog-node';
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
this.app.use(express.json({ limit: '10mb' }));
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// CORS
|
||||
this.app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Logging
|
||||
this.app.use((req, res, next) => {
|
||||
console.log(`[${this.nodeId}] ${new Date().toISOString()} ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Error handling
|
||||
this.app.use((error: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(`[${this.nodeId}] Error:`, error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: error.message,
|
||||
field: error.field,
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
// Health check
|
||||
this.app.get('/health', async (req, res) => {
|
||||
try {
|
||||
const peers = await this.getConnectedPeerCount();
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
nodeId: this.nodeId,
|
||||
peers,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
status: 'unhealthy',
|
||||
nodeId: this.nodeId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
this.setupUserRoutes();
|
||||
this.setupCategoryRoutes();
|
||||
this.setupPostRoutes();
|
||||
this.setupCommentRoutes();
|
||||
this.setupMetricsRoutes();
|
||||
}
|
||||
|
||||
private setupUserRoutes() {
|
||||
// Create user
|
||||
this.app.post('/api/users', async (req, res, next) => {
|
||||
try {
|
||||
const sanitizedData = BlogValidation.sanitizeUserInput(req.body);
|
||||
BlogValidation.validateUser(sanitizedData);
|
||||
|
||||
const user = await User.create(sanitizedData);
|
||||
|
||||
console.log(`[${this.nodeId}] Created user: ${user.username} (${user.id})`);
|
||||
res.status(201).json(user.toJSON());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
this.app.get('/api/users/:id', async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
res.json(user.toJSON());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
this.app.get('/api/users', async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const search = req.query.search as string;
|
||||
|
||||
let query = User.query();
|
||||
|
||||
if (search) {
|
||||
query = query.where('username', 'like', `%${search}%`)
|
||||
.orWhere('displayName', 'like', `%${search}%`);
|
||||
}
|
||||
|
||||
const users = await query
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit)
|
||||
.find();
|
||||
|
||||
res.json({
|
||||
users: users.map(u => u.toJSON()),
|
||||
page,
|
||||
limit,
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
this.app.put('/api/users/:id', async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow updating certain fields
|
||||
const allowedFields = ['displayName', 'avatar', 'roles'];
|
||||
const updateData: any = {};
|
||||
|
||||
allowedFields.forEach(field => {
|
||||
if (req.body[field] !== undefined) {
|
||||
updateData[field] = req.body[field];
|
||||
}
|
||||
});
|
||||
|
||||
Object.assign(user, updateData);
|
||||
await user.save();
|
||||
|
||||
console.log(`[${this.nodeId}] Updated user: ${user.username}`);
|
||||
res.json(user.toJSON());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// User login (update last login)
|
||||
this.app.post('/api/users/:id/login', async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: 'User not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await user.updateLastLogin();
|
||||
res.json({ message: 'Login recorded', lastLoginAt: user.lastLoginAt });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupCategoryRoutes() {
|
||||
// Create category
|
||||
this.app.post('/api/categories', async (req, res, next) => {
|
||||
try {
|
||||
const sanitizedData = BlogValidation.sanitizeCategoryInput(req.body);
|
||||
BlogValidation.validateCategory(sanitizedData);
|
||||
|
||||
const category = await Category.create(sanitizedData);
|
||||
|
||||
console.log(`[${this.nodeId}] Created category: ${category.name} (${category.id})`);
|
||||
res.status(201).json(category);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all categories
|
||||
this.app.get('/api/categories', async (req, res, next) => {
|
||||
try {
|
||||
const categories = await Category.query()
|
||||
.where('isActive', true)
|
||||
.orderBy('name', 'asc')
|
||||
.find();
|
||||
|
||||
res.json({
|
||||
categories,
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get category by ID
|
||||
this.app.get('/api/categories/:id', async (req, res, next) => {
|
||||
try {
|
||||
const category = await Category.findById(req.params.id);
|
||||
if (!category) {
|
||||
return res.status(404).json({
|
||||
error: 'Category not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
res.json(category);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupPostRoutes() {
|
||||
// Create post
|
||||
this.app.post('/api/posts', async (req, res, next) => {
|
||||
try {
|
||||
const sanitizedData = BlogValidation.sanitizePostInput(req.body);
|
||||
BlogValidation.validatePost(sanitizedData);
|
||||
|
||||
const post = await Post.create(sanitizedData);
|
||||
|
||||
console.log(`[${this.nodeId}] Created post: ${post.title} (${post.id})`);
|
||||
res.status(201).json(post);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get post by ID with relationships
|
||||
this.app.get('/api/posts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.query()
|
||||
.where('id', req.params.id)
|
||||
.with(['author', 'category', 'comments'])
|
||||
.first();
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
res.json(post);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all posts with pagination and filters
|
||||
this.app.get('/api/posts', async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const status = req.query.status as string;
|
||||
const authorId = req.query.authorId as string;
|
||||
const categoryId = req.query.categoryId as string;
|
||||
const tag = req.query.tag as string;
|
||||
|
||||
let query = Post.query().with(['author', 'category']);
|
||||
|
||||
if (status) {
|
||||
query = query.where('status', status);
|
||||
}
|
||||
|
||||
if (authorId) {
|
||||
query = query.where('authorId', authorId);
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
query = query.where('categoryId', categoryId);
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
query = query.where('tags', 'includes', tag);
|
||||
}
|
||||
|
||||
const posts = await query
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit)
|
||||
.find();
|
||||
|
||||
res.json({
|
||||
posts,
|
||||
page,
|
||||
limit,
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update post
|
||||
this.app.put('/api/posts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
BlogValidation.validatePostUpdate(req.body);
|
||||
|
||||
Object.assign(post, req.body);
|
||||
post.updatedAt = Date.now();
|
||||
await post.save();
|
||||
|
||||
console.log(`[${this.nodeId}] Updated post: ${post.title}`);
|
||||
res.json(post);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Publish post
|
||||
this.app.post('/api/posts/:id/publish', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await post.publish();
|
||||
console.log(`[${this.nodeId}] Published post: ${post.title}`);
|
||||
res.json(post);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Unpublish post
|
||||
this.app.post('/api/posts/:id/unpublish', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await post.unpublish();
|
||||
console.log(`[${this.nodeId}] Unpublished post: ${post.title}`);
|
||||
res.json(post);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Like post
|
||||
this.app.post('/api/posts/:id/like', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await post.like();
|
||||
res.json({ likeCount: post.likeCount });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// View post (increment view count)
|
||||
this.app.post('/api/posts/:id/view', async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findById(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: 'Post not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await post.incrementViews();
|
||||
res.json({ viewCount: post.viewCount });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupCommentRoutes() {
|
||||
// Create comment
|
||||
this.app.post('/api/comments', async (req, res, next) => {
|
||||
try {
|
||||
const sanitizedData = BlogValidation.sanitizeCommentInput(req.body);
|
||||
BlogValidation.validateComment(sanitizedData);
|
||||
|
||||
const comment = await Comment.create(sanitizedData);
|
||||
|
||||
console.log(`[${this.nodeId}] Created comment on post ${comment.postId} by ${comment.authorId}`);
|
||||
res.status(201).json(comment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get comments for a post
|
||||
this.app.get('/api/posts/:postId/comments', async (req, res, next) => {
|
||||
try {
|
||||
const comments = await Comment.query()
|
||||
.where('postId', req.params.postId)
|
||||
.where('isApproved', true)
|
||||
.with(['author'])
|
||||
.orderBy('createdAt', 'asc')
|
||||
.find();
|
||||
|
||||
res.json({
|
||||
comments,
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Approve comment
|
||||
this.app.post('/api/comments/:id/approve', async (req, res, next) => {
|
||||
try {
|
||||
const comment = await Comment.findById(req.params.id);
|
||||
if (!comment) {
|
||||
return res.status(404).json({
|
||||
error: 'Comment not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await comment.approve();
|
||||
console.log(`[${this.nodeId}] Approved comment ${comment.id}`);
|
||||
res.json(comment);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Like comment
|
||||
this.app.post('/api/comments/:id/like', async (req, res, next) => {
|
||||
try {
|
||||
const comment = await Comment.findById(req.params.id);
|
||||
if (!comment) {
|
||||
return res.status(404).json({
|
||||
error: 'Comment not found',
|
||||
nodeId: this.nodeId
|
||||
});
|
||||
}
|
||||
|
||||
await comment.like();
|
||||
res.json({ likeCount: comment.likeCount });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupMetricsRoutes() {
|
||||
// Network metrics
|
||||
this.app.get('/api/metrics/network', async (req, res, next) => {
|
||||
try {
|
||||
const peers = await this.getConnectedPeerCount();
|
||||
res.json({
|
||||
nodeId: this.nodeId,
|
||||
peers,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Data metrics
|
||||
this.app.get('/api/metrics/data', async (req, res, next) => {
|
||||
try {
|
||||
const [userCount, postCount, commentCount, categoryCount] = await Promise.all([
|
||||
User.count(),
|
||||
Post.count(),
|
||||
Comment.count(),
|
||||
Category.count()
|
||||
]);
|
||||
|
||||
res.json({
|
||||
nodeId: this.nodeId,
|
||||
counts: {
|
||||
users: userCount,
|
||||
posts: postCount,
|
||||
comments: commentCount,
|
||||
categories: categoryCount
|
||||
},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Framework metrics
|
||||
this.app.get('/api/metrics/framework', async (req, res, next) => {
|
||||
try {
|
||||
const metrics = this.framework.getMetrics();
|
||||
res.json({
|
||||
nodeId: this.nodeId,
|
||||
...metrics,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getConnectedPeerCount(): Promise<number> {
|
||||
try {
|
||||
if (this.framework) {
|
||||
const ipfsService = this.framework.getIPFSService();
|
||||
if (ipfsService) {
|
||||
const peers = await ipfsService.getConnectedPeers();
|
||||
return peers.size;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.warn(`[${this.nodeId}] Failed to get peer count:`, error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
console.log(`[${this.nodeId}] Starting Blog API Server...`);
|
||||
|
||||
// Wait for dependencies
|
||||
await this.waitForDependencies();
|
||||
|
||||
// Initialize framework
|
||||
await this.initializeFramework();
|
||||
|
||||
// Start HTTP server
|
||||
const port = process.env.NODE_PORT || 3000;
|
||||
this.app.listen(port, () => {
|
||||
console.log(`[${this.nodeId}] Blog API server listening on port ${port}`);
|
||||
console.log(`[${this.nodeId}] Health check: http://localhost:${port}/health`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[${this.nodeId}] Failed to start:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForDependencies(): Promise<void> {
|
||||
// In a real deployment, you might wait for database connections, etc.
|
||||
console.log(`[${this.nodeId}] Dependencies ready`);
|
||||
}
|
||||
|
||||
private async initializeFramework(): Promise<void> {
|
||||
// Import services - adjust paths based on your actual service locations
|
||||
// Note: You'll need to implement these services or use existing ones
|
||||
const IPFSService = (await import('../../../../src/framework/services/IPFSService')).IPFSService;
|
||||
const OrbitDBService = (await import('../../../../src/framework/services/OrbitDBService')).OrbitDBService;
|
||||
|
||||
// Initialize IPFS service
|
||||
const ipfsService = new IPFSService({
|
||||
swarmKeyFile: process.env.SWARM_KEY_FILE,
|
||||
bootstrap: process.env.BOOTSTRAP_PEER ? [`/ip4/${process.env.BOOTSTRAP_PEER}/tcp/4001`] : [],
|
||||
ports: {
|
||||
swarm: parseInt(process.env.IPFS_PORT) || 4001
|
||||
}
|
||||
});
|
||||
|
||||
await ipfsService.init();
|
||||
console.log(`[${this.nodeId}] IPFS service initialized`);
|
||||
|
||||
// Initialize OrbitDB service
|
||||
const orbitDBService = new OrbitDBService(ipfsService);
|
||||
await orbitDBService.init();
|
||||
console.log(`[${this.nodeId}] OrbitDB service initialized`);
|
||||
|
||||
// Initialize framework
|
||||
this.framework = new DebrosFramework({
|
||||
environment: 'test',
|
||||
features: {
|
||||
autoMigration: true,
|
||||
automaticPinning: true,
|
||||
pubsub: true,
|
||||
queryCache: true,
|
||||
relationshipCache: true
|
||||
},
|
||||
performance: {
|
||||
queryTimeout: 10000,
|
||||
maxConcurrentOperations: 20,
|
||||
batchSize: 50
|
||||
}
|
||||
});
|
||||
|
||||
await this.framework.initialize(orbitDBService, ipfsService);
|
||||
console.log(`[${this.nodeId}] DebrosFramework initialized successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Received SIGTERM, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Received SIGINT, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const server = new BlogAPIServer();
|
||||
server.start();
|
@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Configuring bootstrap IPFS node..."
|
||||
|
||||
# Set swarm key for private network
|
||||
export IPFS_PATH=/root/.ipfs
|
||||
cp /data/swarm.key $IPFS_PATH/swarm.key
|
||||
|
||||
# Configure IPFS for private network
|
||||
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["*"]'
|
||||
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "POST", "GET"]'
|
||||
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Headers '["Authorization"]'
|
||||
|
||||
# Remove default bootstrap nodes (for private network)
|
||||
ipfs bootstrap rm --all
|
||||
|
||||
# Enable experimental features
|
||||
ipfs config --json Experimental.Libp2pStreamMounting true
|
||||
ipfs config --json Experimental.P2pHttpProxy true
|
||||
|
||||
# Configure addresses
|
||||
ipfs config Addresses.API "/ip4/0.0.0.0/tcp/5001"
|
||||
ipfs config Addresses.Gateway "/ip4/0.0.0.0/tcp/8080"
|
||||
ipfs config --json Addresses.Swarm '["/ip4/0.0.0.0/tcp/4001"]'
|
||||
|
||||
# Start IPFS daemon
|
||||
echo "Starting IPFS daemon..."
|
||||
exec ipfs daemon --enable-gc --enable-pubsub-experiment
|
@ -0,0 +1,157 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Bootstrap node for peer discovery
|
||||
blog-bootstrap:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: tests/real-integration/blog-scenario/docker/Dockerfile.bootstrap
|
||||
environment:
|
||||
- NODE_TYPE=bootstrap
|
||||
- NODE_ID=blog-bootstrap
|
||||
- SWARM_KEY_FILE=/data/swarm.key
|
||||
volumes:
|
||||
- ./swarm.key:/data/swarm.key:ro
|
||||
- bootstrap-data:/data/ipfs
|
||||
networks:
|
||||
- blog-network
|
||||
ports:
|
||||
- "4001:4001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5001/api/v0/id"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Blog API Node 1
|
||||
blog-node-1:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: tests/real-integration/blog-scenario/docker/Dockerfile.blog-api
|
||||
depends_on:
|
||||
blog-bootstrap:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ID=blog-node-1
|
||||
- NODE_PORT=3000
|
||||
- IPFS_PORT=4001
|
||||
- BOOTSTRAP_PEER=blog-bootstrap
|
||||
- SWARM_KEY_FILE=/data/swarm.key
|
||||
- NODE_ENV=test
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "4011:4001"
|
||||
volumes:
|
||||
- ./swarm.key:/data/swarm.key:ro
|
||||
- blog-node-1-data:/data
|
||||
networks:
|
||||
- blog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# Blog API Node 2
|
||||
blog-node-2:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: tests/real-integration/blog-scenario/docker/Dockerfile.blog-api
|
||||
depends_on:
|
||||
blog-bootstrap:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ID=blog-node-2
|
||||
- NODE_PORT=3000
|
||||
- IPFS_PORT=4001
|
||||
- BOOTSTRAP_PEER=blog-bootstrap
|
||||
- SWARM_KEY_FILE=/data/swarm.key
|
||||
- NODE_ENV=test
|
||||
ports:
|
||||
- "3002:3000"
|
||||
- "4012:4001"
|
||||
volumes:
|
||||
- ./swarm.key:/data/swarm.key:ro
|
||||
- blog-node-2-data:/data
|
||||
networks:
|
||||
- blog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# Blog API Node 3
|
||||
blog-node-3:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: tests/real-integration/blog-scenario/docker/Dockerfile.blog-api
|
||||
depends_on:
|
||||
blog-bootstrap:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ID=blog-node-3
|
||||
- NODE_PORT=3000
|
||||
- IPFS_PORT=4001
|
||||
- BOOTSTRAP_PEER=blog-bootstrap
|
||||
- SWARM_KEY_FILE=/data/swarm.key
|
||||
- NODE_ENV=test
|
||||
ports:
|
||||
- "3003:3000"
|
||||
- "4013:4001"
|
||||
volumes:
|
||||
- ./swarm.key:/data/swarm.key:ro
|
||||
- blog-node-3-data:/data
|
||||
networks:
|
||||
- blog-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# Test Runner
|
||||
blog-test-runner:
|
||||
build:
|
||||
context: ../../../
|
||||
dockerfile: tests/real-integration/blog-scenario/docker/Dockerfile.test-runner
|
||||
depends_on:
|
||||
blog-node-1:
|
||||
condition: service_healthy
|
||||
blog-node-2:
|
||||
condition: service_healthy
|
||||
blog-node-3:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- TEST_SCENARIO=blog
|
||||
- NODE_ENDPOINTS=http://blog-node-1:3000,http://blog-node-2:3000,http://blog-node-3:3000
|
||||
- TEST_TIMEOUT=300000
|
||||
- NODE_ENV=test
|
||||
volumes:
|
||||
- ./tests:/app/tests:ro
|
||||
- test-results:/app/results
|
||||
networks:
|
||||
- blog-network
|
||||
command: ["npm", "run", "test:blog-integration"]
|
||||
|
||||
volumes:
|
||||
bootstrap-data:
|
||||
driver: local
|
||||
blog-node-1-data:
|
||||
driver: local
|
||||
blog-node-2-data:
|
||||
driver: local
|
||||
blog-node-3-data:
|
||||
driver: local
|
||||
test-results:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
blog-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
3
tests/real-integration/blog-scenario/docker/swarm.key
Normal file
3
tests/real-integration/blog-scenario/docker/swarm.key
Normal file
@ -0,0 +1,3 @@
|
||||
/key/swarm/psk/1.0.0/
|
||||
/base16/
|
||||
9c4b2a1b3e5c8d7f2e1a9c4b6d8f3e5c7a9b2d4f6e8c1a3b5d7f9e2c4a6b8d0f
|
373
tests/real-integration/blog-scenario/models/BlogModels.ts
Normal file
373
tests/real-integration/blog-scenario/models/BlogModels.ts
Normal file
@ -0,0 +1,373 @@
|
||||
import { BaseModel } from '../../../../src/framework/models/BaseModel';
|
||||
import { Model, Field, HasMany, BelongsTo, HasOne, BeforeCreate, AfterCreate } from '../../../../src/framework/models/decorators';
|
||||
|
||||
// User Profile Model
|
||||
@Model({
|
||||
scope: 'global',
|
||||
type: 'docstore'
|
||||
})
|
||||
export class UserProfile extends BaseModel {
|
||||
@Field({ type: 'string', required: true })
|
||||
userId: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
bio?: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
location?: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
website?: string;
|
||||
|
||||
@Field({ type: 'object', required: false })
|
||||
socialLinks?: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
};
|
||||
|
||||
@Field({ type: 'array', required: false, default: [] })
|
||||
interests: string[];
|
||||
|
||||
@Field({ type: 'number', required: false, default: () => Date.now() })
|
||||
createdAt: number;
|
||||
|
||||
@Field({ type: 'number', required: false, default: () => Date.now() })
|
||||
updatedAt: number;
|
||||
|
||||
@BelongsTo(() => User, 'userId')
|
||||
user: User;
|
||||
}
|
||||
|
||||
// User Model
|
||||
@Model({
|
||||
scope: 'global',
|
||||
type: 'docstore'
|
||||
})
|
||||
export class User extends BaseModel {
|
||||
@Field({ type: 'string', required: true, unique: true })
|
||||
username: string;
|
||||
|
||||
@Field({ type: 'string', required: true, unique: true })
|
||||
email: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
displayName?: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
avatar?: string;
|
||||
|
||||
@Field({ type: 'boolean', required: false, default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Field({ type: 'array', required: false, default: [] })
|
||||
roles: string[];
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
createdAt: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
lastLoginAt?: number;
|
||||
|
||||
@HasMany(() => Post, 'authorId')
|
||||
posts: Post[];
|
||||
|
||||
@HasMany(() => Comment, 'authorId')
|
||||
comments: Comment[];
|
||||
|
||||
@HasOne(() => UserProfile, 'userId')
|
||||
profile: UserProfile;
|
||||
|
||||
@BeforeCreate()
|
||||
setTimestamps() {
|
||||
this.createdAt = Date.now();
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
async updateLastLogin(): Promise<void> {
|
||||
this.lastLoginAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = super.toJSON();
|
||||
// Don't expose sensitive data in API responses
|
||||
delete json.password;
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
// Category Model
|
||||
@Model({
|
||||
scope: 'global',
|
||||
type: 'docstore'
|
||||
})
|
||||
export class Category extends BaseModel {
|
||||
@Field({ type: 'string', required: true, unique: true })
|
||||
name: string;
|
||||
|
||||
@Field({ type: 'string', required: true, unique: true })
|
||||
slug: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
description?: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
color?: string;
|
||||
|
||||
@Field({ type: 'boolean', required: false, default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Field({ type: 'number', required: false, default: () => Date.now() })
|
||||
createdAt: number;
|
||||
|
||||
@HasMany(() => Post, 'categoryId')
|
||||
posts: Post[];
|
||||
|
||||
@BeforeCreate()
|
||||
generateSlug() {
|
||||
if (!this.slug && this.name) {
|
||||
this.slug = this.name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post Model
|
||||
@Model({
|
||||
scope: 'user',
|
||||
type: 'docstore'
|
||||
})
|
||||
export class Post extends BaseModel {
|
||||
@Field({ type: 'string', required: true })
|
||||
title: string;
|
||||
|
||||
@Field({ type: 'string', required: true, unique: true })
|
||||
slug: string;
|
||||
|
||||
@Field({ type: 'string', required: true })
|
||||
content: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
excerpt?: string;
|
||||
|
||||
@Field({ type: 'string', required: true })
|
||||
authorId: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
categoryId?: string;
|
||||
|
||||
@Field({ type: 'array', required: false, default: [] })
|
||||
tags: string[];
|
||||
|
||||
@Field({ type: 'string', required: false, default: 'draft' })
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
featuredImage?: string;
|
||||
|
||||
@Field({ type: 'boolean', required: false, default: false })
|
||||
isFeatured: boolean;
|
||||
|
||||
@Field({ type: 'number', required: false, default: 0 })
|
||||
viewCount: number;
|
||||
|
||||
@Field({ type: 'number', required: false, default: 0 })
|
||||
likeCount: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
createdAt: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
updatedAt: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
publishedAt?: number;
|
||||
|
||||
@BelongsTo(() => User, 'authorId')
|
||||
author: User;
|
||||
|
||||
@BelongsTo(() => Category, 'categoryId')
|
||||
category: Category;
|
||||
|
||||
@HasMany(() => Comment, 'postId')
|
||||
comments: Comment[];
|
||||
|
||||
@BeforeCreate()
|
||||
setTimestamps() {
|
||||
const now = Date.now();
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
|
||||
// Generate slug before validation if missing
|
||||
if (!this.slug && this.title) {
|
||||
this.slug = this.title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
@AfterCreate()
|
||||
finalizeSlug() {
|
||||
// Add unique identifier to slug after creation to ensure uniqueness
|
||||
if (this.slug && this.id) {
|
||||
this.slug = this.slug + '-' + this.id.slice(-8);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
async publish(): Promise<void> {
|
||||
this.status = 'published';
|
||||
this.publishedAt = Date.now();
|
||||
this.updatedAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async unpublish(): Promise<void> {
|
||||
this.status = 'draft';
|
||||
this.publishedAt = undefined;
|
||||
this.updatedAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async incrementViews(): Promise<void> {
|
||||
this.viewCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async like(): Promise<void> {
|
||||
this.likeCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async unlike(): Promise<void> {
|
||||
if (this.likeCount > 0) {
|
||||
this.likeCount -= 1;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async archive(): Promise<void> {
|
||||
this.status = 'archived';
|
||||
this.updatedAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Comment Model
|
||||
@Model({
|
||||
scope: 'user',
|
||||
type: 'docstore'
|
||||
})
|
||||
export class Comment extends BaseModel {
|
||||
@Field({ type: 'string', required: true })
|
||||
content: string;
|
||||
|
||||
@Field({ type: 'string', required: true })
|
||||
postId: string;
|
||||
|
||||
@Field({ type: 'string', required: true })
|
||||
authorId: string;
|
||||
|
||||
@Field({ type: 'string', required: false })
|
||||
parentId?: string; // For nested comments
|
||||
|
||||
@Field({ type: 'boolean', required: false, default: true })
|
||||
isApproved: boolean;
|
||||
|
||||
@Field({ type: 'number', required: false, default: 0 })
|
||||
likeCount: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
createdAt: number;
|
||||
|
||||
@Field({ type: 'number', required: false })
|
||||
updatedAt: number;
|
||||
|
||||
@BelongsTo(() => Post, 'postId')
|
||||
post: Post;
|
||||
|
||||
@BelongsTo(() => User, 'authorId')
|
||||
author: User;
|
||||
|
||||
@BelongsTo(() => Comment, 'parentId')
|
||||
parent?: Comment;
|
||||
|
||||
@HasMany(() => Comment, 'parentId')
|
||||
replies: Comment[];
|
||||
|
||||
@BeforeCreate()
|
||||
setTimestamps() {
|
||||
const now = Date.now();
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
async approve(): Promise<void> {
|
||||
this.isApproved = true;
|
||||
this.updatedAt = Date.now();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async like(): Promise<void> {
|
||||
this.likeCount += 1;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async unlike(): Promise<void> {
|
||||
if (this.likeCount > 0) {
|
||||
this.likeCount -= 1;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export all models
|
||||
export { User, UserProfile, Category, Post, Comment };
|
||||
|
||||
// Type definitions for API requests
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface CreatePostRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
authorId: string;
|
||||
categoryId?: string;
|
||||
tags?: string[];
|
||||
featuredImage?: string;
|
||||
status?: 'draft' | 'published';
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
content: string;
|
||||
postId: string;
|
||||
authorId: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePostRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
excerpt?: string;
|
||||
categoryId?: string;
|
||||
tags?: string[];
|
||||
featuredImage?: string;
|
||||
isFeatured?: boolean;
|
||||
}
|
216
tests/real-integration/blog-scenario/models/BlogValidation.ts
Normal file
216
tests/real-integration/blog-scenario/models/BlogValidation.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { CreateUserRequest, CreateCategoryRequest, CreatePostRequest, CreateCommentRequest, UpdatePostRequest } from './BlogModels';
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string, public field?: string) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BlogValidation {
|
||||
static validateUser(data: CreateUserRequest): void {
|
||||
if (!data.username || data.username.length < 3 || data.username.length > 30) {
|
||||
throw new ValidationError('Username must be between 3 and 30 characters', 'username');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(data.username)) {
|
||||
throw new ValidationError('Username can only contain letters, numbers, and underscores', 'username');
|
||||
}
|
||||
|
||||
if (!data.email || !this.isValidEmail(data.email)) {
|
||||
throw new ValidationError('Valid email is required', 'email');
|
||||
}
|
||||
|
||||
if (data.displayName && data.displayName.length > 100) {
|
||||
throw new ValidationError('Display name cannot exceed 100 characters', 'displayName');
|
||||
}
|
||||
|
||||
if (data.avatar && !this.isValidUrl(data.avatar)) {
|
||||
throw new ValidationError('Avatar must be a valid URL', 'avatar');
|
||||
}
|
||||
|
||||
if (data.roles && !Array.isArray(data.roles)) {
|
||||
throw new ValidationError('Roles must be an array', 'roles');
|
||||
}
|
||||
}
|
||||
|
||||
static validateCategory(data: CreateCategoryRequest): void {
|
||||
if (!data.name || data.name.length < 2 || data.name.length > 50) {
|
||||
throw new ValidationError('Category name must be between 2 and 50 characters', 'name');
|
||||
}
|
||||
|
||||
if (data.description && data.description.length > 500) {
|
||||
throw new ValidationError('Description cannot exceed 500 characters', 'description');
|
||||
}
|
||||
|
||||
if (data.color && !/^#[0-9A-Fa-f]{6}$/.test(data.color)) {
|
||||
throw new ValidationError('Color must be a valid hex color code', 'color');
|
||||
}
|
||||
}
|
||||
|
||||
static validatePost(data: CreatePostRequest): void {
|
||||
if (!data.title || data.title.length < 3 || data.title.length > 200) {
|
||||
throw new ValidationError('Title must be between 3 and 200 characters', 'title');
|
||||
}
|
||||
|
||||
if (!data.content || data.content.length < 10) {
|
||||
throw new ValidationError('Content must be at least 10 characters long', 'content');
|
||||
}
|
||||
|
||||
if (data.content.length > 50000) {
|
||||
throw new ValidationError('Content cannot exceed 50,000 characters', 'content');
|
||||
}
|
||||
|
||||
if (!data.authorId) {
|
||||
throw new ValidationError('Author ID is required', 'authorId');
|
||||
}
|
||||
|
||||
if (data.excerpt && data.excerpt.length > 300) {
|
||||
throw new ValidationError('Excerpt cannot exceed 300 characters', 'excerpt');
|
||||
}
|
||||
|
||||
if (data.tags && !Array.isArray(data.tags)) {
|
||||
throw new ValidationError('Tags must be an array', 'tags');
|
||||
}
|
||||
|
||||
if (data.tags && data.tags.length > 10) {
|
||||
throw new ValidationError('Cannot have more than 10 tags', 'tags');
|
||||
}
|
||||
|
||||
if (data.tags) {
|
||||
for (const tag of data.tags) {
|
||||
if (typeof tag !== 'string' || tag.length > 30) {
|
||||
throw new ValidationError('Each tag must be a string with max 30 characters', 'tags');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.featuredImage && !this.isValidUrl(data.featuredImage)) {
|
||||
throw new ValidationError('Featured image must be a valid URL', 'featuredImage');
|
||||
}
|
||||
|
||||
if (data.status && !['draft', 'published'].includes(data.status)) {
|
||||
throw new ValidationError('Status must be either "draft" or "published"', 'status');
|
||||
}
|
||||
}
|
||||
|
||||
static validatePostUpdate(data: UpdatePostRequest): void {
|
||||
if (data.title && (data.title.length < 3 || data.title.length > 200)) {
|
||||
throw new ValidationError('Title must be between 3 and 200 characters', 'title');
|
||||
}
|
||||
|
||||
if (data.content && (data.content.length < 10 || data.content.length > 50000)) {
|
||||
throw new ValidationError('Content must be between 10 and 50,000 characters', 'content');
|
||||
}
|
||||
|
||||
if (data.excerpt && data.excerpt.length > 300) {
|
||||
throw new ValidationError('Excerpt cannot exceed 300 characters', 'excerpt');
|
||||
}
|
||||
|
||||
if (data.tags && !Array.isArray(data.tags)) {
|
||||
throw new ValidationError('Tags must be an array', 'tags');
|
||||
}
|
||||
|
||||
if (data.tags && data.tags.length > 10) {
|
||||
throw new ValidationError('Cannot have more than 10 tags', 'tags');
|
||||
}
|
||||
|
||||
if (data.tags) {
|
||||
for (const tag of data.tags) {
|
||||
if (typeof tag !== 'string' || tag.length > 30) {
|
||||
throw new ValidationError('Each tag must be a string with max 30 characters', 'tags');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.featuredImage && !this.isValidUrl(data.featuredImage)) {
|
||||
throw new ValidationError('Featured image must be a valid URL', 'featuredImage');
|
||||
}
|
||||
|
||||
if (data.isFeatured !== undefined && typeof data.isFeatured !== 'boolean') {
|
||||
throw new ValidationError('isFeatured must be a boolean', 'isFeatured');
|
||||
}
|
||||
}
|
||||
|
||||
static validateComment(data: CreateCommentRequest): void {
|
||||
if (!data.content || data.content.length < 1 || data.content.length > 2000) {
|
||||
throw new ValidationError('Comment must be between 1 and 2000 characters', 'content');
|
||||
}
|
||||
|
||||
if (!data.postId) {
|
||||
throw new ValidationError('Post ID is required', 'postId');
|
||||
}
|
||||
|
||||
if (!data.authorId) {
|
||||
throw new ValidationError('Author ID is required', 'authorId');
|
||||
}
|
||||
|
||||
// parentId is optional, but if provided should be a string
|
||||
if (data.parentId !== undefined && typeof data.parentId !== 'string') {
|
||||
throw new ValidationError('Parent ID must be a string', 'parentId');
|
||||
}
|
||||
}
|
||||
|
||||
private static isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
private static isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitization helpers
|
||||
static sanitizeString(input: string): string {
|
||||
return input.trim().replace(/[<>]/g, '');
|
||||
}
|
||||
|
||||
static sanitizeArray(input: string[]): string[] {
|
||||
return input.map(item => this.sanitizeString(item)).filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
static sanitizeUserInput(data: CreateUserRequest): CreateUserRequest {
|
||||
return {
|
||||
username: this.sanitizeString(data.username),
|
||||
email: this.sanitizeString(data.email.toLowerCase()),
|
||||
displayName: data.displayName ? this.sanitizeString(data.displayName) : undefined,
|
||||
avatar: data.avatar ? this.sanitizeString(data.avatar) : undefined,
|
||||
roles: data.roles ? this.sanitizeArray(data.roles) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
static sanitizeCategoryInput(data: CreateCategoryRequest): CreateCategoryRequest {
|
||||
return {
|
||||
name: this.sanitizeString(data.name),
|
||||
description: data.description ? this.sanitizeString(data.description) : undefined,
|
||||
color: data.color ? this.sanitizeString(data.color) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
static sanitizePostInput(data: CreatePostRequest): CreatePostRequest {
|
||||
return {
|
||||
title: this.sanitizeString(data.title),
|
||||
content: data.content.trim(), // Don't sanitize content too aggressively
|
||||
excerpt: data.excerpt ? this.sanitizeString(data.excerpt) : undefined,
|
||||
authorId: this.sanitizeString(data.authorId),
|
||||
categoryId: data.categoryId ? this.sanitizeString(data.categoryId) : undefined,
|
||||
tags: data.tags ? this.sanitizeArray(data.tags) : undefined,
|
||||
featuredImage: data.featuredImage ? this.sanitizeString(data.featuredImage) : undefined,
|
||||
status: data.status
|
||||
};
|
||||
}
|
||||
|
||||
static sanitizeCommentInput(data: CreateCommentRequest): CreateCommentRequest {
|
||||
return {
|
||||
content: data.content.trim(),
|
||||
postId: this.sanitizeString(data.postId),
|
||||
authorId: this.sanitizeString(data.authorId),
|
||||
parentId: data.parentId ? this.sanitizeString(data.parentId) : undefined
|
||||
};
|
||||
}
|
||||
}
|
243
tests/real-integration/blog-scenario/run-tests.ts
Normal file
243
tests/real-integration/blog-scenario/run-tests.ts
Normal file
@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
interface TestConfig {
|
||||
scenario: string;
|
||||
composeFile: string;
|
||||
testCommand: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
class BlogIntegrationTestRunner {
|
||||
private dockerProcess: ChildProcess | null = null;
|
||||
private isShuttingDown = false;
|
||||
|
||||
constructor(private config: TestConfig) {
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => this.shutdown());
|
||||
process.on('SIGTERM', () => this.shutdown());
|
||||
process.on('exit', () => this.shutdown());
|
||||
}
|
||||
|
||||
async run(): Promise<boolean> {
|
||||
console.log(`🚀 Starting ${this.config.scenario} integration tests...`);
|
||||
console.log(`Using compose file: ${this.config.composeFile}`);
|
||||
|
||||
try {
|
||||
// Verify compose file exists
|
||||
if (!fs.existsSync(this.config.composeFile)) {
|
||||
throw new Error(`Docker compose file not found: ${this.config.composeFile}`);
|
||||
}
|
||||
|
||||
// Clean up any existing containers
|
||||
await this.cleanup();
|
||||
|
||||
// Start Docker services
|
||||
const success = await this.startServices();
|
||||
if (!success) {
|
||||
throw new Error('Failed to start Docker services');
|
||||
}
|
||||
|
||||
// Wait for services to be healthy
|
||||
const healthy = await this.waitForHealthy();
|
||||
if (!healthy) {
|
||||
throw new Error('Services failed to become healthy');
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const testResult = await this.runTests();
|
||||
|
||||
// Cleanup
|
||||
await this.cleanup();
|
||||
|
||||
return testResult;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test execution failed:', error.message);
|
||||
await this.cleanup();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startServices(): Promise<boolean> {
|
||||
console.log('🔧 Starting Docker services...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.dockerProcess = spawn('docker-compose', [
|
||||
'-f', this.config.composeFile,
|
||||
'up',
|
||||
'--build',
|
||||
'--abort-on-container-exit'
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
cwd: path.dirname(this.config.composeFile)
|
||||
});
|
||||
|
||||
let servicesStarted = false;
|
||||
let testRunnerFinished = false;
|
||||
|
||||
this.dockerProcess.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
console.log('[DOCKER]', output.trim());
|
||||
|
||||
// Check if all services are up
|
||||
if (output.includes('blog-node-3') && output.includes('healthy')) {
|
||||
servicesStarted = true;
|
||||
}
|
||||
|
||||
// Check if test runner has finished
|
||||
if (output.includes('blog-test-runner') && (output.includes('exited') || output.includes('done'))) {
|
||||
testRunnerFinished = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.dockerProcess.stderr?.on('data', (data) => {
|
||||
console.error('[DOCKER ERROR]', data.toString().trim());
|
||||
});
|
||||
|
||||
this.dockerProcess.on('exit', (code) => {
|
||||
console.log(`Docker process exited with code: ${code}`);
|
||||
resolve(code === 0 && testRunnerFinished);
|
||||
});
|
||||
|
||||
// Timeout after specified time
|
||||
setTimeout(() => {
|
||||
if (!testRunnerFinished) {
|
||||
console.log('❌ Test execution timed out');
|
||||
resolve(false);
|
||||
}
|
||||
}, this.config.timeout);
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForHealthy(): Promise<boolean> {
|
||||
console.log('🔧 Waiting for services to be healthy...');
|
||||
|
||||
// Wait for health checks to pass
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
const result = await this.checkHealth();
|
||||
if (result) {
|
||||
console.log('✅ All services are healthy');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
console.log('❌ Services failed to become healthy within timeout');
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const healthCheck = spawn('docker-compose', [
|
||||
'-f', this.config.composeFile,
|
||||
'ps'
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
cwd: path.dirname(this.config.composeFile)
|
||||
});
|
||||
|
||||
let output = '';
|
||||
healthCheck.stdout?.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
healthCheck.on('exit', () => {
|
||||
// Check if all required services are healthy
|
||||
const requiredServices = ['blog-node-1', 'blog-node-2', 'blog-node-3'];
|
||||
const allHealthy = requiredServices.every(service =>
|
||||
output.includes(service) && output.includes('Up') && output.includes('healthy')
|
||||
);
|
||||
|
||||
resolve(allHealthy);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async runTests(): Promise<boolean> {
|
||||
console.log('🧪 Running integration tests...');
|
||||
|
||||
// Tests are run as part of the Docker composition
|
||||
// We just need to wait for the test runner container to complete
|
||||
return true;
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
if (this.isShuttingDown) return;
|
||||
this.isShuttingDown = true;
|
||||
|
||||
console.log('🧹 Cleaning up Docker resources...');
|
||||
|
||||
try {
|
||||
// Stop and remove containers
|
||||
const cleanup = spawn('docker-compose', [
|
||||
'-f', this.config.composeFile,
|
||||
'down',
|
||||
'-v',
|
||||
'--remove-orphans'
|
||||
], {
|
||||
stdio: 'inherit',
|
||||
cwd: path.dirname(this.config.composeFile)
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
cleanup.on('exit', resolve);
|
||||
setTimeout(resolve, 10000); // Force cleanup after 10s
|
||||
});
|
||||
|
||||
console.log('✅ Cleanup completed');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Cleanup warning:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async shutdown(): Promise<void> {
|
||||
console.log('\n🛑 Shutting down...');
|
||||
|
||||
if (this.dockerProcess && !this.dockerProcess.killed) {
|
||||
this.dockerProcess.kill('SIGTERM');
|
||||
}
|
||||
|
||||
await this.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const config: TestConfig = {
|
||||
scenario: 'blog',
|
||||
composeFile: path.join(__dirname, 'docker', 'docker-compose.blog.yml'),
|
||||
testCommand: 'npm run test:blog-integration',
|
||||
timeout: 600000 // 10 minutes
|
||||
};
|
||||
|
||||
const runner = new BlogIntegrationTestRunner(config);
|
||||
const success = await runner.run();
|
||||
|
||||
if (success) {
|
||||
console.log('🎉 Blog integration tests completed successfully!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('❌ Blog integration tests failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('💥 Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { BlogIntegrationTestRunner };
|
446
tests/real-integration/blog-scenario/scenarios/BlogTestRunner.ts
Normal file
446
tests/real-integration/blog-scenario/scenarios/BlogTestRunner.ts
Normal file
@ -0,0 +1,446 @@
|
||||
import { ApiClient } from '../../shared/utils/ApiClient';
|
||||
import { SyncWaiter } from '../../shared/utils/SyncWaiter';
|
||||
import { CreateUserRequest, CreateCategoryRequest, CreatePostRequest, CreateCommentRequest } from '../models/BlogModels';
|
||||
|
||||
export interface BlogTestConfig {
|
||||
nodeEndpoints: string[];
|
||||
syncTimeout: number;
|
||||
operationTimeout: number;
|
||||
}
|
||||
|
||||
export class BlogTestRunner {
|
||||
private apiClients: ApiClient[];
|
||||
private syncWaiter: SyncWaiter;
|
||||
|
||||
constructor(private config: BlogTestConfig) {
|
||||
this.apiClients = config.nodeEndpoints.map(endpoint => new ApiClient(endpoint));
|
||||
this.syncWaiter = new SyncWaiter(this.apiClients);
|
||||
}
|
||||
|
||||
// Initialization and setup
|
||||
async waitForNodesReady(timeout: number = 60000): Promise<boolean> {
|
||||
console.log('🔧 Waiting for blog nodes to be ready...');
|
||||
return await this.syncWaiter.waitForNodesReady(timeout);
|
||||
}
|
||||
|
||||
async waitForPeerConnections(timeout: number = 30000): Promise<boolean> {
|
||||
console.log('🔧 Waiting for peer connections...');
|
||||
return await this.syncWaiter.waitForPeerConnections(2, timeout);
|
||||
}
|
||||
|
||||
async waitForSync(timeout: number = 10000): Promise<void> {
|
||||
await this.syncWaiter.waitForSync(timeout);
|
||||
}
|
||||
|
||||
// User operations
|
||||
async createUser(nodeIndex: number, userData: CreateUserRequest): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post('/api/users', userData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create user on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUser(nodeIndex: number, userId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get(`/api/users/${userId}`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get user on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUsers(nodeIndex: number, options: { page?: number; limit?: number; search?: string } = {}): Promise<any[]> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const queryString = new URLSearchParams(options as any).toString();
|
||||
const response = await client.get(`/api/users?${queryString}`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get users on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data.users;
|
||||
}
|
||||
|
||||
async updateUser(nodeIndex: number, userId: string, updateData: any): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.put(`/api/users/${userId}`, updateData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to update user on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Category operations
|
||||
async createCategory(nodeIndex: number, categoryData: CreateCategoryRequest): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post('/api/categories', categoryData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create category on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCategory(nodeIndex: number, categoryId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get(`/api/categories/${categoryId}`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get category on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCategories(nodeIndex: number): Promise<any[]> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get('/api/categories');
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get categories on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data.categories;
|
||||
}
|
||||
|
||||
// Post operations
|
||||
async createPost(nodeIndex: number, postData: CreatePostRequest): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post('/api/posts', postData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPost(nodeIndex: number, postId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get(`/api/posts/${postId}`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPosts(nodeIndex: number, options: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
categoryId?: string;
|
||||
tag?: string;
|
||||
} = {}): Promise<any[]> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const queryString = new URLSearchParams(options as any).toString();
|
||||
const response = await client.get(`/api/posts?${queryString}`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get posts on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data.posts;
|
||||
}
|
||||
|
||||
async updatePost(nodeIndex: number, postId: string, updateData: any): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.put(`/api/posts/${postId}`, updateData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to update post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async publishPost(nodeIndex: number, postId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/posts/${postId}/publish`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to publish post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async unpublishPost(nodeIndex: number, postId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/posts/${postId}/unpublish`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to unpublish post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async likePost(nodeIndex: number, postId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/posts/${postId}/like`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to like post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async viewPost(nodeIndex: number, postId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/posts/${postId}/view`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to view post on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Comment operations
|
||||
async createComment(nodeIndex: number, commentData: CreateCommentRequest): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post('/api/comments', commentData);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create comment on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getComments(nodeIndex: number, postId: string): Promise<any[]> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get(`/api/posts/${postId}/comments`);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get comments on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data.comments;
|
||||
}
|
||||
|
||||
async approveComment(nodeIndex: number, commentId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/comments/${commentId}/approve`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to approve comment on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async likeComment(nodeIndex: number, commentId: string): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.post(`/api/comments/${commentId}/like`, {});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to like comment on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Metrics and monitoring
|
||||
async getNetworkMetrics(nodeIndex: number): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get('/api/metrics/network');
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get network metrics on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getDataMetrics(nodeIndex: number): Promise<any> {
|
||||
const client = this.getClient(nodeIndex);
|
||||
const response = await client.get('/api/metrics/data');
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to get data metrics on node ${nodeIndex}: ${response.error}`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAllNetworkMetrics(): Promise<any[]> {
|
||||
const metrics = [];
|
||||
for (let i = 0; i < this.apiClients.length; i++) {
|
||||
try {
|
||||
const nodeMetrics = await this.getNetworkMetrics(i);
|
||||
metrics.push(nodeMetrics);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get metrics from node ${i}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getAllDataMetrics(): Promise<any[]> {
|
||||
const metrics = [];
|
||||
for (let i = 0; i < this.apiClients.length; i++) {
|
||||
try {
|
||||
const nodeMetrics = await this.getDataMetrics(i);
|
||||
metrics.push(nodeMetrics);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get data metrics from node ${i}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// Data consistency checks
|
||||
async verifyDataConsistency(dataType: 'users' | 'posts' | 'comments' | 'categories', expectedCount: number, tolerance: number = 0): Promise<boolean> {
|
||||
return await this.syncWaiter.waitForDataConsistency(dataType, expectedCount, this.config.syncTimeout, tolerance);
|
||||
}
|
||||
|
||||
async verifyUserSync(userId: string): Promise<boolean> {
|
||||
console.log(`🔍 Verifying user ${userId} sync across all nodes...`);
|
||||
|
||||
try {
|
||||
const userPromises = this.apiClients.map((_, index) => this.getUser(index, userId));
|
||||
const users = await Promise.all(userPromises);
|
||||
|
||||
// Check if all users have the same data
|
||||
const firstUser = users[0];
|
||||
const allSame = users.every(user =>
|
||||
user.id === firstUser.id &&
|
||||
user.username === firstUser.username &&
|
||||
user.email === firstUser.email
|
||||
);
|
||||
|
||||
if (allSame) {
|
||||
console.log(`✅ User ${userId} is consistent across all nodes`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ User ${userId} is not consistent across nodes`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Failed to verify user sync: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPostSync(postId: string): Promise<boolean> {
|
||||
console.log(`🔍 Verifying post ${postId} sync across all nodes...`);
|
||||
|
||||
try {
|
||||
const postPromises = this.apiClients.map((_, index) => this.getPost(index, postId));
|
||||
const posts = await Promise.all(postPromises);
|
||||
|
||||
// Check if all posts have the same data
|
||||
const firstPost = posts[0];
|
||||
const allSame = posts.every(post =>
|
||||
post.id === firstPost.id &&
|
||||
post.title === firstPost.title &&
|
||||
post.status === firstPost.status
|
||||
);
|
||||
|
||||
if (allSame) {
|
||||
console.log(`✅ Post ${postId} is consistent across all nodes`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ Post ${postId} is not consistent across nodes`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Failed to verify post sync: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
private getClient(nodeIndex: number): ApiClient {
|
||||
if (nodeIndex >= this.apiClients.length) {
|
||||
throw new Error(`Node index ${nodeIndex} is out of range. Available nodes: 0-${this.apiClients.length - 1}`);
|
||||
}
|
||||
return this.apiClients[nodeIndex];
|
||||
}
|
||||
|
||||
async logStatus(): Promise<void> {
|
||||
console.log('\n📊 Blog Test Environment Status:');
|
||||
console.log(`Total Nodes: ${this.config.nodeEndpoints.length}`);
|
||||
|
||||
const [networkMetrics, dataMetrics] = await Promise.all([
|
||||
this.getAllNetworkMetrics(),
|
||||
this.getAllDataMetrics()
|
||||
]);
|
||||
|
||||
networkMetrics.forEach((metrics, index) => {
|
||||
const data = dataMetrics[index];
|
||||
console.log(`Node ${index} (${metrics.nodeId}):`);
|
||||
console.log(` Peers: ${metrics.peers}`);
|
||||
if (data) {
|
||||
console.log(` Data: Users=${data.counts.users}, Posts=${data.counts.posts}, Comments=${data.counts.comments}, Categories=${data.counts.categories}`);
|
||||
}
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
console.log('🧹 Cleaning up blog test environment...');
|
||||
// Any cleanup logic if needed
|
||||
}
|
||||
|
||||
// Test data generators
|
||||
generateUserData(index: number): CreateUserRequest {
|
||||
return {
|
||||
username: `testuser${index}`,
|
||||
email: `testuser${index}@example.com`,
|
||||
displayName: `Test User ${index}`,
|
||||
roles: ['user']
|
||||
};
|
||||
}
|
||||
|
||||
generateCategoryData(index: number): CreateCategoryRequest {
|
||||
const categories = [
|
||||
{ name: 'Technology', description: 'Posts about technology and programming' },
|
||||
{ name: 'Design', description: 'UI/UX design and creative content' },
|
||||
{ name: 'Business', description: 'Business strategies and entrepreneurship' },
|
||||
{ name: 'Lifestyle', description: 'Lifestyle and personal development' },
|
||||
{ name: 'Science', description: 'Scientific discoveries and research' }
|
||||
];
|
||||
|
||||
const category = categories[index % categories.length];
|
||||
return {
|
||||
name: `${category.name} ${Math.floor(index / categories.length) || ''}`.trim(),
|
||||
description: category.description
|
||||
};
|
||||
}
|
||||
|
||||
generatePostData(authorId: string, categoryId?: string, index: number = 0): CreatePostRequest {
|
||||
return {
|
||||
title: `Test Blog Post ${index + 1}`,
|
||||
content: `This is the content of test blog post ${index + 1}. It contains detailed information about the topic and provides valuable insights to readers. The content is long enough to test the system's handling of substantial text data.`,
|
||||
excerpt: `This is a test blog post excerpt ${index + 1}`,
|
||||
authorId,
|
||||
categoryId,
|
||||
tags: [`tag${index}`, 'test', 'blog'],
|
||||
status: 'draft'
|
||||
};
|
||||
}
|
||||
|
||||
generateCommentData(postId: string, authorId: string, index: number = 0, parentId?: string): CreateCommentRequest {
|
||||
return {
|
||||
content: `This is test comment ${index + 1}. It provides feedback on the blog post.`,
|
||||
postId,
|
||||
authorId,
|
||||
parentId
|
||||
};
|
||||
}
|
||||
}
|
569
tests/real-integration/blog-scenario/tests/blog-workflow.test.ts
Normal file
569
tests/real-integration/blog-scenario/tests/blog-workflow.test.ts
Normal file
@ -0,0 +1,569 @@
|
||||
import { describe, beforeAll, afterAll, it, expect, jest } from '@jest/globals';
|
||||
import { BlogTestRunner, BlogTestConfig } from '../scenarios/BlogTestRunner';
|
||||
|
||||
// Increase timeout for Docker-based tests
|
||||
jest.setTimeout(300000); // 5 minutes
|
||||
|
||||
describe('Blog Workflow Integration Tests', () => {
|
||||
let testRunner: BlogTestRunner;
|
||||
|
||||
beforeAll(async () => {
|
||||
console.log('🚀 Starting Blog Integration Tests...');
|
||||
|
||||
const config: BlogTestConfig = {
|
||||
nodeEndpoints: [
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3003'
|
||||
],
|
||||
syncTimeout: 15000,
|
||||
operationTimeout: 10000
|
||||
};
|
||||
|
||||
testRunner = new BlogTestRunner(config);
|
||||
|
||||
// Wait for all nodes to be ready
|
||||
const nodesReady = await testRunner.waitForNodesReady(120000);
|
||||
if (!nodesReady) {
|
||||
throw new Error('Blog nodes failed to become ready within timeout');
|
||||
}
|
||||
|
||||
// Wait for peer discovery and connections
|
||||
const peersConnected = await testRunner.waitForPeerConnections(60000);
|
||||
if (!peersConnected) {
|
||||
throw new Error('Blog nodes failed to establish peer connections within timeout');
|
||||
}
|
||||
|
||||
await testRunner.logStatus();
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (testRunner) {
|
||||
await testRunner.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('User Management Workflow', () => {
|
||||
it('should create users on different nodes and sync across network', async () => {
|
||||
console.log('\n🔧 Testing cross-node user creation and sync...');
|
||||
|
||||
// Create users on different nodes
|
||||
const alice = await testRunner.createUser(0, {
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
displayName: 'Alice Smith',
|
||||
roles: ['author']
|
||||
});
|
||||
|
||||
const bob = await testRunner.createUser(1, {
|
||||
username: 'bob',
|
||||
email: 'bob@example.com',
|
||||
displayName: 'Bob Jones',
|
||||
roles: ['user']
|
||||
});
|
||||
|
||||
const charlie = await testRunner.createUser(2, {
|
||||
username: 'charlie',
|
||||
email: 'charlie@example.com',
|
||||
displayName: 'Charlie Brown',
|
||||
roles: ['editor']
|
||||
});
|
||||
|
||||
expect(alice.id).toBeDefined();
|
||||
expect(bob.id).toBeDefined();
|
||||
expect(charlie.id).toBeDefined();
|
||||
|
||||
// Wait for sync
|
||||
await testRunner.waitForSync(10000);
|
||||
|
||||
// Verify Alice exists on all nodes
|
||||
const aliceVerification = await testRunner.verifyUserSync(alice.id);
|
||||
expect(aliceVerification).toBe(true);
|
||||
|
||||
// Verify Bob exists on all nodes
|
||||
const bobVerification = await testRunner.verifyUserSync(bob.id);
|
||||
expect(bobVerification).toBe(true);
|
||||
|
||||
// Verify Charlie exists on all nodes
|
||||
const charlieVerification = await testRunner.verifyUserSync(charlie.id);
|
||||
expect(charlieVerification).toBe(true);
|
||||
|
||||
console.log('✅ Cross-node user creation and sync verified');
|
||||
});
|
||||
|
||||
it('should update user data and sync changes across nodes', async () => {
|
||||
console.log('\n🔧 Testing user updates across nodes...');
|
||||
|
||||
// Create user on node 0
|
||||
const user = await testRunner.createUser(0, {
|
||||
username: 'updateuser',
|
||||
email: 'updateuser@example.com',
|
||||
displayName: 'Original Name'
|
||||
});
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Update user from node 1
|
||||
const updatedUser = await testRunner.updateUser(1, user.id, {
|
||||
displayName: 'Updated Name',
|
||||
roles: ['premium']
|
||||
});
|
||||
|
||||
expect(updatedUser.displayName).toBe('Updated Name');
|
||||
expect(updatedUser.roles).toContain('premium');
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Verify update is reflected on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const nodeUser = await testRunner.getUser(nodeIndex, user.id);
|
||||
expect(nodeUser.displayName).toBe('Updated Name');
|
||||
expect(nodeUser.roles).toContain('premium');
|
||||
}
|
||||
|
||||
console.log('✅ User update sync verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category Management Workflow', () => {
|
||||
it('should create categories and sync across nodes', async () => {
|
||||
console.log('\n🔧 Testing category creation and sync...');
|
||||
|
||||
// Create categories on different nodes
|
||||
const techCategory = await testRunner.createCategory(0, {
|
||||
name: 'Technology',
|
||||
description: 'Posts about technology and programming',
|
||||
color: '#0066cc'
|
||||
});
|
||||
|
||||
const designCategory = await testRunner.createCategory(1, {
|
||||
name: 'Design',
|
||||
description: 'UI/UX design and creative content',
|
||||
color: '#ff6600'
|
||||
});
|
||||
|
||||
expect(techCategory.id).toBeDefined();
|
||||
expect(techCategory.slug).toBe('technology');
|
||||
expect(designCategory.id).toBeDefined();
|
||||
expect(designCategory.slug).toBe('design');
|
||||
|
||||
await testRunner.waitForSync(8000);
|
||||
|
||||
// Verify categories exist on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const categories = await testRunner.getCategories(nodeIndex);
|
||||
|
||||
const techExists = categories.some(c => c.id === techCategory.id);
|
||||
const designExists = categories.some(c => c.id === designCategory.id);
|
||||
|
||||
expect(techExists).toBe(true);
|
||||
expect(designExists).toBe(true);
|
||||
}
|
||||
|
||||
console.log('✅ Category creation and sync verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Publishing Workflow', () => {
|
||||
let author: any;
|
||||
let category: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test author and category
|
||||
author = await testRunner.createUser(0, {
|
||||
username: 'contentauthor',
|
||||
email: 'contentauthor@example.com',
|
||||
displayName: 'Content Author',
|
||||
roles: ['author']
|
||||
});
|
||||
|
||||
category = await testRunner.createCategory(1, {
|
||||
name: 'Test Content',
|
||||
description: 'Category for test content'
|
||||
});
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
});
|
||||
|
||||
it('should support complete blog publishing workflow across nodes', async () => {
|
||||
console.log('\n🔧 Testing complete blog publishing workflow...');
|
||||
|
||||
// Step 1: Create draft post on node 2
|
||||
const post = await testRunner.createPost(2, {
|
||||
title: 'Building Decentralized Applications with DebrosFramework',
|
||||
content: 'In this comprehensive guide, we will explore how to build decentralized applications using the DebrosFramework. This framework provides powerful abstractions over IPFS and OrbitDB, making it easier than ever to create distributed applications.',
|
||||
excerpt: 'Learn how to build decentralized applications with DebrosFramework',
|
||||
authorId: author.id,
|
||||
categoryId: category.id,
|
||||
tags: ['decentralized', 'blockchain', 'dapps', 'tutorial']
|
||||
});
|
||||
|
||||
expect(post.status).toBe('draft');
|
||||
expect(post.authorId).toBe(author.id);
|
||||
expect(post.categoryId).toBe(category.id);
|
||||
|
||||
await testRunner.waitForSync(8000);
|
||||
|
||||
// Step 2: Verify draft post exists on all nodes
|
||||
const postVerification = await testRunner.verifyPostSync(post.id);
|
||||
expect(postVerification).toBe(true);
|
||||
|
||||
// Step 3: Publish post from node 0
|
||||
const publishedPost = await testRunner.publishPost(0, post.id);
|
||||
expect(publishedPost.status).toBe('published');
|
||||
expect(publishedPost.publishedAt).toBeDefined();
|
||||
|
||||
await testRunner.waitForSync(8000);
|
||||
|
||||
// Step 4: Verify published post exists on all nodes with relationships
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const nodePost = await testRunner.getPost(nodeIndex, post.id);
|
||||
expect(nodePost.status).toBe('published');
|
||||
expect(nodePost.publishedAt).toBeDefined();
|
||||
expect(nodePost.author).toBeDefined();
|
||||
expect(nodePost.author.username).toBe('contentauthor');
|
||||
expect(nodePost.category).toBeDefined();
|
||||
expect(nodePost.category.name).toBe('Test Content');
|
||||
}
|
||||
|
||||
console.log('✅ Complete blog publishing workflow verified');
|
||||
});
|
||||
|
||||
it('should handle post engagement across nodes', async () => {
|
||||
console.log('\n🔧 Testing post engagement across nodes...');
|
||||
|
||||
// Create and publish a post
|
||||
const post = await testRunner.createPost(0, {
|
||||
title: 'Engagement Test Post',
|
||||
content: 'This post will test engagement features across nodes.',
|
||||
authorId: author.id,
|
||||
categoryId: category.id
|
||||
});
|
||||
|
||||
await testRunner.publishPost(0, post.id);
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Track views from different nodes
|
||||
await testRunner.viewPost(1, post.id);
|
||||
await testRunner.viewPost(2, post.id);
|
||||
await testRunner.viewPost(0, post.id);
|
||||
|
||||
// Like post from different nodes
|
||||
await testRunner.likePost(1, post.id);
|
||||
await testRunner.likePost(2, post.id);
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Verify engagement metrics are consistent across nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const nodePost = await testRunner.getPost(nodeIndex, post.id);
|
||||
expect(nodePost.viewCount).toBe(3);
|
||||
expect(nodePost.likeCount).toBe(2);
|
||||
}
|
||||
|
||||
console.log('✅ Post engagement sync verified');
|
||||
});
|
||||
|
||||
it('should support post status changes across nodes', async () => {
|
||||
console.log('\n🔧 Testing post status changes...');
|
||||
|
||||
// Create and publish post
|
||||
const post = await testRunner.createPost(1, {
|
||||
title: 'Status Change Test Post',
|
||||
content: 'Testing post status changes across nodes.',
|
||||
authorId: author.id
|
||||
});
|
||||
|
||||
await testRunner.publishPost(1, post.id);
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Verify published status on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const nodePost = await testRunner.getPost(nodeIndex, post.id);
|
||||
expect(nodePost.status).toBe('published');
|
||||
}
|
||||
|
||||
// Unpublish from different node
|
||||
await testRunner.unpublishPost(2, post.id);
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Verify unpublished status on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const nodePost = await testRunner.getPost(nodeIndex, post.id);
|
||||
expect(nodePost.status).toBe('draft');
|
||||
expect(nodePost.publishedAt).toBeUndefined();
|
||||
}
|
||||
|
||||
console.log('✅ Post status change sync verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comment System Workflow', () => {
|
||||
let author: any;
|
||||
let commenter1: any;
|
||||
let commenter2: any;
|
||||
let post: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test users and post
|
||||
[author, commenter1, commenter2] = await Promise.all([
|
||||
testRunner.createUser(0, {
|
||||
username: 'commentauthor',
|
||||
email: 'commentauthor@example.com',
|
||||
displayName: 'Comment Author'
|
||||
}),
|
||||
testRunner.createUser(1, {
|
||||
username: 'commenter1',
|
||||
email: 'commenter1@example.com',
|
||||
displayName: 'First Commenter'
|
||||
}),
|
||||
testRunner.createUser(2, {
|
||||
username: 'commenter2',
|
||||
email: 'commenter2@example.com',
|
||||
displayName: 'Second Commenter'
|
||||
})
|
||||
]);
|
||||
|
||||
post = await testRunner.createPost(0, {
|
||||
title: 'Post for Comment Testing',
|
||||
content: 'This post will receive comments from different nodes.',
|
||||
authorId: author.id
|
||||
});
|
||||
|
||||
await testRunner.publishPost(0, post.id);
|
||||
await testRunner.waitForSync(8000);
|
||||
});
|
||||
|
||||
it('should support distributed comment system', async () => {
|
||||
console.log('\n🔧 Testing distributed comment system...');
|
||||
|
||||
// Create comments from different nodes
|
||||
const comment1 = await testRunner.createComment(1, {
|
||||
content: 'Great post! Very informative and well written.',
|
||||
postId: post.id,
|
||||
authorId: commenter1.id
|
||||
});
|
||||
|
||||
const comment2 = await testRunner.createComment(2, {
|
||||
content: 'I learned a lot from this, thank you for sharing!',
|
||||
postId: post.id,
|
||||
authorId: commenter2.id
|
||||
});
|
||||
|
||||
expect(comment1.id).toBeDefined();
|
||||
expect(comment2.id).toBeDefined();
|
||||
|
||||
await testRunner.waitForSync(8000);
|
||||
|
||||
// Verify comments exist on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const comments = await testRunner.getComments(nodeIndex, post.id);
|
||||
expect(comments.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const comment1Exists = comments.some(c => c.id === comment1.id);
|
||||
const comment2Exists = comments.some(c => c.id === comment2.id);
|
||||
|
||||
expect(comment1Exists).toBe(true);
|
||||
expect(comment2Exists).toBe(true);
|
||||
}
|
||||
|
||||
console.log('✅ Distributed comment creation verified');
|
||||
});
|
||||
|
||||
it('should support nested comments (replies)', async () => {
|
||||
console.log('\n🔧 Testing nested comments...');
|
||||
|
||||
// Create parent comment
|
||||
const parentComment = await testRunner.createComment(0, {
|
||||
content: 'This is a parent comment that will receive replies.',
|
||||
postId: post.id,
|
||||
authorId: author.id
|
||||
});
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Create replies from different nodes
|
||||
const reply1 = await testRunner.createComment(1, {
|
||||
content: 'This is a reply to the parent comment.',
|
||||
postId: post.id,
|
||||
authorId: commenter1.id,
|
||||
parentId: parentComment.id
|
||||
});
|
||||
|
||||
const reply2 = await testRunner.createComment(2, {
|
||||
content: 'Another reply to the same parent comment.',
|
||||
postId: post.id,
|
||||
authorId: commenter2.id,
|
||||
parentId: parentComment.id
|
||||
});
|
||||
|
||||
expect(reply1.parentId).toBe(parentComment.id);
|
||||
expect(reply2.parentId).toBe(parentComment.id);
|
||||
|
||||
await testRunner.waitForSync(8000);
|
||||
|
||||
// Verify nested structure exists on all nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const comments = await testRunner.getComments(nodeIndex, post.id);
|
||||
|
||||
const parent = comments.find(c => c.id === parentComment.id);
|
||||
const replyToParent1 = comments.find(c => c.id === reply1.id);
|
||||
const replyToParent2 = comments.find(c => c.id === reply2.id);
|
||||
|
||||
expect(parent).toBeDefined();
|
||||
expect(replyToParent1).toBeDefined();
|
||||
expect(replyToParent2).toBeDefined();
|
||||
expect(replyToParent1.parentId).toBe(parentComment.id);
|
||||
expect(replyToParent2.parentId).toBe(parentComment.id);
|
||||
}
|
||||
|
||||
console.log('✅ Nested comments verified');
|
||||
});
|
||||
|
||||
it('should handle comment engagement across nodes', async () => {
|
||||
console.log('\n🔧 Testing comment engagement...');
|
||||
|
||||
// Create comment
|
||||
const comment = await testRunner.createComment(0, {
|
||||
content: 'This comment will test engagement features.',
|
||||
postId: post.id,
|
||||
authorId: author.id
|
||||
});
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Like comment from different nodes
|
||||
await testRunner.likeComment(1, comment.id);
|
||||
await testRunner.likeComment(2, comment.id);
|
||||
|
||||
await testRunner.waitForSync(5000);
|
||||
|
||||
// Verify like count is consistent across nodes
|
||||
for (let nodeIndex = 0; nodeIndex < 3; nodeIndex++) {
|
||||
const comments = await testRunner.getComments(nodeIndex, post.id);
|
||||
const likedComment = comments.find(c => c.id === comment.id);
|
||||
expect(likedComment).toBeDefined();
|
||||
expect(likedComment.likeCount).toBe(2);
|
||||
}
|
||||
|
||||
console.log('✅ Comment engagement sync verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and Scalability Tests', () => {
|
||||
it('should handle concurrent operations across nodes', async () => {
|
||||
console.log('\n🔧 Testing concurrent operations performance...');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create operations across all nodes simultaneously
|
||||
const operations = [];
|
||||
|
||||
// Create users concurrently
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const nodeIndex = i % 3;
|
||||
operations.push(
|
||||
testRunner.createUser(nodeIndex, testRunner.generateUserData(i))
|
||||
);
|
||||
}
|
||||
|
||||
// Create categories concurrently
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const nodeIndex = i % 3;
|
||||
operations.push(
|
||||
testRunner.createCategory(nodeIndex, testRunner.generateCategoryData(i))
|
||||
);
|
||||
}
|
||||
|
||||
// Execute all operations concurrently
|
||||
const results = await Promise.all(operations);
|
||||
const creationTime = Date.now() - startTime;
|
||||
|
||||
console.log(`Created ${results.length} records across 3 nodes in ${creationTime}ms`);
|
||||
|
||||
// Verify all operations succeeded
|
||||
expect(results.length).toBe(21);
|
||||
results.forEach(result => {
|
||||
expect(result.id).toBeDefined();
|
||||
});
|
||||
|
||||
// Wait for full sync
|
||||
await testRunner.waitForSync(15000);
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
console.log(`Total operation time including sync: ${totalTime}ms`);
|
||||
|
||||
// Performance expectations (adjust based on your requirements)
|
||||
expect(creationTime).toBeLessThan(30000); // Creation under 30s
|
||||
expect(totalTime).toBeLessThan(60000); // Total under 60s
|
||||
|
||||
await testRunner.logStatus();
|
||||
console.log('✅ Concurrent operations performance verified');
|
||||
});
|
||||
|
||||
it('should maintain data consistency under load', async () => {
|
||||
console.log('\n🔧 Testing data consistency under load...');
|
||||
|
||||
// Get initial counts
|
||||
const initialMetrics = await testRunner.getAllDataMetrics();
|
||||
const initialUserCount = initialMetrics[0]?.counts.users || 0;
|
||||
|
||||
// Create multiple users rapidly
|
||||
const userCreationPromises = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const nodeIndex = i % 3;
|
||||
userCreationPromises.push(
|
||||
testRunner.createUser(nodeIndex, {
|
||||
username: `loaduser${i}`,
|
||||
email: `loaduser${i}@example.com`,
|
||||
displayName: `Load Test User ${i}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(userCreationPromises);
|
||||
await testRunner.waitForSync(20000);
|
||||
|
||||
// Verify data consistency across all nodes
|
||||
const consistency = await testRunner.verifyDataConsistency('users', initialUserCount + 20, 2);
|
||||
expect(consistency).toBe(true);
|
||||
|
||||
console.log('✅ Data consistency under load verified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Resilience Tests', () => {
|
||||
it('should maintain peer connections throughout test execution', async () => {
|
||||
console.log('\n🔧 Testing network resilience...');
|
||||
|
||||
const networkMetrics = await testRunner.getAllNetworkMetrics();
|
||||
|
||||
// Each node should be connected to at least 2 other nodes
|
||||
networkMetrics.forEach((metrics, index) => {
|
||||
console.log(`Node ${index} has ${metrics.peers} peers`);
|
||||
expect(metrics.peers).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
console.log('✅ Network resilience verified');
|
||||
});
|
||||
|
||||
it('should provide consistent API responses across nodes', async () => {
|
||||
console.log('\n🔧 Testing API consistency...');
|
||||
|
||||
// Test the same query on all nodes
|
||||
const nodeResponses = await Promise.all([
|
||||
testRunner.getUsers(0, { limit: 5 }),
|
||||
testRunner.getUsers(1, { limit: 5 }),
|
||||
testRunner.getUsers(2, { limit: 5 })
|
||||
]);
|
||||
|
||||
// All nodes should return data (though exact counts may vary due to sync timing)
|
||||
nodeResponses.forEach((users, index) => {
|
||||
console.log(`Node ${index} returned ${users.length} users`);
|
||||
expect(Array.isArray(users)).toBe(true);
|
||||
});
|
||||
|
||||
console.log('✅ API consistency verified');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,170 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ApiClient } from '../utils/ApiClient';
|
||||
import { SyncWaiter } from '../utils/SyncWaiter';
|
||||
|
||||
export interface NodeConfig {
|
||||
nodeId: string;
|
||||
apiPort: number;
|
||||
ipfsPort: number;
|
||||
nodeType: string;
|
||||
}
|
||||
|
||||
export interface DockerComposeConfig {
|
||||
composeFile: string;
|
||||
scenario: string;
|
||||
nodes: NodeConfig[];
|
||||
}
|
||||
|
||||
export class DockerNodeManager {
|
||||
private process: ChildProcess | null = null;
|
||||
private apiClients: ApiClient[] = [];
|
||||
private syncWaiter: SyncWaiter;
|
||||
|
||||
constructor(private config: DockerComposeConfig) {
|
||||
// Create API clients for each node
|
||||
this.apiClients = this.config.nodes.map(node =>
|
||||
new ApiClient(`http://localhost:${node.apiPort}`)
|
||||
);
|
||||
|
||||
this.syncWaiter = new SyncWaiter(this.apiClients);
|
||||
}
|
||||
|
||||
async startCluster(): Promise<boolean> {
|
||||
console.log(`🚀 Starting ${this.config.scenario} cluster...`);
|
||||
|
||||
try {
|
||||
// Start docker-compose
|
||||
this.process = spawn('docker-compose', [
|
||||
'-f', this.config.composeFile,
|
||||
'up',
|
||||
'--build',
|
||||
'--force-recreate'
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
// Log output
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
console.log(`[DOCKER] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
console.error(`[DOCKER ERROR] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Wait for nodes to be ready
|
||||
const ready = await this.syncWaiter.waitForNodesReady(120000);
|
||||
if (!ready) {
|
||||
throw new Error('Nodes failed to become ready');
|
||||
}
|
||||
|
||||
// Wait for peer connections
|
||||
const connected = await this.syncWaiter.waitForPeerConnections(
|
||||
this.config.nodes.length - 1, // Each node should connect to all others
|
||||
60000
|
||||
);
|
||||
|
||||
if (!connected) {
|
||||
throw new Error('Nodes failed to establish peer connections');
|
||||
}
|
||||
|
||||
console.log(`✅ ${this.config.scenario} cluster started successfully`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to start cluster: ${error.message}`);
|
||||
await this.stopCluster();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopCluster(): Promise<void> {
|
||||
console.log(`🛑 Stopping ${this.config.scenario} cluster...`);
|
||||
|
||||
try {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGTERM');
|
||||
|
||||
// Wait for graceful shutdown
|
||||
await new Promise((resolve) => {
|
||||
this.process?.on('exit', resolve);
|
||||
setTimeout(resolve, 10000); // Force kill after 10s
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up docker containers and volumes
|
||||
const cleanup = spawn('docker-compose', [
|
||||
'-f', this.config.composeFile,
|
||||
'down',
|
||||
'-v',
|
||||
'--remove-orphans'
|
||||
], {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
cleanup.on('exit', resolve);
|
||||
});
|
||||
|
||||
console.log(`✅ ${this.config.scenario} cluster stopped`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error stopping cluster: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
getApiClient(nodeIndex: number): ApiClient {
|
||||
if (nodeIndex >= this.apiClients.length) {
|
||||
throw new Error(`Node index ${nodeIndex} is out of range`);
|
||||
}
|
||||
return this.apiClients[nodeIndex];
|
||||
}
|
||||
|
||||
getSyncWaiter(): SyncWaiter {
|
||||
return this.syncWaiter;
|
||||
}
|
||||
|
||||
async waitForSync(timeout: number = 10000): Promise<void> {
|
||||
await this.syncWaiter.waitForSync(timeout);
|
||||
}
|
||||
|
||||
async getNetworkMetrics(): Promise<any> {
|
||||
const metrics = await this.syncWaiter.getSyncMetrics();
|
||||
|
||||
return {
|
||||
totalNodes: this.config.nodes.length,
|
||||
readyNodes: metrics.length,
|
||||
averagePeers: metrics.length > 0
|
||||
? metrics.reduce((sum, m) => sum + m.peerCount, 0) / metrics.length
|
||||
: 0,
|
||||
nodeMetrics: metrics
|
||||
};
|
||||
}
|
||||
|
||||
async logClusterStatus(): Promise<void> {
|
||||
console.log(`\n📋 ${this.config.scenario} Cluster Status:`);
|
||||
console.log(`Nodes: ${this.config.nodes.length}`);
|
||||
|
||||
const networkMetrics = await this.getNetworkMetrics();
|
||||
console.log(`Ready: ${networkMetrics.readyNodes}/${networkMetrics.totalNodes}`);
|
||||
console.log(`Average Peers: ${networkMetrics.averagePeers.toFixed(1)}`);
|
||||
|
||||
await this.syncWaiter.logSyncStatus();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
this.apiClients.map(client => client.health())
|
||||
);
|
||||
|
||||
return results.every(result =>
|
||||
result.status === 200 && result.data?.status === 'healthy'
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
123
tests/real-integration/shared/utils/ApiClient.ts
Normal file
123
tests/real-integration/shared/utils/ApiClient.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
async get<T = any>(path: string): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`);
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data: response.ok ? data : undefined,
|
||||
error: response.ok ? undefined : data.error || 'Request failed',
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async post<T = any>(path: string, body: any): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data: response.ok ? data : undefined,
|
||||
error: response.ok ? undefined : data.error || 'Request failed',
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async put<T = any>(path: string, body: any): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
data: response.ok ? data : undefined,
|
||||
error: response.ok ? undefined : data.error || 'Request failed',
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async delete<T = any>(path: string): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = response.status === 204 ? {} : await response.json();
|
||||
|
||||
return {
|
||||
data: response.ok ? data : undefined,
|
||||
error: response.ok ? undefined : data.error || 'Request failed',
|
||||
status: response.status
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error.message,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async health(): Promise<ApiResponse<{ status: string; nodeId: string; peers: number }>> {
|
||||
return this.get('/health');
|
||||
}
|
||||
|
||||
async waitForHealth(timeout: number = 30000): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const response = await this.health();
|
||||
if (response.status === 200 && response.data?.status === 'healthy') {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
166
tests/real-integration/shared/utils/SyncWaiter.ts
Normal file
166
tests/real-integration/shared/utils/SyncWaiter.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { ApiClient } from './ApiClient';
|
||||
|
||||
export interface SyncMetrics {
|
||||
nodeId: string;
|
||||
peerCount: number;
|
||||
dataCount: {
|
||||
users: number;
|
||||
posts: number;
|
||||
comments: number;
|
||||
categories: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncWaiter {
|
||||
constructor(private apiClients: ApiClient[]) {}
|
||||
|
||||
async waitForSync(timeout: number = 10000): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, timeout));
|
||||
}
|
||||
|
||||
async waitForPeerConnections(minPeers: number = 2, timeout: number = 30000): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`Waiting for nodes to connect to at least ${minPeers} peers...`);
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
let allConnected = true;
|
||||
|
||||
for (const client of this.apiClients) {
|
||||
try {
|
||||
const health = await client.health();
|
||||
if (!health.data || health.data.peers < minPeers) {
|
||||
allConnected = false;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
allConnected = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allConnected) {
|
||||
console.log('✅ All nodes have sufficient peer connections');
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('❌ Timeout waiting for peer connections');
|
||||
return false;
|
||||
}
|
||||
|
||||
async waitForDataConsistency(
|
||||
dataType: 'users' | 'posts' | 'comments' | 'categories',
|
||||
expectedCount: number,
|
||||
timeout: number = 15000,
|
||||
tolerance: number = 0
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`Waiting for ${dataType} count to reach ${expectedCount} across all nodes...`);
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
let isConsistent = true;
|
||||
const counts: number[] = [];
|
||||
|
||||
for (const client of this.apiClients) {
|
||||
try {
|
||||
const response = await client.get('/api/metrics/data');
|
||||
if (response.data && response.data.counts) {
|
||||
const count = response.data.counts[dataType];
|
||||
counts.push(count);
|
||||
|
||||
if (Math.abs(count - expectedCount) > tolerance) {
|
||||
isConsistent = false;
|
||||
}
|
||||
} else {
|
||||
isConsistent = false;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
isConsistent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isConsistent) {
|
||||
console.log(`✅ Data consistency achieved: ${dataType} = ${expectedCount} across all nodes`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Data counts: ${counts.join(', ')}, expected: ${expectedCount}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log(`❌ Timeout waiting for data consistency: ${dataType}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async getSyncMetrics(): Promise<SyncMetrics[]> {
|
||||
const metrics: SyncMetrics[] = [];
|
||||
|
||||
for (const client of this.apiClients) {
|
||||
try {
|
||||
const [healthResponse, dataResponse] = await Promise.all([
|
||||
client.health(),
|
||||
client.get('/api/metrics/data')
|
||||
]);
|
||||
|
||||
if (healthResponse.data && dataResponse.data) {
|
||||
metrics.push({
|
||||
nodeId: healthResponse.data.nodeId,
|
||||
peerCount: healthResponse.data.peers,
|
||||
dataCount: dataResponse.data.counts
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get metrics from node: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async logSyncStatus(): Promise<void> {
|
||||
console.log('\n📊 Current Sync Status:');
|
||||
const metrics = await this.getSyncMetrics();
|
||||
|
||||
metrics.forEach(metric => {
|
||||
console.log(`Node: ${metric.nodeId}`);
|
||||
console.log(` Peers: ${metric.peerCount}`);
|
||||
console.log(` Data: Users=${metric.dataCount.users}, Posts=${metric.dataCount.posts}, Comments=${metric.dataCount.comments}, Categories=${metric.dataCount.categories}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async waitForNodesReady(timeout: number = 60000): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log('Waiting for all nodes to be ready...');
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
let allReady = true;
|
||||
|
||||
for (let i = 0; i < this.apiClients.length; i++) {
|
||||
const isReady = await this.apiClients[i].waitForHealth(5000);
|
||||
if (!isReady) {
|
||||
console.log(`Node ${i} not ready yet...`);
|
||||
allReady = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allReady) {
|
||||
console.log('✅ All nodes are ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
console.log('❌ Timeout waiting for nodes to be ready');
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user