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:
anonpenguin 2025-06-21 11:15:33 +03:00
parent 344a346fd6
commit 64ed9e82a7
21 changed files with 4045 additions and 7 deletions

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
network.txt network.txt
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
coverage/

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

View File

@ -25,10 +25,10 @@ cd my-debros-app
npm init -y npm init -y
``` ```
### 2. Install DebrosFramework ### 2. Install @debros/network
```bash ```bash
npm install debros-framework npm install @debros/network
npm install --save-dev typescript @types/node npm install --save-dev typescript @types/node
``` ```

140
docs/docs/updated-intro.md Normal file
View 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._

View File

@ -25,10 +25,11 @@
"test:unit": "jest tests/unit", "test:unit": "jest tests/unit",
"test:integration": "jest tests/integration", "test:integration": "jest tests/integration",
"test:e2e": "jest tests/e2e", "test:e2e": "jest tests/e2e",
"test:real": "jest --config jest.real.config.cjs", "test:blog-real": "cd tests/real-integration/blog-scenario && docker-compose -f docker/docker-compose.blog.yml up --build --abort-on-container-exit",
"test:real:debug": "REAL_TEST_DEBUG=true jest --config jest.real.config.cjs", "test:blog-integration": "jest tests/real-integration/blog-scenario/tests --detectOpenHandles --forceExit",
"test:real:basic": "jest --config jest.real.config.cjs tests/real/real-integration.test.ts", "test:blog-build": "cd tests/real-integration/blog-scenario && docker-compose -f docker/docker-compose.blog.yml build",
"test:real:p2p": "jest --config jest.real.config.cjs tests/real/peer-discovery.test.ts" "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": [ "keywords": [
"ipfs", "ipfs",
@ -76,7 +77,9 @@
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/node-fetch": "^2.6.7",
"@types/node-forge": "^1.3.11", "@types/node-forge": "^1.3.11",
"node-fetch": "^2.7.0",
"@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0", "@typescript-eslint/parser": "^8.29.0",
"eslint": "^9.24.0", "eslint": "^9.24.0",

View 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

View File

@ -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"]

View File

@ -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"]

View File

@ -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"]

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
/key/swarm/psk/1.0.0/
/base16/
9c4b2a1b3e5c8d7f2e1a9c4b6d8f3e5c7a9b2d4f6e8c1a3b5d7f9e2c4a6b8d0f

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

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

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

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

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

View File

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

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

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