diff --git a/src/framework/models/BaseModel.ts b/src/framework/models/BaseModel.ts index fc7ec20..ec1f07f 100644 --- a/src/framework/models/BaseModel.ts +++ b/src/framework/models/BaseModel.ts @@ -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}`; diff --git a/src/framework/models/decorators/Field.ts b/src/framework/models/decorators/Field.ts index 183d926..8aa91e4 100644 --- a/src/framework/models/decorators/Field.ts +++ b/src/framework/models/decorators/Field.ts @@ -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, + }); }; } diff --git a/src/framework/models/decorators/relationships.ts b/src/framework/models/decorators/relationships.ts index f2f90a7..649bbe3 100644 --- a/src/framework/models/decorators/relationships.ts +++ b/src/framework/models/decorators/relationships.ts @@ -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 diff --git a/tests/real-integration/blog-scenario/tests/basic-operations.test.ts b/tests/real-integration/blog-scenario/tests/basic-operations.test.ts new file mode 100644 index 0000000..40d5f64 --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/basic-operations.test.ts @@ -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); + }); +}); diff --git a/tests/real-integration/blog-scenario/tests/cross-node-operations.test.ts b/tests/real-integration/blog-scenario/tests/cross-node-operations.test.ts new file mode 100644 index 0000000..45ef03a --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/cross-node-operations.test.ts @@ -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(); + 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); + }); +}); diff --git a/tests/real-integration/blog-scenario/tests/jest.config.js b/tests/real-integration/blog-scenario/tests/jest.config.js new file mode 100644 index 0000000..795c320 --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + ], + setupFilesAfterEnv: ['/jest.setup.js'], + testTimeout: 120000, // 2 minutes default timeout + maxWorkers: 1, // Run tests sequentially to avoid conflicts + verbose: true, + detectOpenHandles: true, + forceExit: true, +}; diff --git a/tests/real-integration/blog-scenario/tests/jest.setup.js b/tests/real-integration/blog-scenario/tests/jest.setup.js new file mode 100644 index 0000000..bfa0f32 --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/jest.setup.js @@ -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); +}; diff --git a/tests/real-integration/blog-scenario/tests/package.json b/tests/real-integration/blog-scenario/tests/package.json new file mode 100644 index 0000000..94eda93 --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/package.json @@ -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" + } +} diff --git a/tests/real-integration/blog-scenario/tests/setup.ts b/tests/real-integration/blog-scenario/tests/setup.ts new file mode 100644 index 0000000..9cc2dce --- /dev/null +++ b/tests/real-integration/blog-scenario/tests/setup.ts @@ -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 { + 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 { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // User operations + async createUser(user: TestUser, nodeId?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + maxWaitMs: number = 10000, + intervalMs: number = 500 + ): Promise { + 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();