feat(tests): Add integration tests for blog scenario

- Implement basic operations tests for user, category, post, and comment management.
- Create cross-node operations tests to verify distributed content creation and concurrent operations.
- Introduce Jest configuration and setup files for testing environment.
- Enhance BlogTestHelper with methods for user, category, post, and comment operations.
- Ensure data consistency and network metrics verification across nodes.
This commit is contained in:
anonpenguin 2025-07-03 06:15:27 +03:00
parent e6636c8850
commit 8c8a19ab5f
9 changed files with 1046 additions and 145 deletions

View File

@ -545,6 +545,11 @@ export abstract class BaseModel {
private applyFieldDefaults(): void {
const modelClass = this.constructor as typeof BaseModel;
// Ensure we have fields map
if (!modelClass.fields) {
return;
}
for (const [fieldName, fieldConfig] of modelClass.fields) {
if (fieldConfig.default !== undefined) {
const privateKey = `_${fieldName}`;

View File

@ -3,89 +3,82 @@ import { BaseModel } from '../BaseModel';
export function Field(config: FieldConfig) {
return function (target: any, propertyKey: string) {
// When decorators are used in an ES module context, the `target` for a property decorator
// can be undefined. We need to defer the Object.defineProperty call until we have
// a valid target. We can achieve this by replacing the original decorator with one
// that captures the config and applies it later.
// This is a workaround for the decorator context issue.
const decorator = (instance: any) => {
if (!Object.getOwnPropertyDescriptor(instance, propertyKey)) {
Object.defineProperty(instance, propertyKey, {
get() {
const privateKey = `_${propertyKey}`;
// Use the reliable getFieldValue method if available, otherwise fallback to private key
if (this.getFieldValue && typeof this.getFieldValue === 'function') {
return this.getFieldValue(propertyKey);
}
// Fallback to direct private key access
return this[privateKey];
},
set(value) {
const ctor = this.constructor as typeof BaseModel;
const privateKey = `_${propertyKey}`;
// One-time initialization of the fields map on the constructor
if (!ctor.hasOwnProperty('fields')) {
const parentFields = ctor.fields ? new Map(ctor.fields) : new Map();
Object.defineProperty(ctor, 'fields', {
value: parentFields,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store field configuration if it's not already there
if (!ctor.fields.has(propertyKey)) {
ctor.fields.set(propertyKey, config);
}
// Apply transformation first
const transformedValue = config.transform ? config.transform(value) : value;
// Only validate non-required constraints during assignment
// Required field validation will happen during save()
const validationResult = validateFieldValueNonRequired(
transformedValue,
config,
propertyKey,
);
if (!validationResult.valid) {
throw new ValidationError(validationResult.errors);
}
// Check if value actually changed
const oldValue = this[privateKey];
if (oldValue !== transformedValue) {
// Set the value and mark as dirty
this[privateKey] = transformedValue;
if (this._isDirty !== undefined) {
this._isDirty = true;
}
// Track field modification
if (this.markFieldAsModified && typeof this.markFieldAsModified === 'function') {
this.markFieldAsModified(propertyKey);
}
}
},
enumerable: true,
configurable: true,
});
}
};
// We need to apply this to the prototype. Since target is undefined,
// we can't do it directly. Instead, we can rely on the class constructor's
// prototype, which will be available when the class is instantiated.
// A common pattern is to add the decorator logic to a static array on the constructor
// and apply them in the base model constructor.
// Let's try a simpler approach for now by checking the target.
if (target) {
decorator(target);
// Validate field configuration
validateFieldConfig(config);
// Get the constructor function
const ctor = target.constructor as typeof BaseModel;
// Initialize fields map if it doesn't exist
if (!ctor.hasOwnProperty('fields')) {
const parentFields = ctor.fields ? new Map(ctor.fields) : new Map();
Object.defineProperty(ctor, 'fields', {
value: parentFields,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store field configuration
ctor.fields.set(propertyKey, config);
// Define property on the prototype
Object.defineProperty(target, propertyKey, {
get() {
const privateKey = `_${propertyKey}`;
return this[privateKey];
},
set(value) {
const privateKey = `_${propertyKey}`;
const ctor = this.constructor as typeof BaseModel;
// Ensure fields map exists on the constructor
if (!ctor.hasOwnProperty('fields')) {
const parentFields = ctor.fields ? new Map(ctor.fields) : new Map();
Object.defineProperty(ctor, 'fields', {
value: parentFields,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store field configuration if it's not already there
if (!ctor.fields.has(propertyKey)) {
ctor.fields.set(propertyKey, config);
}
// Apply transformation first
const transformedValue = config.transform ? config.transform(value) : value;
// Only validate non-required constraints during assignment
const validationResult = validateFieldValueNonRequired(
transformedValue,
config,
propertyKey,
);
if (!validationResult.valid) {
throw new ValidationError(validationResult.errors);
}
// Check if value actually changed
const oldValue = this[privateKey];
if (oldValue !== transformedValue) {
// Set the value and mark as dirty
this[privateKey] = transformedValue;
if (this._isDirty !== undefined) {
this._isDirty = true;
}
// Track field modification
if (this.markFieldAsModified && typeof this.markFieldAsModified === 'function') {
this.markFieldAsModified(propertyKey);
}
}
},
enumerable: true,
configurable: true,
});
};
}

View File

@ -91,72 +91,86 @@ function createRelationshipProperty(
propertyKey: string,
config: RelationshipConfig,
): void {
// In an ES module context, `target` can be undefined when decorators are first evaluated.
// We must ensure we only call Object.defineProperty on a valid object (the class prototype).
if (target) {
Object.defineProperty(target, propertyKey, {
get() {
const ctor = this.constructor as typeof BaseModel;
// One-time initialization of the relationships map on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Check if relationship is already loaded
if (this._loadedRelations && this._loadedRelations.has(propertyKey)) {
return this._loadedRelations.get(propertyKey);
}
if (config.lazy) {
// Return a promise for lazy loading
return this.loadRelation(propertyKey);
} else {
throw new Error(
`Relationship '${propertyKey}' not loaded. Use .load(['${propertyKey}']) first.`,
);
}
},
set(value) {
const ctor = this.constructor as typeof BaseModel;
// One-time initialization of the relationships map on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Allow manual setting of relationship values
if (!this._loadedRelations) {
this._loadedRelations = new Map();
}
this._loadedRelations.set(propertyKey, value);
},
enumerable: true,
// Get the constructor function
const ctor = target.constructor as typeof BaseModel;
// Initialize relationships map if it doesn't exist
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration
ctor.relationships.set(propertyKey, config);
// Define property on the prototype
Object.defineProperty(target, propertyKey, {
get() {
const ctor = this.constructor as typeof BaseModel;
// Ensure relationships map exists on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Check if relationship is already loaded
if (this._loadedRelations && this._loadedRelations.has(propertyKey)) {
return this._loadedRelations.get(propertyKey);
}
if (config.lazy) {
// Return a promise for lazy loading
return this.loadRelation(propertyKey);
} else {
throw new Error(
`Relationship '${propertyKey}' not loaded. Use .load(['${propertyKey}']) first.`,
);
}
},
set(value) {
const ctor = this.constructor as typeof BaseModel;
// Ensure relationships map exists on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Allow manual setting of relationship values
if (!this._loadedRelations) {
this._loadedRelations = new Map();
}
this._loadedRelations.set(propertyKey, value);
},
enumerable: true,
configurable: true,
});
}
// Utility function to get relationship configuration

View File

@ -0,0 +1,236 @@
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import { blogTestHelper, TestUser, TestCategory, TestPost, TestComment } from './setup';
describe('Blog Basic Operations', () => {
let testUser: TestUser;
let testCategory: TestCategory;
let testPost: TestPost;
let testComment: TestComment;
beforeAll(async () => {
console.log('🔄 Waiting for all nodes to be ready...');
await blogTestHelper.waitForNodesReady();
console.log('✅ All nodes are ready for testing');
}, 60000); // 60 second timeout for setup
describe('User Management', () => {
test('should create a user successfully', async () => {
const testData = blogTestHelper.generateTestData();
testUser = await blogTestHelper.createUser(testData.user);
expect(testUser).toBeDefined();
expect(testUser.id).toBeDefined();
expect(testUser.username).toBe(testData.user.username);
expect(testUser.email).toBe(testData.user.email);
expect(testUser.displayName).toBe(testData.user.displayName);
console.log(`✅ Created user: ${testUser.username} (${testUser.id})`);
}, 30000);
test('should retrieve user by ID from all nodes', async () => {
expect(testUser?.id).toBeDefined();
// Test retrieval from each node
for (const node of blogTestHelper.getNodes()) {
const retrievedUser = await blogTestHelper.getUser(testUser.id!, node.id);
expect(retrievedUser).toBeDefined();
expect(retrievedUser!.id).toBe(testUser.id);
expect(retrievedUser!.username).toBe(testUser.username);
expect(retrievedUser!.email).toBe(testUser.email);
console.log(`✅ Retrieved user from ${node.id}: ${retrievedUser!.username}`);
}
}, 30000);
test('should list users with pagination', async () => {
const result = await blogTestHelper.listUsers();
expect(result).toBeDefined();
expect(result.users).toBeInstanceOf(Array);
expect(result.users.length).toBeGreaterThan(0);
expect(result.page).toBeDefined();
expect(result.limit).toBeDefined();
// Verify our test user is in the list
const foundUser = result.users.find(u => u.id === testUser.id);
expect(foundUser).toBeDefined();
console.log(`✅ Listed ${result.users.length} users`);
}, 30000);
});
describe('Category Management', () => {
test('should create a category successfully', async () => {
const testData = blogTestHelper.generateTestData();
testCategory = await blogTestHelper.createCategory(testData.category);
expect(testCategory).toBeDefined();
expect(testCategory.id).toBeDefined();
expect(testCategory.name).toBe(testData.category.name);
expect(testCategory.description).toBe(testData.category.description);
expect(testCategory.color).toBe(testData.category.color);
console.log(`✅ Created category: ${testCategory.name} (${testCategory.id})`);
}, 30000);
test('should retrieve category from all nodes', async () => {
expect(testCategory?.id).toBeDefined();
for (const node of blogTestHelper.getNodes()) {
const retrievedCategory = await blogTestHelper.getCategory(testCategory.id!, node.id);
expect(retrievedCategory).toBeDefined();
expect(retrievedCategory!.id).toBe(testCategory.id);
expect(retrievedCategory!.name).toBe(testCategory.name);
console.log(`✅ Retrieved category from ${node.id}: ${retrievedCategory!.name}`);
}
}, 30000);
test('should list all categories', async () => {
const result = await blogTestHelper.listCategories();
expect(result).toBeDefined();
expect(result.categories).toBeInstanceOf(Array);
expect(result.categories.length).toBeGreaterThan(0);
// Verify our test category is in the list
const foundCategory = result.categories.find(c => c.id === testCategory.id);
expect(foundCategory).toBeDefined();
console.log(`✅ Listed ${result.categories.length} categories`);
}, 30000);
});
describe('Post Management', () => {
test('should create a post successfully', async () => {
expect(testUser?.id).toBeDefined();
expect(testCategory?.id).toBeDefined();
const testData = blogTestHelper.generateTestData();
const postData = testData.post(testUser.id!, testCategory.id!);
testPost = await blogTestHelper.createPost(postData);
expect(testPost).toBeDefined();
expect(testPost.id).toBeDefined();
expect(testPost.title).toBe(postData.title);
expect(testPost.content).toBe(postData.content);
expect(testPost.authorId).toBe(testUser.id);
expect(testPost.categoryId).toBe(testCategory.id);
expect(testPost.status).toBe('draft');
console.log(`✅ Created post: ${testPost.title} (${testPost.id})`);
}, 30000);
test('should retrieve post with relationships from all nodes', async () => {
expect(testPost?.id).toBeDefined();
for (const node of blogTestHelper.getNodes()) {
const retrievedPost = await blogTestHelper.getPost(testPost.id!, node.id);
expect(retrievedPost).toBeDefined();
expect(retrievedPost!.id).toBe(testPost.id);
expect(retrievedPost!.title).toBe(testPost.title);
expect(retrievedPost!.authorId).toBe(testUser.id);
expect(retrievedPost!.categoryId).toBe(testCategory.id);
console.log(`✅ Retrieved post from ${node.id}: ${retrievedPost!.title}`);
}
}, 30000);
test('should publish post and update status', async () => {
expect(testPost?.id).toBeDefined();
const publishedPost = await blogTestHelper.publishPost(testPost.id!);
expect(publishedPost).toBeDefined();
expect(publishedPost.status).toBe('published');
expect(publishedPost.publishedAt).toBeDefined();
// Verify status change is replicated across nodes
await blogTestHelper.waitForDataReplication(async () => {
for (const node of blogTestHelper.getNodes()) {
const post = await blogTestHelper.getPost(testPost.id!, node.id);
if (!post || post.status !== 'published') {
return false;
}
}
return true;
}, 15000);
console.log(`✅ Published post: ${publishedPost.title}`);
}, 30000);
test('should like post and increment count', async () => {
expect(testPost?.id).toBeDefined();
const result = await blogTestHelper.likePost(testPost.id!);
expect(result).toBeDefined();
expect(result.likeCount).toBeGreaterThan(0);
console.log(`✅ Liked post, count: ${result.likeCount}`);
}, 30000);
});
describe('Comment Management', () => {
test('should create a comment successfully', async () => {
expect(testPost?.id).toBeDefined();
expect(testUser?.id).toBeDefined();
const testData = blogTestHelper.generateTestData();
const commentData = testData.comment(testPost.id!, testUser.id!);
testComment = await blogTestHelper.createComment(commentData);
expect(testComment).toBeDefined();
expect(testComment.id).toBeDefined();
expect(testComment.content).toBe(commentData.content);
expect(testComment.postId).toBe(testPost.id);
expect(testComment.authorId).toBe(testUser.id);
console.log(`✅ Created comment: ${testComment.id}`);
}, 30000);
test('should retrieve post comments from all nodes', async () => {
expect(testPost?.id).toBeDefined();
expect(testComment?.id).toBeDefined();
for (const node of blogTestHelper.getNodes()) {
const result = await blogTestHelper.getPostComments(testPost.id!, node.id);
expect(result).toBeDefined();
expect(result.comments).toBeInstanceOf(Array);
// Find our test comment
const foundComment = result.comments.find(c => c.id === testComment.id);
expect(foundComment).toBeDefined();
expect(foundComment!.content).toBe(testComment.content);
console.log(`✅ Retrieved ${result.comments.length} comments from ${node.id}`);
}
}, 30000);
});
describe('Data Metrics', () => {
test('should get data metrics from all nodes', async () => {
for (const node of blogTestHelper.getNodes()) {
const metrics = await blogTestHelper.getDataMetrics(node.id);
expect(metrics).toBeDefined();
expect(metrics.nodeId).toBe(node.id);
expect(metrics.counts).toBeDefined();
expect(metrics.counts.users).toBeGreaterThan(0);
expect(metrics.counts.categories).toBeGreaterThan(0);
expect(metrics.counts.posts).toBeGreaterThan(0);
expect(metrics.counts.comments).toBeGreaterThan(0);
console.log(`${node.id} metrics:`, JSON.stringify(metrics.counts, null, 2));
}
}, 30000);
});
});

View File

@ -0,0 +1,285 @@
import { describe, test, expect, beforeAll } from '@jest/globals';
import { blogTestHelper, TestUser, TestCategory, TestPost, TestComment } from './setup';
describe('Cross-Node Operations', () => {
let users: TestUser[] = [];
let categories: TestCategory[] = [];
let posts: TestPost[] = [];
beforeAll(async () => {
console.log('🔄 Waiting for all nodes to be ready...');
await blogTestHelper.waitForNodesReady();
console.log('✅ All nodes are ready for cross-node testing');
}, 60000);
describe('Distributed Content Creation', () => {
test('should create users on different nodes', async () => {
const nodes = blogTestHelper.getNodes();
// Create one user on each node
for (let i = 0; i < nodes.length; i++) {
const testData = blogTestHelper.generateTestData();
const user = await blogTestHelper.createUser(testData.user, nodes[i].id);
expect(user).toBeDefined();
expect(user.id).toBeDefined();
users.push(user);
console.log(`✅ Created user ${user.username} on ${nodes[i].id}`);
}
expect(users).toHaveLength(3);
}, 45000);
test('should verify users are replicated across all nodes', async () => {
// Wait for replication
await blogTestHelper.sleep(3000);
for (const user of users) {
for (const node of blogTestHelper.getNodes()) {
const retrievedUser = await blogTestHelper.getUser(user.id!, node.id);
expect(retrievedUser).toBeDefined();
expect(retrievedUser!.id).toBe(user.id);
expect(retrievedUser!.username).toBe(user.username);
console.log(`✅ User ${user.username} found on ${node.id}`);
}
}
}, 45000);
test('should create categories on different nodes', async () => {
const nodes = blogTestHelper.getNodes();
for (let i = 0; i < nodes.length; i++) {
const testData = blogTestHelper.generateTestData();
const category = await blogTestHelper.createCategory(testData.category, nodes[i].id);
expect(category).toBeDefined();
expect(category.id).toBeDefined();
categories.push(category);
console.log(`✅ Created category ${category.name} on ${nodes[i].id}`);
}
expect(categories).toHaveLength(3);
}, 45000);
test('should create posts with cross-node relationships', async () => {
const nodes = blogTestHelper.getNodes();
// Create posts where author and category are from different nodes
for (let i = 0; i < nodes.length; i++) {
const authorIndex = i;
const categoryIndex = (i + 1) % nodes.length; // Use next node's category
const nodeIndex = (i + 2) % nodes.length; // Create on third node
const testData = blogTestHelper.generateTestData();
const postData = testData.post(users[authorIndex].id!, categories[categoryIndex].id!);
const post = await blogTestHelper.createPost(postData, nodes[nodeIndex].id);
expect(post).toBeDefined();
expect(post.id).toBeDefined();
expect(post.authorId).toBe(users[authorIndex].id);
expect(post.categoryId).toBe(categories[categoryIndex].id);
posts.push(post);
console.log(
`✅ Created post "${post.title}" on ${nodes[nodeIndex].id} ` +
`(author from node-${authorIndex + 1}, category from node-${categoryIndex + 1})`
);
}
expect(posts).toHaveLength(3);
}, 45000);
test('should verify cross-node posts are accessible from all nodes', async () => {
// Wait for replication
await blogTestHelper.sleep(3000);
for (const post of posts) {
for (const node of blogTestHelper.getNodes()) {
const retrievedPost = await blogTestHelper.getPost(post.id!, node.id);
expect(retrievedPost).toBeDefined();
expect(retrievedPost!.id).toBe(post.id);
expect(retrievedPost!.title).toBe(post.title);
expect(retrievedPost!.authorId).toBe(post.authorId);
expect(retrievedPost!.categoryId).toBe(post.categoryId);
}
}
console.log('✅ All cross-node posts are accessible from all nodes');
}, 45000);
});
describe('Concurrent Operations', () => {
test('should handle concurrent likes on same post from different nodes', async () => {
const post = posts[0];
const nodes = blogTestHelper.getNodes();
// Perform concurrent likes from all nodes
const likePromises = nodes.map(node =>
blogTestHelper.likePost(post.id!, node.id)
);
const results = await Promise.all(likePromises);
// All should succeed
results.forEach(result => {
expect(result).toBeDefined();
expect(result.likeCount).toBeGreaterThan(0);
});
// Wait for eventual consistency
await blogTestHelper.sleep(3000);
// Verify final like count is consistent across nodes
const finalCounts: number[] = [];
for (const node of nodes) {
const updatedPost = await blogTestHelper.getPost(post.id!, node.id);
expect(updatedPost).toBeDefined();
finalCounts.push(updatedPost!.likeCount || 0);
}
// All nodes should have the same final count
const uniqueCounts = [...new Set(finalCounts)];
expect(uniqueCounts).toHaveLength(1);
console.log(`✅ Concurrent likes handled, final count: ${finalCounts[0]}`);
}, 45000);
test('should handle simultaneous comment creation', async () => {
const post = posts[1];
const nodes = blogTestHelper.getNodes();
// Create comments simultaneously from different nodes
const commentPromises = nodes.map((node, index) => {
const testData = blogTestHelper.generateTestData();
const commentData = testData.comment(post.id!, users[index].id!);
return blogTestHelper.createComment(commentData, node.id);
});
const comments = await Promise.all(commentPromises);
// All comments should be created successfully
comments.forEach((comment, index) => {
expect(comment).toBeDefined();
expect(comment.id).toBeDefined();
expect(comment.postId).toBe(post.id);
expect(comment.authorId).toBe(users[index].id);
});
// Wait for replication
await blogTestHelper.sleep(3000);
// Verify all comments are visible from all nodes
for (const node of nodes) {
const result = await blogTestHelper.getPostComments(post.id!, node.id);
expect(result.comments.length).toBeGreaterThanOrEqual(3);
// Verify all our comments are present
for (const comment of comments) {
const found = result.comments.find(c => c.id === comment.id);
expect(found).toBeDefined();
}
}
console.log(`✅ Created ${comments.length} simultaneous comments`);
}, 45000);
});
describe('Load Distribution', () => {
test('should distribute read operations across nodes', async () => {
const readCounts = new Map<string, number>();
const totalReads = 30;
// Perform multiple reads and track which nodes are used
for (let i = 0; i < totalReads; i++) {
const randomPost = posts[Math.floor(Math.random() * posts.length)];
const node = blogTestHelper.getRandomNode();
const post = await blogTestHelper.getPost(randomPost.id!, node.id);
expect(post).toBeDefined();
readCounts.set(node.id, (readCounts.get(node.id) || 0) + 1);
}
// Verify reads were distributed across nodes
const nodeIds = blogTestHelper.getNodes().map(n => n.id);
nodeIds.forEach(nodeId => {
const count = readCounts.get(nodeId) || 0;
console.log(`${nodeId}: ${count} reads`);
expect(count).toBeGreaterThan(0); // Each node should have at least one read
});
console.log('✅ Read operations distributed across all nodes');
}, 45000);
test('should verify consistent data across all read operations', async () => {
// Read the same post from all nodes multiple times
const post = posts[0];
const readResults: TestPost[] = [];
for (let i = 0; i < 10; i++) {
for (const node of blogTestHelper.getNodes()) {
const result = await blogTestHelper.getPost(post.id!, node.id);
expect(result).toBeDefined();
readResults.push(result!);
}
}
// Verify all reads return identical data
readResults.forEach(result => {
expect(result.id).toBe(post.id);
expect(result.title).toBe(post.title);
expect(result.content).toBe(post.content);
expect(result.authorId).toBe(post.authorId);
expect(result.categoryId).toBe(post.categoryId);
});
console.log(`${readResults.length} read operations returned consistent data`);
}, 45000);
});
describe('Network Metrics', () => {
test('should show network connectivity between nodes', async () => {
for (const node of blogTestHelper.getNodes()) {
const metrics = await blogTestHelper.getNodeMetrics(node.id);
expect(metrics).toBeDefined();
expect(metrics.nodeId).toBe(node.id);
console.log(`📊 ${node.id} framework metrics:`, {
services: metrics.services,
environment: metrics.environment,
features: metrics.features
});
}
}, 30000);
test('should verify data consistency across all nodes', async () => {
const allMetrics = [];
for (const node of blogTestHelper.getNodes()) {
const metrics = await blogTestHelper.getDataMetrics(node.id);
allMetrics.push(metrics);
console.log(`📊 ${node.id} data counts:`, metrics.counts);
}
// Verify all nodes have the same data counts (eventual consistency)
const firstCounts = allMetrics[0].counts;
allMetrics.forEach((metrics, index) => {
expect(metrics.counts.users).toBe(firstCounts.users);
expect(metrics.counts.categories).toBe(firstCounts.categories);
expect(metrics.counts.posts).toBe(firstCounts.posts);
console.log(`✅ Node ${index + 1} data counts match reference`);
});
console.log('✅ Data consistency verified across all nodes');
}, 30000);
});
});

View File

@ -0,0 +1,19 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.ts',
'!**/*.d.ts',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testTimeout: 120000, // 2 minutes default timeout
maxWorkers: 1, // Run tests sequentially to avoid conflicts
verbose: true,
detectOpenHandles: true,
forceExit: true,
};

View File

@ -0,0 +1,20 @@
// Global test setup
console.log('🚀 Starting Blog Integration Tests');
console.log('📡 Target nodes: blog-node-1, blog-node-2, blog-node-3');
console.log('⏰ Test timeout: 120 seconds');
console.log('=====================================');
// Increase timeout for all tests
jest.setTimeout(120000);
// Global error handler
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
// Clean up console logs for better readability
const originalLog = console.log;
console.log = (...args) => {
const timestamp = new Date().toISOString();
originalLog(`[${timestamp}]`, ...args);
};

View File

@ -0,0 +1,23 @@
{
"name": "blog-integration-tests",
"version": "1.0.0",
"description": "Integration tests for blog scenario",
"main": "index.js",
"scripts": {
"test": "jest --config jest.config.js",
"test:basic": "jest --config jest.config.js basic-operations.test.ts",
"test:cross-node": "jest --config jest.config.js cross-node-operations.test.ts",
"test:watch": "jest --config jest.config.js --watch",
"test:coverage": "jest --config jest.config.js --coverage"
},
"dependencies": {
"axios": "^1.6.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,306 @@
import axios, { AxiosResponse } from 'axios';
export interface TestNode {
id: string;
baseUrl: string;
port: number;
}
export interface TestUser {
id?: string;
username: string;
email: string;
displayName?: string;
avatar?: string;
roles?: string[];
}
export interface TestCategory {
id?: string;
name: string;
description?: string;
color?: string;
}
export interface TestPost {
id?: string;
title: string;
content: string;
excerpt?: string;
authorId: string;
categoryId?: string;
tags?: string[];
status?: 'draft' | 'published' | 'archived';
}
export interface TestComment {
id?: string;
content: string;
postId: string;
authorId: string;
parentId?: string;
}
export class BlogTestHelper {
private nodes: TestNode[];
private timeout: number;
constructor() {
this.nodes = [
{ id: 'blog-node-1', baseUrl: 'http://blog-node-1:3000', port: 3000 },
{ id: 'blog-node-2', baseUrl: 'http://blog-node-2:3000', port: 3000 },
{ id: 'blog-node-3', baseUrl: 'http://blog-node-3:3000', port: 3000 }
];
this.timeout = 30000; // 30 seconds
}
getNodes(): TestNode[] {
return this.nodes;
}
getRandomNode(): TestNode {
return this.nodes[Math.floor(Math.random() * this.nodes.length)];
}
async waitForNodesReady(): Promise<void> {
const maxRetries = 30;
const retryDelay = 1000;
for (const node of this.nodes) {
let retries = 0;
let healthy = false;
while (retries < maxRetries && !healthy) {
try {
const response = await axios.get(`${node.baseUrl}/health`, {
timeout: 5000
});
if (response.status === 200 && response.data.status === 'healthy') {
console.log(`✅ Node ${node.id} is healthy`);
healthy = true;
}
} catch (error) {
retries++;
console.log(`⏳ Waiting for ${node.id} to be ready (attempt ${retries}/${maxRetries})`);
await this.sleep(retryDelay);
}
}
if (!healthy) {
throw new Error(`Node ${node.id} failed to become healthy after ${maxRetries} attempts`);
}
}
// Additional wait for inter-node connectivity
console.log('⏳ Waiting for nodes to establish connectivity...');
await this.sleep(5000);
}
async sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// User operations
async createUser(user: TestUser, nodeId?: string): Promise<TestUser> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/users`, user, {
timeout: this.timeout
});
return response.data;
}
async getUser(userId: string, nodeId?: string): Promise<TestUser | null> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
try {
const response = await axios.get(`${node.baseUrl}/api/users/${userId}`, {
timeout: this.timeout
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
async listUsers(nodeId?: string, params?: any): Promise<{ users: TestUser[], page: number, limit: number }> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.get(`${node.baseUrl}/api/users`, {
params,
timeout: this.timeout
});
return response.data;
}
// Category operations
async createCategory(category: TestCategory, nodeId?: string): Promise<TestCategory> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/categories`, category, {
timeout: this.timeout
});
return response.data;
}
async getCategory(categoryId: string, nodeId?: string): Promise<TestCategory | null> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
try {
const response = await axios.get(`${node.baseUrl}/api/categories/${categoryId}`, {
timeout: this.timeout
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
async listCategories(nodeId?: string): Promise<{ categories: TestCategory[] }> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.get(`${node.baseUrl}/api/categories`, {
timeout: this.timeout
});
return response.data;
}
// Post operations
async createPost(post: TestPost, nodeId?: string): Promise<TestPost> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/posts`, post, {
timeout: this.timeout
});
return response.data;
}
async getPost(postId: string, nodeId?: string): Promise<TestPost | null> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
try {
const response = await axios.get(`${node.baseUrl}/api/posts/${postId}`, {
timeout: this.timeout
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
async publishPost(postId: string, nodeId?: string): Promise<TestPost> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/posts/${postId}/publish`, {}, {
timeout: this.timeout
});
return response.data;
}
async likePost(postId: string, nodeId?: string): Promise<{ likeCount: number }> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/posts/${postId}/like`, {}, {
timeout: this.timeout
});
return response.data;
}
// Comment operations
async createComment(comment: TestComment, nodeId?: string): Promise<TestComment> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.post(`${node.baseUrl}/api/comments`, comment, {
timeout: this.timeout
});
return response.data;
}
async getPostComments(postId: string, nodeId?: string): Promise<{ comments: TestComment[] }> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.get(`${node.baseUrl}/api/posts/${postId}/comments`, {
timeout: this.timeout
});
return response.data;
}
// Metrics and health
async getNodeMetrics(nodeId: string): Promise<any> {
const node = this.getNodeById(nodeId);
const response = await axios.get(`${node.baseUrl}/api/metrics/framework`, {
timeout: this.timeout
});
return response.data;
}
async getDataMetrics(nodeId?: string): Promise<any> {
const node = nodeId ? this.getNodeById(nodeId) : this.getRandomNode();
const response = await axios.get(`${node.baseUrl}/api/metrics/data`, {
timeout: this.timeout
});
return response.data;
}
// Utility methods
private getNodeById(nodeId: string): TestNode {
const node = this.nodes.find(n => n.id === nodeId);
if (!node) {
throw new Error(`Node with id ${nodeId} not found`);
}
return node;
}
async waitForDataReplication(
checkFunction: () => Promise<boolean>,
maxWaitMs: number = 10000,
intervalMs: number = 500
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
if (await checkFunction()) {
return;
}
await this.sleep(intervalMs);
}
throw new Error(`Data replication timeout after ${maxWaitMs}ms`);
}
generateTestData() {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
return {
user: {
username: `testuser_${random}`,
email: `test_${random}@example.com`,
displayName: `Test User ${random}`,
roles: ['user']
} as TestUser,
category: {
name: `Test Category ${random}`,
description: `Test category created at ${timestamp}`,
color: '#ff0000'
} as TestCategory,
post: (authorId: string, categoryId?: string) => ({
title: `Test Post ${random}`,
content: `This is test content created at ${timestamp}`,
excerpt: `Test excerpt ${random}`,
authorId,
categoryId,
tags: ['test', 'integration'],
status: 'draft' as const
} as TestPost),
comment: (postId: string, authorId: string) => ({
content: `Test comment created at ${timestamp}`,
postId,
authorId
} as TestComment)
};
}
}
export const blogTestHelper = new BlogTestHelper();