Add unit tests for RelationshipManager and ShardManager

- Implement comprehensive tests for RelationshipManager covering various relationship types (BelongsTo, HasMany, HasOne, ManyToMany) and eager loading functionality.
- Include caching mechanisms and error handling in RelationshipManager tests.
- Create unit tests for ShardManager to validate shard creation, routing, management, global index operations, and query functionalities.
- Ensure tests cover different sharding strategies (hash, range, user) and handle edge cases like errors and non-existent models.
This commit is contained in:
anonpenguin 2025-06-19 11:20:13 +03:00
parent 067e462339
commit 1cbca09352
18 changed files with 11048 additions and 4815 deletions

1
.gitignore vendored
View File

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

34
jest.config.mjs Normal file
View File

@ -0,0 +1,34 @@
export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
transform: {
'^.+\\.ts$': ['ts-jest', {
useESM: true
}],
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/examples/**',
],
coverageDirectory: 'coverage',
coverageReporters: [
'text',
'lcov',
'html'
],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testTimeout: 30000,
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@orbitdb/core$': '<rootDir>/tests/mocks/orbitdb.ts',
'^@helia/helia$': '<rootDir>/tests/mocks/ipfs.ts',
},
};

View File

@ -18,7 +18,13 @@
"prepare": "husky",
"lint": "npx eslint src",
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
"lint:fix": "npx eslint src --fix"
"lint:fix": "npx eslint src --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"test:e2e": "jest tests/e2e"
},
"keywords": [
"ipfs",
@ -60,8 +66,10 @@
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@jest/globals": "^30.0.1",
"@orbitdb/core-types": "^1.0.14",
"@types/express": "^5.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^22.13.10",
"@types/node-forge": "^1.3.11",
"@typescript-eslint/eslint-plugin": "^8.29.0",
@ -71,9 +79,11 @@
"eslint-plugin-prettier": "^5.2.6",
"globals": "^16.0.0",
"husky": "^8.0.3",
"jest": "^30.0.1",
"lint-staged": "^15.5.0",
"prettier": "^3.5.3",
"rimraf": "^5.0.5",
"ts-jest": "^29.4.0",
"tsc-esm-fix": "^3.1.2",
"typescript": "^5.8.2",
"typescript-eslint": "^8.29.0"

8451
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1646
system.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,996 @@
import { describe, beforeEach, afterEach, it, expect, jest } from '@jest/globals';
import { DebrosFramework } from '../../src/framework/DebrosFramework';
import { BaseModel } from '../../src/framework/models/BaseModel';
import { Model, Field, HasMany, BelongsTo, HasOne, BeforeCreate, AfterCreate } from '../../src/framework/models/decorators';
import { createMockServices } from '../mocks/services';
// Complete Blog Example Models
@Model({
scope: 'global',
type: 'docstore'
})
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: true })
password: string; // In real app, this would be hashed
@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() {
this.lastLoginAt = Date.now();
await this.save();
}
async changePassword(newPassword: string) {
// In a real app, this would hash the password
this.password = newPassword;
await this.save();
}
}
@Model({
scope: 'global',
type: 'docstore'
})
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[];
@BelongsTo(() => User, 'userId')
user: User;
}
@Model({
scope: 'global',
type: 'docstore'
})
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;
@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, '');
}
}
}
@Model({
scope: 'user',
type: 'docstore'
})
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;
}
@AfterCreate()
generateSlugIfNeeded() {
if (!this.slug && this.title) {
this.slug = this.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') + '-' + this.id.slice(-8);
}
}
// Helper methods
async publish() {
this.status = 'published';
this.publishedAt = Date.now();
this.updatedAt = Date.now();
await this.save();
}
async unpublish() {
this.status = 'draft';
this.publishedAt = undefined;
this.updatedAt = Date.now();
await this.save();
}
async incrementViews() {
this.viewCount += 1;
await this.save();
}
async like() {
this.likeCount += 1;
await this.save();
}
async unlike() {
if (this.likeCount > 0) {
this.likeCount -= 1;
await this.save();
}
}
}
@Model({
scope: 'user',
type: 'docstore'
})
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() {
this.isApproved = true;
this.updatedAt = Date.now();
await this.save();
}
async like() {
this.likeCount += 1;
await this.save();
}
}
describe('Blog Example - End-to-End Tests', () => {
let framework: DebrosFramework;
let mockServices: any;
beforeEach(async () => {
mockServices = createMockServices();
framework = new DebrosFramework({
environment: 'test',
features: {
autoMigration: false,
automaticPinning: false,
pubsub: false,
queryCache: true,
relationshipCache: true
}
});
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
// Suppress console output for cleaner test output
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
});
afterEach(async () => {
if (framework) {
await framework.cleanup();
}
jest.restoreAllMocks();
});
describe('User Management', () => {
it('should create and manage users', async () => {
// Create a new user
const user = await User.create({
username: 'johndoe',
email: 'john@example.com',
password: 'secure123',
displayName: 'John Doe',
roles: ['author']
});
expect(user).toBeInstanceOf(User);
expect(user.username).toBe('johndoe');
expect(user.email).toBe('john@example.com');
expect(user.displayName).toBe('John Doe');
expect(user.isActive).toBe(true);
expect(user.roles).toEqual(['author']);
expect(user.createdAt).toBeDefined();
expect(user.id).toBeDefined();
});
it('should create user profile', async () => {
const user = await User.create({
username: 'janedoe',
email: 'jane@example.com',
password: 'secure456'
});
const profile = await UserProfile.create({
userId: user.id,
bio: 'Software developer and blogger',
location: 'San Francisco, CA',
website: 'https://janedoe.com',
socialLinks: {
twitter: '@janedoe',
github: 'janedoe'
},
interests: ['javascript', 'web development', 'open source']
});
expect(profile).toBeInstanceOf(UserProfile);
expect(profile.userId).toBe(user.id);
expect(profile.bio).toBe('Software developer and blogger');
expect(profile.socialLinks?.twitter).toBe('@janedoe');
expect(profile.interests).toContain('javascript');
});
it('should handle user authentication workflow', async () => {
const user = await User.create({
username: 'authuser',
email: 'auth@example.com',
password: 'original123'
});
// Simulate login
await user.updateLastLogin();
expect(user.lastLoginAt).toBeDefined();
// Change password
await user.changePassword('newpassword456');
expect(user.password).toBe('newpassword456');
});
});
describe('Content Management', () => {
let author: User;
let category: Category;
beforeEach(async () => {
author = await User.create({
username: 'contentauthor',
email: 'author@example.com',
password: 'authorpass',
roles: ['author', 'editor']
});
category = await Category.create({
name: 'Technology',
description: 'Posts about technology and programming'
});
});
it('should create and manage categories', async () => {
expect(category).toBeInstanceOf(Category);
expect(category.name).toBe('Technology');
expect(category.slug).toBe('technology');
expect(category.description).toBe('Posts about technology and programming');
expect(category.isActive).toBe(true);
});
it('should create draft posts', async () => {
const post = await Post.create({
title: 'My First Blog Post',
content: 'This is the content of my first blog post. It contains valuable information about web development.',
excerpt: 'Learn about web development in this comprehensive guide.',
authorId: author.id,
categoryId: category.id,
tags: ['web development', 'tutorial', 'beginner'],
featuredImage: 'https://example.com/image.jpg'
});
expect(post).toBeInstanceOf(Post);
expect(post.title).toBe('My First Blog Post');
expect(post.status).toBe('draft'); // Default status
expect(post.authorId).toBe(author.id);
expect(post.categoryId).toBe(category.id);
expect(post.tags).toEqual(['web development', 'tutorial', 'beginner']);
expect(post.viewCount).toBe(0);
expect(post.likeCount).toBe(0);
expect(post.createdAt).toBeDefined();
expect(post.slug).toBeDefined();
});
it('should publish and unpublish posts', async () => {
const post = await Post.create({
title: 'Publishing Test Post',
content: 'This post will be published and then unpublished.',
authorId: author.id
});
// Initially draft
expect(post.status).toBe('draft');
expect(post.publishedAt).toBeUndefined();
// Publish the post
await post.publish();
expect(post.status).toBe('published');
expect(post.publishedAt).toBeDefined();
// Unpublish the post
await post.unpublish();
expect(post.status).toBe('draft');
expect(post.publishedAt).toBeUndefined();
});
it('should track post engagement', async () => {
const post = await Post.create({
title: 'Engagement Test Post',
content: 'This post will test engagement tracking.',
authorId: author.id
});
// Track views
await post.incrementViews();
await post.incrementViews();
expect(post.viewCount).toBe(2);
// Track likes
await post.like();
await post.like();
expect(post.likeCount).toBe(2);
// Unlike
await post.unlike();
expect(post.likeCount).toBe(1);
});
});
describe('Comment System', () => {
let author: User;
let commenter: User;
let post: Post;
beforeEach(async () => {
author = await User.create({
username: 'postauthor',
email: 'postauthor@example.com',
password: 'authorpass'
});
commenter = await User.create({
username: 'commenter',
email: 'commenter@example.com',
password: 'commenterpass'
});
post = await Post.create({
title: 'Post with Comments',
content: 'This post will have comments.',
authorId: author.id
});
await post.publish();
});
it('should create comments on posts', async () => {
const comment = await Comment.create({
content: 'This is a great post! Thanks for sharing.',
postId: post.id,
authorId: commenter.id
});
expect(comment).toBeInstanceOf(Comment);
expect(comment.content).toBe('This is a great post! Thanks for sharing.');
expect(comment.postId).toBe(post.id);
expect(comment.authorId).toBe(commenter.id);
expect(comment.isApproved).toBe(true); // Default value
expect(comment.likeCount).toBe(0);
expect(comment.createdAt).toBeDefined();
});
it('should support nested comments (replies)', async () => {
// Create parent comment
const parentComment = await Comment.create({
content: 'This is the parent comment.',
postId: post.id,
authorId: commenter.id
});
// Create reply
const reply = await Comment.create({
content: 'This is a reply to the parent comment.',
postId: post.id,
authorId: author.id,
parentId: parentComment.id
});
expect(reply.parentId).toBe(parentComment.id);
expect(reply.content).toBe('This is a reply to the parent comment.');
});
it('should manage comment approval and engagement', async () => {
const comment = await Comment.create({
content: 'This comment needs approval.',
postId: post.id,
authorId: commenter.id,
isApproved: false
});
// Initially not approved
expect(comment.isApproved).toBe(false);
// Approve comment
await comment.approve();
expect(comment.isApproved).toBe(true);
// Like comment
await comment.like();
expect(comment.likeCount).toBe(1);
});
});
describe('Content Discovery and Queries', () => {
let authors: User[];
let categories: Category[];
let posts: Post[];
beforeEach(async () => {
// Create test authors
authors = [];
for (let i = 0; i < 3; i++) {
const author = await User.create({
username: `author${i}`,
email: `author${i}@example.com`,
password: 'password123'
});
authors.push(author);
}
// Create test categories
categories = [];
const categoryNames = ['Technology', 'Design', 'Business'];
for (const name of categoryNames) {
const category = await Category.create({
name,
description: `Posts about ${name.toLowerCase()}`
});
categories.push(category);
}
// Create test posts
posts = [];
for (let i = 0; i < 6; i++) {
const post = await Post.create({
title: `Test Post ${i + 1}`,
content: `This is the content of test post ${i + 1}.`,
authorId: authors[i % authors.length].id,
categoryId: categories[i % categories.length].id,
tags: [`tag${i}`, `common-tag`],
status: i % 2 === 0 ? 'published' : 'draft'
});
if (post.status === 'published') {
await post.publish();
}
posts.push(post);
}
});
it('should query posts by status', async () => {
const publishedQuery = Post.query().where('status', 'published');
const draftQuery = Post.query().where('status', 'draft');
// These would work in a real implementation with actual database queries
expect(publishedQuery).toBeDefined();
expect(draftQuery).toBeDefined();
expect(typeof publishedQuery.find).toBe('function');
expect(typeof draftQuery.count).toBe('function');
});
it('should query posts by author', async () => {
const authorQuery = Post.query().where('authorId', authors[0].id);
expect(authorQuery).toBeDefined();
expect(typeof authorQuery.find).toBe('function');
});
it('should query posts by category', async () => {
const categoryQuery = Post.query().where('categoryId', categories[0].id);
expect(categoryQuery).toBeDefined();
expect(typeof categoryQuery.orderBy).toBe('function');
});
it('should support complex queries with multiple conditions', async () => {
const complexQuery = Post.query()
.where('status', 'published')
.where('isFeatured', true)
.where('categoryId', categories[0].id)
.orderBy('publishedAt', 'desc')
.limit(10);
expect(complexQuery).toBeDefined();
expect(typeof complexQuery.find).toBe('function');
expect(typeof complexQuery.count).toBe('function');
});
it('should query posts by tags', async () => {
const tagQuery = Post.query()
.where('tags', 'includes', 'common-tag')
.where('status', 'published')
.orderBy('publishedAt', 'desc');
expect(tagQuery).toBeDefined();
});
});
describe('Relationships and Data Loading', () => {
let user: User;
let profile: UserProfile;
let category: Category;
let post: Post;
let comments: Comment[];
beforeEach(async () => {
// Create user with profile
user = await User.create({
username: 'relationuser',
email: 'relation@example.com',
password: 'password123'
});
profile = await UserProfile.create({
userId: user.id,
bio: 'I am a test user for relationship testing',
interests: ['testing', 'relationships']
});
// Create category and post
category = await Category.create({
name: 'Relationships',
description: 'Testing relationships'
});
post = await Post.create({
title: 'Post with Relationships',
content: 'This post tests relationship loading.',
authorId: user.id,
categoryId: category.id
});
await post.publish();
// Create comments
comments = [];
for (let i = 0; i < 3; i++) {
const comment = await Comment.create({
content: `Comment ${i + 1} on the post.`,
postId: post.id,
authorId: user.id
});
comments.push(comment);
}
});
it('should load user relationships', async () => {
const relationshipManager = framework.getRelationshipManager();
// Load user's posts
const userPosts = await relationshipManager!.loadRelationship(user, 'posts');
expect(Array.isArray(userPosts)).toBe(true);
// Load user's profile
const userProfile = await relationshipManager!.loadRelationship(user, 'profile');
// Mock implementation might return null, but the method should work
expect(userProfile === null || userProfile instanceof UserProfile).toBe(true);
// Load user's comments
const userComments = await relationshipManager!.loadRelationship(user, 'comments');
expect(Array.isArray(userComments)).toBe(true);
});
it('should load post relationships', async () => {
const relationshipManager = framework.getRelationshipManager();
// Load post's author
const postAuthor = await relationshipManager!.loadRelationship(post, 'author');
// Mock might return null, but relationship should be loadable
expect(postAuthor === null || postAuthor instanceof User).toBe(true);
// Load post's category
const postCategory = await relationshipManager!.loadRelationship(post, 'category');
expect(postCategory === null || postCategory instanceof Category).toBe(true);
// Load post's comments
const postComments = await relationshipManager!.loadRelationship(post, 'comments');
expect(Array.isArray(postComments)).toBe(true);
});
it('should support eager loading of multiple relationships', async () => {
const relationshipManager = framework.getRelationshipManager();
// Eager load multiple relationships on multiple posts
await relationshipManager!.eagerLoadRelationships(
[post],
['author', 'category', 'comments']
);
// Relationships should be available through the loaded relations
expect(post._loadedRelations.size).toBeGreaterThan(0);
});
it('should handle nested relationships', async () => {
const relationshipManager = framework.getRelationshipManager();
// Load comments first
const postComments = await relationshipManager!.loadRelationship(post, 'comments');
if (Array.isArray(postComments) && postComments.length > 0) {
// Load author relationship on first comment
const commentAuthor = await relationshipManager!.loadRelationship(postComments[0], 'author');
expect(commentAuthor === null || commentAuthor instanceof User).toBe(true);
}
});
});
describe('Blog Workflow Integration', () => {
it('should support complete blog publishing workflow', async () => {
// 1. Create author
const author = await User.create({
username: 'blogauthor',
email: 'blog@example.com',
password: 'blogpass',
displayName: 'Blog Author',
roles: ['author']
});
// 2. Create author profile
const profile = await UserProfile.create({
userId: author.id,
bio: 'Professional blogger and writer',
website: 'https://blogauthor.com'
});
// 3. Create category
const category = await Category.create({
name: 'Web Development',
description: 'Posts about web development and programming'
});
// 4. Create draft post
const post = await Post.create({
title: 'Advanced JavaScript Techniques',
content: 'In this post, we will explore advanced JavaScript techniques...',
excerpt: 'Learn advanced JavaScript techniques to improve your code.',
authorId: author.id,
categoryId: category.id,
tags: ['javascript', 'advanced', 'programming'],
featuredImage: 'https://example.com/js-advanced.jpg'
});
expect(post.status).toBe('draft');
// 5. Publish the post
await post.publish();
expect(post.status).toBe('published');
expect(post.publishedAt).toBeDefined();
// 6. Reader discovers and engages with post
await post.incrementViews();
await post.like();
expect(post.viewCount).toBe(1);
expect(post.likeCount).toBe(1);
// 7. Create reader and comment
const reader = await User.create({
username: 'reader',
email: 'reader@example.com',
password: 'readerpass'
});
const comment = await Comment.create({
content: 'Great post! Very helpful information.',
postId: post.id,
authorId: reader.id
});
// 8. Author replies to comment
const reply = await Comment.create({
content: 'Thank you for the feedback! Glad you found it helpful.',
postId: post.id,
authorId: author.id,
parentId: comment.id
});
// Verify the complete workflow
expect(author).toBeInstanceOf(User);
expect(profile).toBeInstanceOf(UserProfile);
expect(category).toBeInstanceOf(Category);
expect(post).toBeInstanceOf(Post);
expect(comment).toBeInstanceOf(Comment);
expect(reply).toBeInstanceOf(Comment);
expect(reply.parentId).toBe(comment.id);
});
it('should support content management operations', async () => {
const author = await User.create({
username: 'contentmgr',
email: 'mgr@example.com',
password: 'mgrpass'
});
// Create multiple posts
const posts = [];
for (let i = 0; i < 5; i++) {
const post = await Post.create({
title: `Management Post ${i + 1}`,
content: `Content for post ${i + 1}`,
authorId: author.id,
tags: [`tag${i}`]
});
posts.push(post);
}
// Publish some posts
await posts[0].publish();
await posts[2].publish();
await posts[4].publish();
// Feature a post
posts[0].isFeatured = true;
await posts[0].save();
// Archive a post
posts[1].status = 'archived';
await posts[1].save();
// Verify post states
expect(posts[0].status).toBe('published');
expect(posts[0].isFeatured).toBe(true);
expect(posts[1].status).toBe('archived');
expect(posts[2].status).toBe('published');
expect(posts[3].status).toBe('draft');
});
});
describe('Performance and Scalability', () => {
it('should handle bulk operations efficiently', async () => {
const startTime = Date.now();
// Create multiple users concurrently
const userPromises = [];
for (let i = 0; i < 10; i++) {
userPromises.push(User.create({
username: `bulkuser${i}`,
email: `bulk${i}@example.com`,
password: 'bulkpass'
}));
}
const users = await Promise.all(userPromises);
expect(users).toHaveLength(10);
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete reasonably quickly (less than 1 second for mocked operations)
expect(duration).toBeLessThan(1000);
});
it('should support concurrent read operations', async () => {
const author = await User.create({
username: 'concurrentauthor',
email: 'concurrent@example.com',
password: 'concurrentpass'
});
const post = await Post.create({
title: 'Concurrent Read Test',
content: 'Testing concurrent reads',
authorId: author.id
});
// Simulate concurrent reads
const readPromises = [];
for (let i = 0; i < 5; i++) {
readPromises.push(post.incrementViews());
}
await Promise.all(readPromises);
// View count should reflect all increments
expect(post.viewCount).toBe(5);
});
});
describe('Data Integrity and Validation', () => {
it('should enforce required field validation', async () => {
await expect(User.create({
// Missing required fields username and email
password: 'password123'
} as any)).rejects.toThrow();
});
it('should enforce unique constraints', async () => {
await User.create({
username: 'uniqueuser',
email: 'unique@example.com',
password: 'password123'
});
// Attempt to create user with same username should fail
await expect(User.create({
username: 'uniqueuser', // Duplicate username
email: 'different@example.com',
password: 'password123'
})).rejects.toThrow();
});
it('should validate field types', async () => {
await expect(User.create({
username: 'typetest',
email: 'typetest@example.com',
password: 'password123',
isActive: 'not-a-boolean' as any // Invalid type
})).rejects.toThrow();
});
it('should apply default values correctly', async () => {
const user = await User.create({
username: 'defaultuser',
email: 'default@example.com',
password: 'password123'
});
expect(user.isActive).toBe(true); // Default value
expect(user.roles).toEqual([]); // Default array
const post = await Post.create({
title: 'Default Test',
content: 'Testing defaults',
authorId: user.id
});
expect(post.status).toBe('draft'); // Default status
expect(post.tags).toEqual([]); // Default array
expect(post.viewCount).toBe(0); // Default number
expect(post.isFeatured).toBe(false); // Default boolean
});
});
});

View File

@ -0,0 +1,536 @@
import { describe, beforeEach, afterEach, it, expect, jest } from '@jest/globals';
import { DebrosFramework, DebrosFrameworkConfig } from '../../src/framework/DebrosFramework';
import { BaseModel } from '../../src/framework/models/BaseModel';
import { Model, Field, HasMany, BelongsTo } from '../../src/framework/models/decorators';
import { createMockServices } from '../mocks/services';
// Test models for integration testing
@Model({
scope: 'global',
type: 'docstore'
})
class User extends BaseModel {
@Field({ type: 'string', required: true })
username: string;
@Field({ type: 'string', required: true })
email: string;
@Field({ type: 'boolean', required: false, default: true })
isActive: boolean;
@HasMany(() => Post, 'userId')
posts: Post[];
}
@Model({
scope: 'user',
type: 'docstore'
})
class Post extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
@Field({ type: 'string', required: true })
content: string;
@Field({ type: 'string', required: true })
userId: string;
@Field({ type: 'boolean', required: false, default: false })
published: boolean;
@BelongsTo(() => User, 'userId')
user: User;
}
describe('DebrosFramework Integration Tests', () => {
let framework: DebrosFramework;
let mockServices: any;
let config: DebrosFrameworkConfig;
beforeEach(() => {
mockServices = createMockServices();
config = {
environment: 'test',
features: {
autoMigration: false,
automaticPinning: false,
pubsub: false,
queryCache: true,
relationshipCache: true
},
performance: {
queryTimeout: 5000,
migrationTimeout: 30000,
maxConcurrentOperations: 10,
batchSize: 100
},
monitoring: {
enableMetrics: true,
logLevel: 'info',
metricsInterval: 1000
}
};
framework = new DebrosFramework(config);
// Suppress console output for cleaner test output
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
});
afterEach(async () => {
if (framework) {
await framework.cleanup();
}
jest.restoreAllMocks();
});
describe('Framework Initialization', () => {
it('should initialize successfully with valid services', async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
const status = framework.getStatus();
expect(status.initialized).toBe(true);
expect(status.healthy).toBe(true);
expect(status.environment).toBe('test');
expect(status.services.orbitdb).toBe('connected');
expect(status.services.ipfs).toBe('connected');
});
it('should throw error when already initialized', async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
await expect(
framework.initialize(mockServices.orbitDBService, mockServices.ipfsService)
).rejects.toThrow('Framework is already initialized');
});
it('should throw error without required services', async () => {
await expect(framework.initialize()).rejects.toThrow(
'IPFS service is required'
);
});
it('should handle initialization failures gracefully', async () => {
// Make IPFS service initialization fail
const failingIPFS = {
...mockServices.ipfsService,
init: jest.fn().mockRejectedValue(new Error('IPFS init failed'))
};
await expect(
framework.initialize(mockServices.orbitDBService, failingIPFS)
).rejects.toThrow('IPFS init failed');
const status = framework.getStatus();
expect(status.initialized).toBe(false);
expect(status.healthy).toBe(false);
});
it('should apply config overrides during initialization', async () => {
const overrideConfig = {
environment: 'production' as const,
features: { queryCache: false }
};
await framework.initialize(
mockServices.orbitDBService,
mockServices.ipfsService,
overrideConfig
);
const status = framework.getStatus();
expect(status.environment).toBe('production');
});
});
describe('Framework Lifecycle', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should provide access to core managers', () => {
expect(framework.getDatabaseManager()).toBeDefined();
expect(framework.getShardManager()).toBeDefined();
expect(framework.getRelationshipManager()).toBeDefined();
expect(framework.getQueryCache()).toBeDefined();
});
it('should provide access to services', () => {
expect(framework.getOrbitDBService()).toBeDefined();
expect(framework.getIPFSService()).toBeDefined();
});
it('should handle graceful shutdown', async () => {
const initialStatus = framework.getStatus();
expect(initialStatus.initialized).toBe(true);
await framework.stop();
const finalStatus = framework.getStatus();
expect(finalStatus.initialized).toBe(false);
});
it('should perform health checks', async () => {
const health = await framework.healthCheck();
expect(health.healthy).toBe(true);
expect(health.services.ipfs).toBe('connected');
expect(health.services.orbitdb).toBe('connected');
expect(health.lastHealthCheck).toBeGreaterThan(0);
});
it('should collect metrics', () => {
const metrics = framework.getMetrics();
expect(metrics).toHaveProperty('uptime');
expect(metrics).toHaveProperty('totalModels');
expect(metrics).toHaveProperty('totalDatabases');
expect(metrics).toHaveProperty('queriesExecuted');
expect(metrics).toHaveProperty('memoryUsage');
expect(metrics).toHaveProperty('performance');
});
});
describe('Model and Database Integration', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should integrate with model system for database operations', async () => {
// Create a user
const userData = {
username: 'testuser',
email: 'test@example.com',
isActive: true
};
const user = await User.create(userData);
expect(user).toBeInstanceOf(User);
expect(user.username).toBe('testuser');
expect(user.email).toBe('test@example.com');
expect(user.isActive).toBe(true);
expect(user.id).toBeDefined();
});
it('should handle user-scoped and global-scoped models differently', async () => {
// Global-scoped model (User)
const user = await User.create({
username: 'globaluser',
email: 'global@example.com'
});
// User-scoped model (Post) - should use user's database
const post = await Post.create({
title: 'Test Post',
content: 'This is a test post',
userId: user.id,
published: true
});
expect(user).toBeInstanceOf(User);
expect(post).toBeInstanceOf(Post);
expect(post.userId).toBe(user.id);
});
it('should support relationship loading', async () => {
const user = await User.create({
username: 'userWithPosts',
email: 'posts@example.com'
});
// Create posts for the user
await Post.create({
title: 'First Post',
content: 'Content 1',
userId: user.id
});
await Post.create({
title: 'Second Post',
content: 'Content 2',
userId: user.id
});
// Load user's posts
const relationshipManager = framework.getRelationshipManager();
const posts = await relationshipManager!.loadRelationship(user, 'posts');
expect(Array.isArray(posts)).toBe(true);
expect(posts.length).toBeGreaterThanOrEqual(0); // Mock may return empty array
});
});
describe('Query and Cache Integration', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should integrate query system with cache', async () => {
const queryCache = framework.getQueryCache();
expect(queryCache).toBeDefined();
// Test query caching
const cacheKey = 'test-query-key';
const testData = [{ id: '1', name: 'Test' }];
queryCache!.set(cacheKey, testData, 'User');
const cachedResult = queryCache!.get(cacheKey);
expect(cachedResult).toEqual(testData);
});
it('should support complex query building', () => {
const query = User.query()
.where('isActive', true)
.where('email', 'like', '%@example.com')
.orderBy('username', 'asc')
.limit(10);
expect(query).toBeDefined();
expect(typeof query.find).toBe('function');
expect(typeof query.count).toBe('function');
});
});
describe('Sharding Integration', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should integrate with shard manager for model distribution', () => {
const shardManager = framework.getShardManager();
expect(shardManager).toBeDefined();
// Test shard routing
const testKey = 'test-key-123';
const modelWithShards = 'TestModel';
// This would work if we had shards created for TestModel
expect(() => {
shardManager!.getShardCount(modelWithShards);
}).not.toThrow();
});
it('should support cross-shard queries', async () => {
const shardManager = framework.getShardManager();
// Test querying across all shards (mock implementation)
const queryFn = async (database: any) => {
return []; // Mock query result
};
// This would work if we had shards created
const models = shardManager!.getAllModelsWithShards();
expect(Array.isArray(models)).toBe(true);
});
});
describe('Migration Integration', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should integrate migration system', () => {
const migrationManager = framework.getMigrationManager();
expect(migrationManager).toBeDefined();
// Test migration registration
const testMigration = {
id: 'test-migration-1',
version: '1.0.0',
name: 'Test Migration',
description: 'A test migration',
targetModels: ['User'],
up: [{
type: 'add_field' as const,
modelName: 'User',
fieldName: 'newField',
fieldConfig: { type: 'string' as const, required: false }
}],
down: [{
type: 'remove_field' as const,
modelName: 'User',
fieldName: 'newField'
}],
createdAt: Date.now()
};
expect(() => {
migrationManager!.registerMigration(testMigration);
}).not.toThrow();
const registered = migrationManager!.getMigration(testMigration.id);
expect(registered).toEqual(testMigration);
});
it('should handle pending migrations', () => {
const migrationManager = framework.getMigrationManager();
const pendingMigrations = migrationManager!.getPendingMigrations();
expect(Array.isArray(pendingMigrations)).toBe(true);
});
});
describe('Error Handling and Recovery', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should handle service failures gracefully', async () => {
// Simulate OrbitDB service failure
const orbitDBService = framework.getOrbitDBService();
jest.spyOn(orbitDBService!, 'getOrbitDB').mockImplementation(() => {
throw new Error('OrbitDB service failed');
});
// Framework should still respond to health checks
const health = await framework.healthCheck();
expect(health).toBeDefined();
});
it('should provide error information in status', async () => {
const status = framework.getStatus();
expect(status).toHaveProperty('services');
expect(status.services).toHaveProperty('orbitdb');
expect(status.services).toHaveProperty('ipfs');
});
it('should support manual service recovery', async () => {
// Stop the framework
await framework.stop();
// Verify it's stopped
let status = framework.getStatus();
expect(status.initialized).toBe(false);
// Restart with new services
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
// Verify it's running again
status = framework.getStatus();
expect(status.initialized).toBe(true);
expect(status.healthy).toBe(true);
});
});
describe('Configuration Management', () => {
it('should merge default configuration correctly', () => {
const customConfig: DebrosFrameworkConfig = {
environment: 'production',
features: {
queryCache: false,
automaticPinning: true
},
performance: {
batchSize: 500
}
};
const customFramework = new DebrosFramework(customConfig);
const status = customFramework.getStatus();
expect(status.environment).toBe('production');
});
it('should support configuration updates', async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
const configManager = framework.getConfigManager();
expect(configManager).toBeDefined();
// Configuration should be accessible through the framework
const currentConfig = configManager!.getFullConfig();
expect(currentConfig).toBeDefined();
expect(currentConfig.environment).toBe('test');
});
});
describe('Performance and Monitoring', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should track uptime correctly', () => {
const metrics = framework.getMetrics();
expect(metrics.uptime).toBeGreaterThanOrEqual(0);
});
it('should collect performance metrics', () => {
const metrics = framework.getMetrics();
expect(metrics.performance).toBeDefined();
expect(metrics.performance.slowQueries).toBeDefined();
expect(metrics.performance.failedOperations).toBeDefined();
expect(metrics.performance.averageResponseTime).toBeDefined();
});
it('should track memory usage', () => {
const metrics = framework.getMetrics();
expect(metrics.memoryUsage).toBeDefined();
expect(metrics.memoryUsage.queryCache).toBeDefined();
expect(metrics.memoryUsage.relationshipCache).toBeDefined();
expect(metrics.memoryUsage.total).toBeDefined();
});
it('should provide detailed status information', () => {
const status = framework.getStatus();
expect(status.version).toBeDefined();
expect(status.lastHealthCheck).toBeGreaterThanOrEqual(0);
expect(status.services).toBeDefined();
});
});
describe('Concurrent Operations', () => {
beforeEach(async () => {
await framework.initialize(mockServices.orbitDBService, mockServices.ipfsService);
});
it('should handle concurrent model operations', async () => {
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(User.create({
username: `user${i}`,
email: `user${i}@example.com`
}));
}
const users = await Promise.all(promises);
expect(users).toHaveLength(5);
users.forEach((user, index) => {
expect(user.username).toBe(`user${index}`);
});
});
it('should handle concurrent relationship loading', async () => {
const user = await User.create({
username: 'concurrentUser',
email: 'concurrent@example.com'
});
const relationshipManager = framework.getRelationshipManager();
const promises = [
relationshipManager!.loadRelationship(user, 'posts'),
relationshipManager!.loadRelationship(user, 'posts'),
relationshipManager!.loadRelationship(user, 'posts')
];
const results = await Promise.all(promises);
expect(results).toHaveLength(3);
// Results should be consistent (either all arrays or all same result)
expect(Array.isArray(results[0])).toBe(Array.isArray(results[1]));
});
});
});

244
tests/mocks/ipfs.ts Normal file
View File

@ -0,0 +1,244 @@
// Mock IPFS for testing
export class MockLibp2p {
private peers = new Set<string>();
async start() {
// Mock start
}
async stop() {
// Mock stop
}
getPeers() {
return Array.from(this.peers);
}
async dial(peerId: string) {
this.peers.add(peerId);
return { remotePeer: peerId };
}
async hangUp(peerId: string) {
this.peers.delete(peerId);
}
get peerId() {
return { toString: () => 'mock-peer-id' };
}
// PubSub mock
pubsub = {
publish: jest.fn(async (topic: string, data: Uint8Array) => {
// Mock publish
}),
subscribe: jest.fn(async (topic: string) => {
// Mock subscribe
}),
unsubscribe: jest.fn(async (topic: string) => {
// Mock unsubscribe
}),
getTopics: jest.fn(() => []),
getPeers: jest.fn(() => [])
};
// Services mock
services = {
pubsub: this.pubsub
};
}
export class MockHelia {
public libp2p: MockLibp2p;
private content = new Map<string, Uint8Array>();
private pins = new Set<string>();
constructor() {
this.libp2p = new MockLibp2p();
}
async start() {
await this.libp2p.start();
}
async stop() {
await this.libp2p.stop();
}
get blockstore() {
return {
put: jest.fn(async (cid: any, block: Uint8Array) => {
const key = cid.toString();
this.content.set(key, block);
return cid;
}),
get: jest.fn(async (cid: any) => {
const key = cid.toString();
const block = this.content.get(key);
if (!block) {
throw new Error(`Block not found: ${key}`);
}
return block;
}),
has: jest.fn(async (cid: any) => {
return this.content.has(cid.toString());
}),
delete: jest.fn(async (cid: any) => {
return this.content.delete(cid.toString());
})
};
}
get datastore() {
return {
put: jest.fn(async (key: any, value: Uint8Array) => {
this.content.set(key.toString(), value);
}),
get: jest.fn(async (key: any) => {
const value = this.content.get(key.toString());
if (!value) {
throw new Error(`Key not found: ${key}`);
}
return value;
}),
has: jest.fn(async (key: any) => {
return this.content.has(key.toString());
}),
delete: jest.fn(async (key: any) => {
return this.content.delete(key.toString());
})
};
}
get pins() {
return {
add: jest.fn(async (cid: any) => {
this.pins.add(cid.toString());
}),
rm: jest.fn(async (cid: any) => {
this.pins.delete(cid.toString());
}),
ls: jest.fn(async function* () {
for (const pin of Array.from(this.pins)) {
yield { cid: pin };
}
}.bind(this))
};
}
// Add UnixFS mock
get fs() {
return {
addBytes: jest.fn(async (data: Uint8Array) => {
const cid = `mock-cid-${Date.now()}`;
this.content.set(cid, data);
return { toString: () => cid };
}),
cat: jest.fn(async function* (cid: any) {
const data = this.content.get(cid.toString());
if (data) {
yield data;
}
}.bind(this)),
addFile: jest.fn(async (file: any) => {
const cid = `mock-file-cid-${Date.now()}`;
return { toString: () => cid };
})
};
}
}
export const createHelia = jest.fn(async (options: any = {}) => {
const helia = new MockHelia();
await helia.start();
return helia;
});
export const createLibp2p = jest.fn(async (options: any = {}) => {
return new MockLibp2p();
});
// Mock IPFS service for framework
export class MockIPFSService {
private helia: MockHelia;
constructor() {
this.helia = new MockHelia();
}
async init() {
await this.helia.start();
}
async stop() {
await this.helia.stop();
}
getHelia() {
return this.helia;
}
getLibp2pInstance() {
return this.helia.libp2p;
}
async getConnectedPeers() {
const peers = this.helia.libp2p.getPeers();
const peerMap = new Map();
peers.forEach(peer => peerMap.set(peer, peer));
return peerMap;
}
async pinOnNode(nodeId: string, cid: string) {
await this.helia.pins.add(cid);
}
get pubsub() {
return {
publish: jest.fn(async (topic: string, data: string) => {
await this.helia.libp2p.pubsub.publish(topic, new TextEncoder().encode(data));
}),
subscribe: jest.fn(async (topic: string, handler: Function) => {
// Mock subscribe
}),
unsubscribe: jest.fn(async (topic: string) => {
// Mock unsubscribe
})
};
}
}
// Mock OrbitDB service for framework
export class MockOrbitDBService {
private orbitdb: any;
constructor() {
this.orbitdb = new (require('./orbitdb').MockOrbitDB)();
}
async init() {
await this.orbitdb.start();
}
async stop() {
await this.orbitdb.stop();
}
async openDB(name: string, type: string) {
return await this.orbitdb.open(name, { type });
}
getOrbitDB() {
return this.orbitdb;
}
}
// Default export
export default {
createHelia,
createLibp2p,
MockHelia,
MockLibp2p,
MockIPFSService,
MockOrbitDBService
};

154
tests/mocks/orbitdb.ts Normal file
View File

@ -0,0 +1,154 @@
// Mock OrbitDB for testing
export class MockOrbitDB {
private databases = new Map<string, MockDatabase>();
private isOpen = false;
async open(name: string, options: any = {}) {
if (!this.databases.has(name)) {
this.databases.set(name, new MockDatabase(name, options));
}
return this.databases.get(name);
}
async stop() {
this.isOpen = false;
for (const db of this.databases.values()) {
await db.close();
}
}
async start() {
this.isOpen = true;
}
get address() {
return 'mock-orbitdb-address';
}
}
export class MockDatabase {
private data = new Map<string, any>();
private _events: Array<{ type: string; payload: any }> = [];
public name: string;
public type: string;
constructor(name: string, options: any = {}) {
this.name = name;
this.type = options.type || 'docstore';
}
// DocStore methods
async put(doc: any, options?: any) {
const id = doc._id || doc.id || this.generateId();
const record = { ...doc, _id: id };
this.data.set(id, record);
this._events.push({ type: 'write', payload: record });
return id;
}
async get(id: string) {
return this.data.get(id) || null;
}
async del(id: string) {
const deleted = this.data.delete(id);
if (deleted) {
this._events.push({ type: 'delete', payload: { _id: id } });
}
return deleted;
}
async query(filter?: (doc: any) => boolean) {
const docs = Array.from(this.data.values());
return filter ? docs.filter(filter) : docs;
}
async all() {
return Array.from(this.data.values());
}
// EventLog methods
async add(data: any) {
const entry = {
payload: data,
hash: this.generateId(),
clock: { time: Date.now() }
};
this._events.push(entry);
return entry.hash;
}
async iterator(options?: any) {
const events = this._events.slice();
return {
collect: () => events,
[Symbol.iterator]: function* () {
for (const event of events) {
yield event;
}
}
};
}
// KeyValue methods
async set(key: string, value: any) {
this.data.set(key, value);
this._events.push({ type: 'put', payload: { key, value } });
return key;
}
// Counter methods
async inc(amount: number = 1) {
const current = this.data.get('counter') || 0;
const newValue = current + amount;
this.data.set('counter', newValue);
this._events.push({ type: 'increment', payload: { amount, value: newValue } });
return newValue;
}
get value() {
return this.data.get('counter') || 0;
}
// General methods
async close() {
// Mock close
}
async drop() {
this.data.clear();
this._events = [];
}
get address() {
return `mock-db-${this.name}`;
}
get events() {
return this._events;
}
// Event emitter mock
on(event: string, callback: Function) {
// Mock event listener
}
off(event: string, callback: Function) {
// Mock event listener removal
}
private generateId(): string {
return `mock-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
export const createOrbitDB = jest.fn(async (options: any) => {
return new MockOrbitDB();
});
// Default export for ES modules
export default {
createOrbitDB,
MockOrbitDB,
MockDatabase
};

35
tests/mocks/services.ts Normal file
View File

@ -0,0 +1,35 @@
// Mock services factory for testing
import { MockIPFSService, MockOrbitDBService } from './ipfs';
export function createMockServices() {
const ipfsService = new MockIPFSService();
const orbitDBService = new MockOrbitDBService();
return {
ipfsService,
orbitDBService,
async initialize() {
await ipfsService.init();
await orbitDBService.init();
},
async cleanup() {
await ipfsService.stop();
await orbitDBService.stop();
}
};
}
// Test utilities
export function createMockDatabase() {
const { MockDatabase } = require('./orbitdb');
return new MockDatabase('test-db', { type: 'docstore' });
}
export function createMockRecord(overrides: any = {}) {
return {
id: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides
};
}

51
tests/setup.ts Normal file
View File

@ -0,0 +1,51 @@
// Test setup file
import 'reflect-metadata';
// Global test configuration
jest.setTimeout(30000);
// Mock console to reduce noise during testing
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
// Setup global test utilities
global.beforeEach(() => {
jest.clearAllMocks();
});
// Add custom matchers if needed
expect.extend({
toBeValidModel(received: any) {
const pass = received &&
typeof received.id === 'string' &&
typeof received.save === 'function' &&
typeof received.delete === 'function';
if (pass) {
return {
message: () => `Expected ${received} not to be a valid model`,
pass: true,
};
} else {
return {
message: () => `Expected ${received} to be a valid model with id, save, and delete methods`,
pass: false,
};
}
},
});
// Declare custom matcher types for TypeScript
declare global {
namespace jest {
interface Matchers<R> {
toBeValidModel(): R;
}
}
}

View File

@ -0,0 +1,440 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { DatabaseManager, UserMappingsData } from '../../../src/framework/core/DatabaseManager';
import { FrameworkOrbitDBService } from '../../../src/framework/services/OrbitDBService';
import { ModelRegistry } from '../../../src/framework/core/ModelRegistry';
import { createMockServices } from '../../mocks/services';
import { BaseModel } from '../../../src/framework/models/BaseModel';
import { Model, Field } from '../../../src/framework/models/decorators';
// Test models for DatabaseManager testing
@Model({
scope: 'global',
type: 'docstore'
})
class GlobalTestModel extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
}
@Model({
scope: 'user',
type: 'keyvalue'
})
class UserTestModel extends BaseModel {
@Field({ type: 'string', required: true })
name: string;
}
describe('DatabaseManager', () => {
let databaseManager: DatabaseManager;
let mockOrbitDBService: FrameworkOrbitDBService;
let mockDatabase: any;
let mockOrbitDB: any;
beforeEach(() => {
const mockServices = createMockServices();
mockOrbitDBService = mockServices.orbitDBService;
// Create mock database
mockDatabase = {
address: { toString: () => 'mock-address-123' },
set: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(null),
put: jest.fn().mockResolvedValue('mock-hash'),
add: jest.fn().mockResolvedValue('mock-hash'),
del: jest.fn().mockResolvedValue(undefined),
query: jest.fn().mockReturnValue([]),
iterator: jest.fn().mockReturnValue({
collect: jest.fn().mockReturnValue([])
}),
all: jest.fn().mockReturnValue({}),
value: 0,
id: 'mock-counter-id',
inc: jest.fn().mockResolvedValue(undefined)
};
mockOrbitDB = {
open: jest.fn().mockResolvedValue(mockDatabase)
};
// Mock OrbitDB service methods
jest.spyOn(mockOrbitDBService, 'openDatabase').mockResolvedValue(mockDatabase);
jest.spyOn(mockOrbitDBService, 'getOrbitDB').mockReturnValue(mockOrbitDB);
// Mock ModelRegistry
jest.spyOn(ModelRegistry, 'getGlobalModels').mockReturnValue([
{ modelName: 'GlobalTestModel', dbType: 'docstore' }
]);
jest.spyOn(ModelRegistry, 'getUserScopedModels').mockReturnValue([
{ modelName: 'UserTestModel', dbType: 'keyvalue' }
]);
databaseManager = new DatabaseManager(mockOrbitDBService);
jest.clearAllMocks();
});
describe('Initialization', () => {
it('should initialize all databases correctly', async () => {
await databaseManager.initializeAllDatabases();
// Should create global databases
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith(
'global-globaltestmodel',
'docstore'
);
// Should create system directory shards
for (let i = 0; i < 4; i++) {
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith(
`global-user-directory-shard-${i}`,
'keyvalue'
);
}
});
it('should not initialize databases twice', async () => {
await databaseManager.initializeAllDatabases();
const firstCallCount = (mockOrbitDBService.openDatabase as jest.Mock).mock.calls.length;
await databaseManager.initializeAllDatabases();
const secondCallCount = (mockOrbitDBService.openDatabase as jest.Mock).mock.calls.length;
expect(secondCallCount).toBe(firstCallCount);
});
it('should handle database creation errors', async () => {
jest.spyOn(mockOrbitDBService, 'openDatabase').mockRejectedValueOnce(new Error('Creation failed'));
await expect(databaseManager.initializeAllDatabases()).rejects.toThrow('Creation failed');
});
});
describe('User Database Management', () => {
beforeEach(async () => {
// Initialize global databases first
await databaseManager.initializeAllDatabases();
});
it('should create user databases correctly', async () => {
const userId = 'test-user-123';
const userMappings = await databaseManager.createUserDatabases(userId);
expect(userMappings).toBeInstanceOf(UserMappingsData);
expect(userMappings.userId).toBe(userId);
expect(userMappings.databases).toHaveProperty('usertestmodelDB');
// Should create mappings database
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith(
`${userId}-mappings`,
'keyvalue'
);
// Should create user model database
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith(
`${userId}-usertestmodel`,
'keyvalue'
);
// Should store mappings in database
expect(mockDatabase.set).toHaveBeenCalledWith('mappings', expect.any(Object));
});
it('should retrieve user mappings from cache', async () => {
const userId = 'test-user-456';
// Create user databases first
const originalMappings = await databaseManager.createUserDatabases(userId);
jest.clearAllMocks();
// Get mappings again - should come from cache
const cachedMappings = await databaseManager.getUserMappings(userId);
expect(cachedMappings).toBe(originalMappings);
expect(mockDatabase.get).not.toHaveBeenCalled();
});
it('should retrieve user mappings from global directory', async () => {
const userId = 'test-user-789';
const mappingsAddress = 'mock-mappings-address';
const mappingsData = { usertestmodelDB: 'mock-db-address' };
// Mock directory shard return
mockDatabase.get
.mockResolvedValueOnce(mappingsAddress) // From directory shard
.mockResolvedValueOnce(mappingsData); // From mappings DB
const userMappings = await databaseManager.getUserMappings(userId);
expect(userMappings).toBeInstanceOf(UserMappingsData);
expect(userMappings.userId).toBe(userId);
expect(userMappings.databases).toEqual(mappingsData);
// Should open mappings database
expect(mockOrbitDB.open).toHaveBeenCalledWith(mappingsAddress);
});
it('should handle user not found in directory', async () => {
const userId = 'nonexistent-user';
// Mock directory shard returning null
mockDatabase.get.mockResolvedValue(null);
await expect(databaseManager.getUserMappings(userId)).rejects.toThrow(
`User ${userId} not found in directory`
);
});
it('should get user database correctly', async () => {
const userId = 'test-user-db';
const modelName = 'UserTestModel';
// Create user databases first
await databaseManager.createUserDatabases(userId);
const userDB = await databaseManager.getUserDatabase(userId, modelName);
expect(userDB).toBe(mockDatabase);
});
it('should handle missing user database', async () => {
const userId = 'test-user-missing';
const modelName = 'NonExistentModel';
// Create user databases first
await databaseManager.createUserDatabases(userId);
await expect(databaseManager.getUserDatabase(userId, modelName)).rejects.toThrow(
`Database not found for user ${userId} and model ${modelName}`
);
});
});
describe('Global Database Management', () => {
beforeEach(async () => {
await databaseManager.initializeAllDatabases();
});
it('should get global database correctly', async () => {
const globalDB = await databaseManager.getGlobalDatabase('GlobalTestModel');
expect(globalDB).toBe(mockDatabase);
});
it('should handle missing global database', async () => {
await expect(databaseManager.getGlobalDatabase('NonExistentModel')).rejects.toThrow(
'Global database not found for model: NonExistentModel'
);
});
it('should get global directory shards', async () => {
const shards = await databaseManager.getGlobalDirectoryShards();
expect(shards).toHaveLength(4);
expect(shards.every(shard => shard === mockDatabase)).toBe(true);
});
});
describe('Database Operations', () => {
beforeEach(async () => {
await databaseManager.initializeAllDatabases();
});
describe('getAllDocuments', () => {
it('should get all documents from eventlog', async () => {
const mockDocs = [{ id: '1', data: 'test' }];
mockDatabase.iterator.mockReturnValue({
collect: jest.fn().mockReturnValue(mockDocs)
});
const docs = await databaseManager.getAllDocuments(mockDatabase, 'eventlog');
expect(docs).toEqual(mockDocs);
expect(mockDatabase.iterator).toHaveBeenCalled();
});
it('should get all documents from keyvalue', async () => {
const mockData = { key1: { id: '1' }, key2: { id: '2' } };
mockDatabase.all.mockReturnValue(mockData);
const docs = await databaseManager.getAllDocuments(mockDatabase, 'keyvalue');
expect(docs).toEqual([{ id: '1' }, { id: '2' }]);
expect(mockDatabase.all).toHaveBeenCalled();
});
it('should get all documents from docstore', async () => {
const mockDocs = [{ id: '1' }, { id: '2' }];
mockDatabase.query.mockReturnValue(mockDocs);
const docs = await databaseManager.getAllDocuments(mockDatabase, 'docstore');
expect(docs).toEqual(mockDocs);
expect(mockDatabase.query).toHaveBeenCalledWith(expect.any(Function));
});
it('should get documents from counter', async () => {
mockDatabase.value = 42;
mockDatabase.id = 'counter-123';
const docs = await databaseManager.getAllDocuments(mockDatabase, 'counter');
expect(docs).toEqual([{ value: 42, id: 'counter-123' }]);
});
it('should handle unsupported database type', async () => {
await expect(
databaseManager.getAllDocuments(mockDatabase, 'unsupported' as any)
).rejects.toThrow('Unsupported database type: unsupported');
});
});
describe('addDocument', () => {
it('should add document to eventlog', async () => {
const data = { content: 'test' };
mockDatabase.add.mockResolvedValue('hash123');
const result = await databaseManager.addDocument(mockDatabase, 'eventlog', data);
expect(result).toBe('hash123');
expect(mockDatabase.add).toHaveBeenCalledWith(data);
});
it('should add document to keyvalue', async () => {
const data = { id: 'key1', content: 'test' };
const result = await databaseManager.addDocument(mockDatabase, 'keyvalue', data);
expect(result).toBe('key1');
expect(mockDatabase.set).toHaveBeenCalledWith('key1', data);
});
it('should add document to docstore', async () => {
const data = { id: 'doc1', content: 'test' };
mockDatabase.put.mockResolvedValue('hash123');
const result = await databaseManager.addDocument(mockDatabase, 'docstore', data);
expect(result).toBe('hash123');
expect(mockDatabase.put).toHaveBeenCalledWith(data);
});
it('should increment counter', async () => {
const data = { amount: 5 };
mockDatabase.id = 'counter-123';
const result = await databaseManager.addDocument(mockDatabase, 'counter', data);
expect(result).toBe('counter-123');
expect(mockDatabase.inc).toHaveBeenCalledWith(5);
});
});
describe('updateDocument', () => {
it('should update document in keyvalue', async () => {
const data = { id: 'key1', content: 'updated' };
await databaseManager.updateDocument(mockDatabase, 'keyvalue', 'key1', data);
expect(mockDatabase.set).toHaveBeenCalledWith('key1', data);
});
it('should update document in docstore', async () => {
const data = { id: 'doc1', content: 'updated' };
await databaseManager.updateDocument(mockDatabase, 'docstore', 'doc1', data);
expect(mockDatabase.put).toHaveBeenCalledWith(data);
});
it('should add new entry for append-only stores', async () => {
const data = { id: 'event1', content: 'updated' };
mockDatabase.add.mockResolvedValue('hash123');
await databaseManager.updateDocument(mockDatabase, 'eventlog', 'event1', data);
expect(mockDatabase.add).toHaveBeenCalledWith(data);
});
});
describe('deleteDocument', () => {
it('should delete document from keyvalue', async () => {
await databaseManager.deleteDocument(mockDatabase, 'keyvalue', 'key1');
expect(mockDatabase.del).toHaveBeenCalledWith('key1');
});
it('should delete document from docstore', async () => {
await databaseManager.deleteDocument(mockDatabase, 'docstore', 'doc1');
expect(mockDatabase.del).toHaveBeenCalledWith('doc1');
});
it('should add deletion marker for append-only stores', async () => {
mockDatabase.add.mockResolvedValue('hash123');
await databaseManager.deleteDocument(mockDatabase, 'eventlog', 'event1');
expect(mockDatabase.add).toHaveBeenCalledWith({
_deleted: true,
id: 'event1',
deletedAt: expect.any(Number)
});
});
});
});
describe('Shard Index Calculation', () => {
it('should calculate consistent shard indices', async () => {
await databaseManager.initializeAllDatabases();
const userId1 = 'user-123';
const userId2 = 'user-456';
// Create users and verify they're stored in shards
await databaseManager.createUserDatabases(userId1);
await databaseManager.createUserDatabases(userId2);
// The shard index should be consistent for the same user
const calls = (mockDatabase.set as jest.Mock).mock.calls;
const user1Calls = calls.filter(call => call[0] === userId1);
const user2Calls = calls.filter(call => call[0] === userId2);
expect(user1Calls).toHaveLength(1);
expect(user2Calls).toHaveLength(1);
});
});
describe('Error Handling', () => {
it('should handle database operation errors', async () => {
await databaseManager.initializeAllDatabases();
mockDatabase.put.mockRejectedValue(new Error('Database error'));
await expect(
databaseManager.addDocument(mockDatabase, 'docstore', { id: 'test' })
).rejects.toThrow('Database error');
});
it('should handle missing global directory', async () => {
// Don't initialize databases
const userId = 'test-user';
await expect(databaseManager.getUserMappings(userId)).rejects.toThrow(
'Global directory not initialized'
);
});
});
describe('Cleanup', () => {
it('should stop and clear all resources', async () => {
await databaseManager.initializeAllDatabases();
await databaseManager.createUserDatabases('test-user');
await databaseManager.stop();
// After stopping, initialization should be required again
await expect(databaseManager.getGlobalDatabase('GlobalTestModel')).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,478 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { BaseModel } from '../../../src/framework/models/BaseModel';
import {
Model,
Field,
BelongsTo,
HasMany,
HasOne,
ManyToMany,
BeforeCreate,
AfterCreate,
BeforeUpdate,
AfterUpdate,
BeforeDelete,
AfterDelete,
getFieldConfig,
getRelationshipConfig,
getHooks
} from '../../../src/framework/models/decorators';
describe('Decorators', () => {
describe('@Model Decorator', () => {
it('should define model metadata correctly', () => {
@Model({
scope: 'global',
type: 'docstore',
sharding: {
strategy: 'hash',
count: 4,
key: 'id'
}
})
class TestModel extends BaseModel {}
expect(TestModel.scope).toBe('global');
expect(TestModel.storeType).toBe('docstore');
expect(TestModel.sharding).toEqual({
strategy: 'hash',
count: 4,
key: 'id'
});
});
it('should apply default model configuration', () => {
@Model({})
class DefaultModel extends BaseModel {}
expect(DefaultModel.scope).toBe('global');
expect(DefaultModel.storeType).toBe('docstore');
});
it('should register model with ModelRegistry', () => {
@Model({
scope: 'user',
type: 'eventlog'
})
class RegistryModel extends BaseModel {}
// The model should be automatically registered
expect(RegistryModel.scope).toBe('user');
expect(RegistryModel.storeType).toBe('eventlog');
});
});
describe('@Field Decorator', () => {
@Model({})
class FieldTestModel extends BaseModel {
@Field({ type: 'string', required: true })
requiredField: string;
@Field({ type: 'number', required: false, default: 42 })
defaultField: number;
@Field({
type: 'string',
required: true,
validate: (value: string) => value.length >= 3,
transform: (value: string) => value.toLowerCase()
})
validatedField: string;
@Field({ type: 'array', required: false, default: [] })
arrayField: string[];
@Field({ type: 'boolean', required: false, default: true })
booleanField: boolean;
@Field({ type: 'object', required: false })
objectField: Record<string, any>;
}
it('should define field metadata correctly', () => {
const requiredFieldConfig = getFieldConfig(FieldTestModel, 'requiredField');
expect(requiredFieldConfig).toEqual({
type: 'string',
required: true
});
const defaultFieldConfig = getFieldConfig(FieldTestModel, 'defaultField');
expect(defaultFieldConfig).toEqual({
type: 'number',
required: false,
default: 42
});
});
it('should handle field validation configuration', () => {
const validatedFieldConfig = getFieldConfig(FieldTestModel, 'validatedField');
expect(validatedFieldConfig.type).toBe('string');
expect(validatedFieldConfig.required).toBe(true);
expect(typeof validatedFieldConfig.validate).toBe('function');
expect(typeof validatedFieldConfig.transform).toBe('function');
});
it('should apply field validation', () => {
const validatedFieldConfig = getFieldConfig(FieldTestModel, 'validatedField');
expect(validatedFieldConfig.validate!('test')).toBe(true);
expect(validatedFieldConfig.validate!('hi')).toBe(false); // Less than 3 characters
});
it('should apply field transformation', () => {
const validatedFieldConfig = getFieldConfig(FieldTestModel, 'validatedField');
expect(validatedFieldConfig.transform!('TEST')).toBe('test');
expect(validatedFieldConfig.transform!('MixedCase')).toBe('mixedcase');
});
it('should handle different field types', () => {
const arrayFieldConfig = getFieldConfig(FieldTestModel, 'arrayField');
expect(arrayFieldConfig.type).toBe('array');
expect(arrayFieldConfig.default).toEqual([]);
const booleanFieldConfig = getFieldConfig(FieldTestModel, 'booleanField');
expect(booleanFieldConfig.type).toBe('boolean');
expect(booleanFieldConfig.default).toBe(true);
const objectFieldConfig = getFieldConfig(FieldTestModel, 'objectField');
expect(objectFieldConfig.type).toBe('object');
expect(objectFieldConfig.required).toBe(false);
});
});
describe('Relationship Decorators', () => {
@Model({})
class User extends BaseModel {
@Field({ type: 'string', required: true })
username: string;
@HasMany(() => Post, 'userId')
posts: Post[];
@HasOne(() => Profile, 'userId')
profile: Profile;
@ManyToMany(() => Role, 'user_roles', 'userId', 'roleId')
roles: Role[];
}
@Model({})
class Post extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
@Field({ type: 'string', required: true })
userId: string;
@BelongsTo(() => User, 'userId')
user: User;
}
@Model({})
class Profile extends BaseModel {
@Field({ type: 'string', required: true })
userId: string;
@BelongsTo(() => User, 'userId')
user: User;
}
@Model({})
class Role extends BaseModel {
@Field({ type: 'string', required: true })
name: string;
@ManyToMany(() => User, 'user_roles', 'roleId', 'userId')
users: User[];
}
it('should define BelongsTo relationships correctly', () => {
const relationships = getRelationshipConfig(Post);
const userRelation = relationships.find(r => r.propertyKey === 'user');
expect(userRelation).toBeDefined();
expect(userRelation?.type).toBe('belongsTo');
expect(userRelation?.targetModel()).toBe(User);
expect(userRelation?.foreignKey).toBe('userId');
});
it('should define HasMany relationships correctly', () => {
const relationships = getRelationshipConfig(User);
const postsRelation = relationships.find(r => r.propertyKey === 'posts');
expect(postsRelation).toBeDefined();
expect(postsRelation?.type).toBe('hasMany');
expect(postsRelation?.targetModel()).toBe(Post);
expect(postsRelation?.foreignKey).toBe('userId');
});
it('should define HasOne relationships correctly', () => {
const relationships = getRelationshipConfig(User);
const profileRelation = relationships.find(r => r.propertyKey === 'profile');
expect(profileRelation).toBeDefined();
expect(profileRelation?.type).toBe('hasOne');
expect(profileRelation?.targetModel()).toBe(Profile);
expect(profileRelation?.foreignKey).toBe('userId');
});
it('should define ManyToMany relationships correctly', () => {
const relationships = getRelationshipConfig(User);
const rolesRelation = relationships.find(r => r.propertyKey === 'roles');
expect(rolesRelation).toBeDefined();
expect(rolesRelation?.type).toBe('manyToMany');
expect(rolesRelation?.targetModel()).toBe(Role);
expect(rolesRelation?.through).toBe('user_roles');
expect(rolesRelation?.foreignKey).toBe('userId');
expect(rolesRelation?.otherKey).toBe('roleId');
});
it('should support relationship options', () => {
@Model({})
class TestModel extends BaseModel {
@HasMany(() => Post, 'userId', {
cache: true,
eager: false,
orderBy: 'createdAt',
limit: 10
})
posts: Post[];
}
const relationships = getRelationshipConfig(TestModel);
const postsRelation = relationships.find(r => r.propertyKey === 'posts');
expect(postsRelation?.options).toEqual({
cache: true,
eager: false,
orderBy: 'createdAt',
limit: 10
});
});
});
describe('Hook Decorators', () => {
let hookCallOrder: string[] = [];
@Model({})
class HookTestModel extends BaseModel {
@Field({ type: 'string', required: true })
name: string;
@BeforeCreate()
beforeCreateHook() {
hookCallOrder.push('beforeCreate');
}
@AfterCreate()
afterCreateHook() {
hookCallOrder.push('afterCreate');
}
@BeforeUpdate()
beforeUpdateHook() {
hookCallOrder.push('beforeUpdate');
}
@AfterUpdate()
afterUpdateHook() {
hookCallOrder.push('afterUpdate');
}
@BeforeDelete()
beforeDeleteHook() {
hookCallOrder.push('beforeDelete');
}
@AfterDelete()
afterDeleteHook() {
hookCallOrder.push('afterDelete');
}
}
beforeEach(() => {
hookCallOrder = [];
});
it('should register lifecycle hooks correctly', () => {
const hooks = getHooks(HookTestModel);
expect(hooks.beforeCreate).toContain('beforeCreateHook');
expect(hooks.afterCreate).toContain('afterCreateHook');
expect(hooks.beforeUpdate).toContain('beforeUpdateHook');
expect(hooks.afterUpdate).toContain('afterUpdateHook');
expect(hooks.beforeDelete).toContain('beforeDeleteHook');
expect(hooks.afterDelete).toContain('afterDeleteHook');
});
it('should support multiple hooks of the same type', () => {
@Model({})
class MultiHookModel extends BaseModel {
@BeforeCreate()
firstBeforeCreate() {
hookCallOrder.push('first');
}
@BeforeCreate()
secondBeforeCreate() {
hookCallOrder.push('second');
}
}
const hooks = getHooks(MultiHookModel);
expect(hooks.beforeCreate).toHaveLength(2);
expect(hooks.beforeCreate).toContain('firstBeforeCreate');
expect(hooks.beforeCreate).toContain('secondBeforeCreate');
});
});
describe('Complex Decorator Combinations', () => {
it('should handle models with all decorator types', () => {
@Model({
scope: 'user',
type: 'docstore',
sharding: {
strategy: 'user',
count: 2,
key: 'userId'
}
})
class ComplexModel extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
@Field({ type: 'string', required: true })
userId: string;
@Field({
type: 'array',
required: false,
default: [],
transform: (tags: string[]) => tags.map(t => t.toLowerCase())
})
tags: string[];
@BelongsTo(() => User, 'userId')
user: User;
@BeforeCreate()
setDefaults() {
this.tags = this.tags || [];
}
@BeforeUpdate()
updateTimestamp() {
// Update logic
}
}
// Check model configuration
expect(ComplexModel.scope).toBe('user');
expect(ComplexModel.storeType).toBe('docstore');
expect(ComplexModel.sharding).toEqual({
strategy: 'user',
count: 2,
key: 'userId'
});
// Check field configuration
const titleConfig = getFieldConfig(ComplexModel, 'title');
expect(titleConfig.required).toBe(true);
const tagsConfig = getFieldConfig(ComplexModel, 'tags');
expect(tagsConfig.default).toEqual([]);
expect(typeof tagsConfig.transform).toBe('function');
// Check relationships
const relationships = getRelationshipConfig(ComplexModel);
const userRelation = relationships.find(r => r.propertyKey === 'user');
expect(userRelation?.type).toBe('belongsTo');
// Check hooks
const hooks = getHooks(ComplexModel);
expect(hooks.beforeCreate).toContain('setDefaults');
expect(hooks.beforeUpdate).toContain('updateTimestamp');
});
});
describe('Decorator Error Handling', () => {
it('should handle invalid field types', () => {
expect(() => {
@Model({})
class InvalidFieldModel extends BaseModel {
@Field({ type: 'invalid-type' as any, required: true })
invalidField: any;
}
}).toThrow();
});
it('should handle invalid model scope', () => {
expect(() => {
@Model({ scope: 'invalid-scope' as any })
class InvalidScopeModel extends BaseModel {}
}).toThrow();
});
it('should handle invalid store type', () => {
expect(() => {
@Model({ type: 'invalid-store' as any })
class InvalidStoreModel extends BaseModel {}
}).toThrow();
});
});
describe('Metadata Inheritance', () => {
@Model({
scope: 'global',
type: 'docstore'
})
class BaseTestModel extends BaseModel {
@Field({ type: 'string', required: true })
baseField: string;
@BeforeCreate()
baseHook() {
// Base hook
}
}
@Model({
scope: 'user', // Override scope
type: 'eventlog' // Override type
})
class ExtendedTestModel extends BaseTestModel {
@Field({ type: 'number', required: false })
extendedField: number;
@BeforeCreate()
extendedHook() {
// Extended hook
}
}
it('should inherit field metadata from parent class', () => {
const baseFieldConfig = getFieldConfig(ExtendedTestModel, 'baseField');
expect(baseFieldConfig).toBeDefined();
expect(baseFieldConfig.type).toBe('string');
expect(baseFieldConfig.required).toBe(true);
const extendedFieldConfig = getFieldConfig(ExtendedTestModel, 'extendedField');
expect(extendedFieldConfig).toBeDefined();
expect(extendedFieldConfig.type).toBe('number');
});
it('should override model configuration in child class', () => {
expect(ExtendedTestModel.scope).toBe('user');
expect(ExtendedTestModel.storeType).toBe('eventlog');
});
it('should inherit and extend hooks', () => {
const hooks = getHooks(ExtendedTestModel);
expect(hooks.beforeCreate).toContain('baseHook');
expect(hooks.beforeCreate).toContain('extendedHook');
});
});
});

View File

@ -0,0 +1,652 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import {
MigrationManager,
Migration,
MigrationOperation,
MigrationResult,
MigrationValidator,
MigrationLogger
} from '../../../src/framework/migrations/MigrationManager';
import { FieldConfig } from '../../../src/framework/types/models';
import { createMockServices } from '../../mocks/services';
describe('MigrationManager', () => {
let migrationManager: MigrationManager;
let mockDatabaseManager: any;
let mockShardManager: any;
let mockLogger: MigrationLogger;
const createTestMigration = (overrides: Partial<Migration> = {}): Migration => ({
id: 'test-migration-1',
version: '1.0.0',
name: 'Test Migration',
description: 'A test migration for unit testing',
targetModels: ['TestModel'],
up: [
{
type: 'add_field',
modelName: 'TestModel',
fieldName: 'newField',
fieldConfig: {
type: 'string',
required: false,
default: 'default-value'
} as FieldConfig
}
],
down: [
{
type: 'remove_field',
modelName: 'TestModel',
fieldName: 'newField'
}
],
createdAt: Date.now(),
...overrides
});
beforeEach(() => {
const mockServices = createMockServices();
mockDatabaseManager = {
getAllDocuments: jest.fn().mockResolvedValue([]),
addDocument: jest.fn().mockResolvedValue('mock-id'),
updateDocument: jest.fn().mockResolvedValue(undefined),
deleteDocument: jest.fn().mockResolvedValue(undefined),
};
mockShardManager = {
getAllShards: jest.fn().mockReturnValue([]),
getShardForKey: jest.fn().mockReturnValue({ name: 'shard-0', database: {} }),
};
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn()
};
migrationManager = new MigrationManager(mockDatabaseManager, mockShardManager, mockLogger);
jest.clearAllMocks();
});
describe('Migration Registration', () => {
it('should register a valid migration', () => {
const migration = createTestMigration();
migrationManager.registerMigration(migration);
const registered = migrationManager.getMigration(migration.id);
expect(registered).toEqual(migration);
expect(mockLogger.info).toHaveBeenCalledWith(
`Registered migration: ${migration.name} (${migration.version})`,
expect.objectContaining({
migrationId: migration.id,
targetModels: migration.targetModels
})
);
});
it('should throw error for invalid migration structure', () => {
const invalidMigration = createTestMigration({
id: '', // Invalid - empty ID
});
expect(() => migrationManager.registerMigration(invalidMigration)).toThrow(
'Migration must have id, version, and name'
);
});
it('should throw error for migration without target models', () => {
const invalidMigration = createTestMigration({
targetModels: [] // Invalid - empty target models
});
expect(() => migrationManager.registerMigration(invalidMigration)).toThrow(
'Migration must specify target models'
);
});
it('should throw error for migration without up operations', () => {
const invalidMigration = createTestMigration({
up: [] // Invalid - no up operations
});
expect(() => migrationManager.registerMigration(invalidMigration)).toThrow(
'Migration must have at least one up operation'
);
});
it('should throw error for duplicate version with different ID', () => {
const migration1 = createTestMigration({ id: 'migration-1', version: '1.0.0' });
const migration2 = createTestMigration({ id: 'migration-2', version: '1.0.0' });
migrationManager.registerMigration(migration1);
expect(() => migrationManager.registerMigration(migration2)).toThrow(
'Migration version 1.0.0 already exists with different ID'
);
});
it('should allow registering same migration with same ID', () => {
const migration = createTestMigration();
migrationManager.registerMigration(migration);
migrationManager.registerMigration(migration); // Should not throw
expect(migrationManager.getMigrations()).toHaveLength(1);
});
});
describe('Migration Retrieval', () => {
beforeEach(() => {
const migration1 = createTestMigration({ id: 'migration-1', version: '1.0.0' });
const migration2 = createTestMigration({ id: 'migration-2', version: '2.0.0' });
const migration3 = createTestMigration({ id: 'migration-3', version: '1.5.0' });
migrationManager.registerMigration(migration1);
migrationManager.registerMigration(migration2);
migrationManager.registerMigration(migration3);
});
it('should get all migrations sorted by version', () => {
const migrations = migrationManager.getMigrations();
expect(migrations).toHaveLength(3);
expect(migrations[0].version).toBe('1.0.0');
expect(migrations[1].version).toBe('1.5.0');
expect(migrations[2].version).toBe('2.0.0');
});
it('should get migration by ID', () => {
const migration = migrationManager.getMigration('migration-2');
expect(migration).toBeDefined();
expect(migration?.version).toBe('2.0.0');
});
it('should return null for non-existent migration', () => {
const migration = migrationManager.getMigration('non-existent');
expect(migration).toBeNull();
});
it('should get pending migrations', () => {
// Mock applied migrations (empty for this test)
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
const pending = migrationManager.getPendingMigrations();
expect(pending).toHaveLength(3);
});
it('should filter pending migrations by model', () => {
const migration4 = createTestMigration({
id: 'migration-4',
version: '3.0.0',
targetModels: ['OtherModel']
});
migrationManager.registerMigration(migration4);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
const pending = migrationManager.getPendingMigrations('TestModel');
expect(pending).toHaveLength(3); // Should exclude migration-4
expect(pending.every(m => m.targetModels.includes('TestModel'))).toBe(true);
});
});
describe('Migration Operations', () => {
it('should validate add_field operation', () => {
const operation: MigrationOperation = {
type: 'add_field',
modelName: 'TestModel',
fieldName: 'newField',
fieldConfig: { type: 'string', required: false }
};
expect(() => (migrationManager as any).validateOperation(operation)).not.toThrow();
});
it('should validate remove_field operation', () => {
const operation: MigrationOperation = {
type: 'remove_field',
modelName: 'TestModel',
fieldName: 'oldField'
};
expect(() => (migrationManager as any).validateOperation(operation)).not.toThrow();
});
it('should validate rename_field operation', () => {
const operation: MigrationOperation = {
type: 'rename_field',
modelName: 'TestModel',
fieldName: 'oldField',
newFieldName: 'newField'
};
expect(() => (migrationManager as any).validateOperation(operation)).not.toThrow();
});
it('should validate transform_data operation', () => {
const operation: MigrationOperation = {
type: 'transform_data',
modelName: 'TestModel',
transformer: (data: any) => data
};
expect(() => (migrationManager as any).validateOperation(operation)).not.toThrow();
});
it('should reject invalid operation type', () => {
const operation: MigrationOperation = {
type: 'invalid_type' as any,
modelName: 'TestModel'
};
expect(() => (migrationManager as any).validateOperation(operation)).toThrow(
'Invalid operation type: invalid_type'
);
});
it('should reject operation without model name', () => {
const operation: MigrationOperation = {
type: 'add_field',
modelName: ''
};
expect(() => (migrationManager as any).validateOperation(operation)).toThrow(
'Operation must specify modelName'
);
});
});
describe('Migration Execution', () => {
let migration: Migration;
beforeEach(() => {
migration = createTestMigration();
migrationManager.registerMigration(migration);
// Mock helper methods
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([
{ id: 'record-1', name: 'Test 1' },
{ id: 'record-2', name: 'Test 2' }
]);
jest.spyOn(migrationManager as any, 'updateRecord').mockResolvedValue(undefined);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
});
it('should run migration successfully', async () => {
const result = await migrationManager.runMigration(migration.id);
expect(result.success).toBe(true);
expect(result.migrationId).toBe(migration.id);
expect(result.recordsProcessed).toBe(2);
expect(result.rollbackAvailable).toBe(true);
expect(mockLogger.info).toHaveBeenCalledWith(
`Migration completed: ${migration.name}`,
expect.objectContaining({
migrationId: migration.id,
recordsProcessed: 2
})
);
});
it('should perform dry run without modifying data', async () => {
jest.spyOn(migrationManager as any, 'countRecordsForModel').mockResolvedValue(2);
const result = await migrationManager.runMigration(migration.id, { dryRun: true });
expect(result.success).toBe(true);
expect(result.warnings).toContain('This was a dry run - no data was actually modified');
expect(migrationManager as any).not.toHaveProperty('updateRecord');
expect(mockLogger.info).toHaveBeenCalledWith(
`Performing dry run for migration: ${migration.name}`
);
});
it('should throw error for non-existent migration', async () => {
await expect(migrationManager.runMigration('non-existent')).rejects.toThrow(
'Migration non-existent not found'
);
});
it('should throw error for already running migration', async () => {
// Start first migration (don't await)
const promise1 = migrationManager.runMigration(migration.id);
// Try to start same migration again
await expect(migrationManager.runMigration(migration.id)).rejects.toThrow(
`Migration ${migration.id} is already running`
);
// Clean up first migration
await promise1;
});
it('should handle migration with dependencies', async () => {
const dependentMigration = createTestMigration({
id: 'dependent-migration',
version: '2.0.0',
dependencies: ['test-migration-1']
});
migrationManager.registerMigration(dependentMigration);
// Mock that dependency is not applied
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
await expect(migrationManager.runMigration(dependentMigration.id)).rejects.toThrow(
'Migration dependency not satisfied: test-migration-1'
);
});
});
describe('Migration Rollback', () => {
let migration: Migration;
beforeEach(() => {
migration = createTestMigration();
migrationManager.registerMigration(migration);
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([
{ id: 'record-1', name: 'Test 1', newField: 'default-value' },
{ id: 'record-2', name: 'Test 2', newField: 'default-value' }
]);
jest.spyOn(migrationManager as any, 'updateRecord').mockResolvedValue(undefined);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
});
it('should rollback applied migration', async () => {
// Mock that migration was applied
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([
{ migrationId: migration.id, success: true }
]);
const result = await migrationManager.rollbackMigration(migration.id);
expect(result.success).toBe(true);
expect(result.migrationId).toBe(migration.id);
expect(result.rollbackAvailable).toBe(false);
expect(mockLogger.info).toHaveBeenCalledWith(
`Rollback completed: ${migration.name}`,
expect.objectContaining({ migrationId: migration.id })
);
});
it('should throw error for non-existent migration rollback', async () => {
await expect(migrationManager.rollbackMigration('non-existent')).rejects.toThrow(
'Migration non-existent not found'
);
});
it('should throw error for unapplied migration rollback', async () => {
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
await expect(migrationManager.rollbackMigration(migration.id)).rejects.toThrow(
`Migration ${migration.id} has not been applied`
);
});
it('should handle migration without rollback operations', async () => {
const migrationWithoutRollback = createTestMigration({
id: 'no-rollback',
down: []
});
migrationManager.registerMigration(migrationWithoutRollback);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([
{ migrationId: 'no-rollback', success: true }
]);
await expect(migrationManager.rollbackMigration('no-rollback')).rejects.toThrow(
'Migration has no rollback operations defined'
);
});
});
describe('Batch Migration Operations', () => {
beforeEach(() => {
const migration1 = createTestMigration({ id: 'migration-1', version: '1.0.0' });
const migration2 = createTestMigration({ id: 'migration-2', version: '2.0.0' });
const migration3 = createTestMigration({ id: 'migration-3', version: '3.0.0' });
migrationManager.registerMigration(migration1);
migrationManager.registerMigration(migration2);
migrationManager.registerMigration(migration3);
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([]);
jest.spyOn(migrationManager as any, 'updateRecord').mockResolvedValue(undefined);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
});
it('should run all pending migrations', async () => {
const results = await migrationManager.runPendingMigrations();
expect(results).toHaveLength(3);
expect(results.every(r => r.success)).toBe(true);
expect(mockLogger.info).toHaveBeenCalledWith(
'Running 3 pending migrations',
expect.objectContaining({ dryRun: false })
);
});
it('should run pending migrations for specific model', async () => {
const migration4 = createTestMigration({
id: 'migration-4',
version: '4.0.0',
targetModels: ['OtherModel']
});
migrationManager.registerMigration(migration4);
const results = await migrationManager.runPendingMigrations({ modelName: 'TestModel' });
expect(results).toHaveLength(3); // Should exclude migration-4
});
it('should stop on error when specified', async () => {
// Make second migration fail
jest.spyOn(migrationManager, 'runMigration')
.mockResolvedValueOnce({ success: true } as MigrationResult)
.mockRejectedValueOnce(new Error('Migration failed'));
await expect(
migrationManager.runPendingMigrations({ stopOnError: true })
).rejects.toThrow('Migration failed');
});
it('should continue on error when not specified', async () => {
// Make second migration fail
jest.spyOn(migrationManager, 'runMigration')
.mockResolvedValueOnce({ success: true } as MigrationResult)
.mockRejectedValueOnce(new Error('Migration failed'))
.mockResolvedValueOnce({ success: true } as MigrationResult);
const results = await migrationManager.runPendingMigrations({ stopOnError: false });
expect(results).toHaveLength(2); // Only successful migrations
expect(mockLogger.error).toHaveBeenCalledWith(
'Skipping failed migration: migration-2',
expect.objectContaining({ error: expect.any(Error) })
);
});
});
describe('Migration Validation', () => {
it('should run pre-migration validators', async () => {
const validator: MigrationValidator = {
name: 'Test Validator',
description: 'Tests migration validity',
validate: jest.fn().mockResolvedValue({
valid: true,
errors: [],
warnings: ['Test warning']
})
};
const migration = createTestMigration({
validators: [validator]
});
migrationManager.registerMigration(migration);
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([]);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
await migrationManager.runMigration(migration.id);
expect(validator.validate).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
`Running pre-migration validator: ${validator.name}`
);
});
it('should fail migration on validation error', async () => {
const validator: MigrationValidator = {
name: 'Failing Validator',
description: 'Always fails',
validate: jest.fn().mockResolvedValue({
valid: false,
errors: ['Validation failed'],
warnings: []
})
};
const migration = createTestMigration({
validators: [validator]
});
migrationManager.registerMigration(migration);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
await expect(migrationManager.runMigration(migration.id)).rejects.toThrow(
'Pre-migration validation failed: Validation failed'
);
});
});
describe('Migration Progress and Monitoring', () => {
it('should track migration progress', async () => {
const migration = createTestMigration();
migrationManager.registerMigration(migration);
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([
{ id: 'record-1' }
]);
jest.spyOn(migrationManager as any, 'updateRecord').mockResolvedValue(undefined);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
const migrationPromise = migrationManager.runMigration(migration.id);
// Check progress while migration is running
const progress = migrationManager.getMigrationProgress(migration.id);
expect(progress).toBeDefined();
expect(progress?.status).toBe('running');
await migrationPromise;
// Progress should be cleared after completion
const finalProgress = migrationManager.getMigrationProgress(migration.id);
expect(finalProgress).toBeNull();
});
it('should get active migrations', async () => {
const migration1 = createTestMigration({ id: 'migration-1' });
const migration2 = createTestMigration({ id: 'migration-2' });
migrationManager.registerMigration(migration1);
migrationManager.registerMigration(migration2);
jest.spyOn(migrationManager as any, 'getAllRecordsForModel').mockResolvedValue([]);
jest.spyOn(migrationManager as any, 'getAppliedMigrations').mockReturnValue([]);
jest.spyOn(migrationManager as any, 'recordMigrationResult').mockResolvedValue(undefined);
// Start migrations but don't await
const promise1 = migrationManager.runMigration(migration1.id);
const promise2 = migrationManager.runMigration(migration2.id);
const activeMigrations = migrationManager.getActiveMigrations();
expect(activeMigrations).toHaveLength(2);
expect(activeMigrations.every(p => p.status === 'running')).toBe(true);
await Promise.all([promise1, promise2]);
});
it('should get migration history', () => {
// Manually add some history
const result1: MigrationResult = {
migrationId: 'migration-1',
success: true,
duration: 1000,
recordsProcessed: 10,
recordsModified: 5,
warnings: [],
errors: [],
rollbackAvailable: true
};
const result2: MigrationResult = {
migrationId: 'migration-2',
success: false,
duration: 500,
recordsProcessed: 5,
recordsModified: 0,
warnings: [],
errors: ['Test error'],
rollbackAvailable: false
};
(migrationManager as any).migrationHistory.set('migration-1', [result1]);
(migrationManager as any).migrationHistory.set('migration-2', [result2]);
const allHistory = migrationManager.getMigrationHistory();
expect(allHistory).toHaveLength(2);
const specificHistory = migrationManager.getMigrationHistory('migration-1');
expect(specificHistory).toEqual([result1]);
});
});
describe('Version Comparison', () => {
it('should compare versions correctly', () => {
const compareVersions = (migrationManager as any).compareVersions.bind(migrationManager);
expect(compareVersions('1.0.0', '2.0.0')).toBe(-1);
expect(compareVersions('2.0.0', '1.0.0')).toBe(1);
expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
expect(compareVersions('1.2.0', '1.1.0')).toBe(1);
expect(compareVersions('1.0.1', '1.0.0')).toBe(1);
expect(compareVersions('1.0', '1.0.0')).toBe(0);
});
});
describe('Field Value Conversion', () => {
it('should convert field values correctly', () => {
const convertFieldValue = (migrationManager as any).convertFieldValue.bind(migrationManager);
expect(convertFieldValue('123', { type: 'number' })).toBe(123);
expect(convertFieldValue(123, { type: 'string' })).toBe('123');
expect(convertFieldValue('true', { type: 'boolean' })).toBe(true);
expect(convertFieldValue('test', { type: 'array' })).toEqual(['test']);
expect(convertFieldValue(['test'], { type: 'array' })).toEqual(['test']);
expect(convertFieldValue(null, { type: 'string' })).toBeNull();
});
});
describe('Cleanup', () => {
it('should cleanup resources', async () => {
await migrationManager.cleanup();
expect(migrationManager.getActiveMigrations()).toHaveLength(0);
expect(mockLogger.info).toHaveBeenCalledWith('Cleaning up migration manager');
});
});
});

View File

@ -0,0 +1,458 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { BaseModel } from '../../../src/framework/models/BaseModel';
import { Model, Field, BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate } from '../../../src/framework/models/decorators';
import { createMockServices } from '../../mocks/services';
// Test model for testing BaseModel functionality
@Model({
scope: 'global',
type: 'docstore'
})
class TestUser extends BaseModel {
@Field({ type: 'string', required: true, unique: true })
username: string;
@Field({ type: 'string', required: true, unique: true })
email: string;
@Field({ type: 'number', required: false, default: 0 })
score: number;
@Field({ type: 'boolean', required: false, default: true })
isActive: boolean;
@Field({ type: 'array', required: false, default: [] })
tags: string[];
@Field({ type: 'number', required: false })
createdAt: number;
@Field({ type: 'number', required: false })
updatedAt: number;
// Hook counters for testing
static beforeCreateCount = 0;
static afterCreateCount = 0;
static beforeUpdateCount = 0;
static afterUpdateCount = 0;
@BeforeCreate()
beforeCreateHook() {
this.createdAt = Date.now();
this.updatedAt = Date.now();
TestUser.beforeCreateCount++;
}
@AfterCreate()
afterCreateHook() {
TestUser.afterCreateCount++;
}
@BeforeUpdate()
beforeUpdateHook() {
this.updatedAt = Date.now();
TestUser.beforeUpdateCount++;
}
@AfterUpdate()
afterUpdateHook() {
TestUser.afterUpdateCount++;
}
// Custom validation method
validateEmail(): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(this.email);
}
}
// Test model with validation
@Model({
scope: 'user',
type: 'docstore'
})
class TestPost extends BaseModel {
@Field({
type: 'string',
required: true,
validate: (value: string) => {
if (value.length < 3) {
throw new Error('Title must be at least 3 characters');
}
return true;
}
})
title: string;
@Field({
type: 'string',
required: true,
validate: (value: string) => value.length <= 1000
})
content: string;
@Field({ type: 'string', required: true })
userId: string;
@Field({
type: 'array',
required: false,
default: [],
transform: (tags: string[]) => tags.map(tag => tag.toLowerCase())
})
tags: string[];
}
describe('BaseModel', () => {
let mockServices: any;
beforeEach(() => {
mockServices = createMockServices();
// Reset hook counters
TestUser.beforeCreateCount = 0;
TestUser.afterCreateCount = 0;
TestUser.beforeUpdateCount = 0;
TestUser.afterUpdateCount = 0;
// Mock the framework initialization
jest.clearAllMocks();
});
describe('Model Creation', () => {
it('should create a new model instance with required fields', () => {
const user = new TestUser();
user.username = 'testuser';
user.email = 'test@example.com';
expect(user.username).toBe('testuser');
expect(user.email).toBe('test@example.com');
expect(user.score).toBe(0); // Default value
expect(user.isActive).toBe(true); // Default value
expect(user.tags).toEqual([]); // Default value
});
it('should generate a unique ID for new instances', () => {
const user1 = new TestUser();
const user2 = new TestUser();
expect(user1.id).toBeDefined();
expect(user2.id).toBeDefined();
expect(user1.id).not.toBe(user2.id);
});
it('should create instance using static create method', async () => {
const userData = {
username: 'alice',
email: 'alice@example.com',
score: 100
};
const user = await TestUser.create(userData);
expect(user).toBeInstanceOf(TestUser);
expect(user.username).toBe('alice');
expect(user.email).toBe('alice@example.com');
expect(user.score).toBe(100);
expect(user.isActive).toBe(true); // Default value
});
});
describe('Validation', () => {
it('should validate required fields on create', async () => {
await expect(async () => {
await TestUser.create({
// Missing required username and email
score: 50
});
}).rejects.toThrow();
});
it('should validate field constraints', async () => {
await expect(async () => {
await TestPost.create({
title: 'Hi', // Too short (< 3 characters)
content: 'Test content',
userId: 'user123'
});
}).rejects.toThrow('Title must be at least 3 characters');
});
it('should apply field transformations', async () => {
const post = await TestPost.create({
title: 'Test Post',
content: 'Test content',
userId: 'user123',
tags: ['JavaScript', 'TypeScript', 'REACT']
});
// Tags should be transformed to lowercase
expect(post.tags).toEqual(['javascript', 'typescript', 'react']);
});
it('should validate field types', async () => {
await expect(async () => {
await TestUser.create({
username: 'testuser',
email: 'test@example.com',
score: 'invalid-number' as any // Wrong type
});
}).rejects.toThrow();
});
});
describe('CRUD Operations', () => {
let user: TestUser;
beforeEach(async () => {
user = await TestUser.create({
username: 'testuser',
email: 'test@example.com',
score: 50
});
});
it('should save a model instance', async () => {
user.score = 100;
await user.save();
expect(user.score).toBe(100);
expect(TestUser.beforeUpdateCount).toBe(1);
expect(TestUser.afterUpdateCount).toBe(1);
});
it('should find a model by ID', async () => {
const foundUser = await TestUser.findById(user.id);
expect(foundUser).toBeInstanceOf(TestUser);
expect(foundUser?.id).toBe(user.id);
expect(foundUser?.username).toBe(user.username);
});
it('should return null when model not found', async () => {
const foundUser = await TestUser.findById('non-existent-id');
expect(foundUser).toBeNull();
});
it('should find model by criteria', async () => {
const foundUser = await TestUser.findOne({ username: 'testuser' });
expect(foundUser).toBeInstanceOf(TestUser);
expect(foundUser?.username).toBe('testuser');
});
it('should delete a model instance', async () => {
const userId = user.id;
await user.delete();
const foundUser = await TestUser.findById(userId);
expect(foundUser).toBeNull();
});
it('should find all models', async () => {
// Create another user
await TestUser.create({
username: 'testuser2',
email: 'test2@example.com'
});
const allUsers = await TestUser.findAll();
expect(allUsers.length).toBeGreaterThanOrEqual(2);
expect(allUsers.every(u => u instanceof TestUser)).toBe(true);
});
});
describe('Model Hooks', () => {
it('should execute beforeCreate and afterCreate hooks', async () => {
const initialBeforeCount = TestUser.beforeCreateCount;
const initialAfterCount = TestUser.afterCreateCount;
const user = await TestUser.create({
username: 'hooktest',
email: 'hook@example.com'
});
expect(TestUser.beforeCreateCount).toBe(initialBeforeCount + 1);
expect(TestUser.afterCreateCount).toBe(initialAfterCount + 1);
expect(user.createdAt).toBeDefined();
expect(user.updatedAt).toBeDefined();
});
it('should execute beforeUpdate and afterUpdate hooks', async () => {
const user = await TestUser.create({
username: 'updatetest',
email: 'update@example.com'
});
const initialBeforeCount = TestUser.beforeUpdateCount;
const initialAfterCount = TestUser.afterUpdateCount;
const initialUpdatedAt = user.updatedAt;
// Wait a bit to ensure different timestamp
await new Promise(resolve => setTimeout(resolve, 10));
user.score = 100;
await user.save();
expect(TestUser.beforeUpdateCount).toBe(initialBeforeCount + 1);
expect(TestUser.afterUpdateCount).toBe(initialAfterCount + 1);
expect(user.updatedAt).toBeGreaterThan(initialUpdatedAt!);
});
});
describe('Serialization', () => {
it('should serialize to JSON correctly', async () => {
const user = await TestUser.create({
username: 'serialtest',
email: 'serial@example.com',
score: 75,
tags: ['test', 'user']
});
const json = user.toJSON();
expect(json).toMatchObject({
id: user.id,
username: 'serialtest',
email: 'serial@example.com',
score: 75,
isActive: true,
tags: ['test', 'user'],
createdAt: expect.any(Number),
updatedAt: expect.any(Number)
});
});
it('should create instance from JSON', () => {
const data = {
id: 'test-id',
username: 'fromjson',
email: 'json@example.com',
score: 80,
isActive: false,
tags: ['json'],
createdAt: Date.now(),
updatedAt: Date.now()
};
const user = TestUser.fromJSON(data);
expect(user).toBeInstanceOf(TestUser);
expect(user.id).toBe('test-id');
expect(user.username).toBe('fromjson');
expect(user.email).toBe('json@example.com');
expect(user.score).toBe(80);
expect(user.isActive).toBe(false);
expect(user.tags).toEqual(['json']);
});
});
describe('Query Interface', () => {
it('should provide query interface', () => {
const queryBuilder = TestUser.query();
expect(queryBuilder).toBeDefined();
expect(typeof queryBuilder.where).toBe('function');
expect(typeof queryBuilder.find).toBe('function');
expect(typeof queryBuilder.findOne).toBe('function');
expect(typeof queryBuilder.count).toBe('function');
});
it('should support method chaining in queries', () => {
const queryBuilder = TestUser.query()
.where('isActive', true)
.where('score', '>', 50)
.orderBy('username')
.limit(10);
expect(queryBuilder).toBeDefined();
// The query builder should return itself for chaining
expect(typeof queryBuilder.find).toBe('function');
});
});
describe('Field Modification Tracking', () => {
it('should track field modifications', async () => {
const user = await TestUser.create({
username: 'tracktest',
email: 'track@example.com'
});
expect(user.isFieldModified('username')).toBe(false);
user.username = 'newusername';
expect(user.isFieldModified('username')).toBe(true);
user.score = 100;
expect(user.isFieldModified('score')).toBe(true);
expect(user.isFieldModified('email')).toBe(false);
});
it('should get modified fields', async () => {
const user = await TestUser.create({
username: 'modifytest',
email: 'modify@example.com'
});
user.username = 'newusername';
user.score = 200;
const modifiedFields = user.getModifiedFields();
expect(modifiedFields).toContain('username');
expect(modifiedFields).toContain('score');
expect(modifiedFields).not.toContain('email');
});
it('should clear modifications after save', async () => {
const user = await TestUser.create({
username: 'cleartest',
email: 'clear@example.com'
});
user.username = 'newusername';
expect(user.isFieldModified('username')).toBe(true);
await user.save();
expect(user.isFieldModified('username')).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle validation errors gracefully', async () => {
try {
await TestPost.create({
title: '', // Empty title should fail validation
content: 'Test content',
userId: 'user123'
});
fail('Should have thrown validation error');
} catch (error: any) {
expect(error.message).toContain('required');
}
});
it('should handle database errors gracefully', async () => {
// This would test database connection errors, timeouts, etc.
// For now, we'll test with a simple validation error
const user = new TestUser();
user.username = 'test';
user.email = 'invalid-email'; // Invalid email format
await expect(user.save()).rejects.toThrow();
});
});
describe('Custom Methods', () => {
it('should support custom validation methods', async () => {
const user = await TestUser.create({
username: 'emailtest',
email: 'valid@example.com'
});
expect(user.validateEmail()).toBe(true);
user.email = 'invalid-email';
expect(user.validateEmail()).toBe(false);
});
});
});

View File

@ -0,0 +1,664 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { QueryBuilder } from '../../../src/framework/query/QueryBuilder';
import { BaseModel } from '../../../src/framework/models/BaseModel';
import { Model, Field } from '../../../src/framework/models/decorators';
import { createMockServices } from '../../mocks/services';
// Test models for QueryBuilder testing
@Model({
scope: 'global',
type: 'docstore'
})
class TestUser extends BaseModel {
@Field({ type: 'string', required: true })
username: string;
@Field({ type: 'string', required: true })
email: string;
@Field({ type: 'number', required: false, default: 0 })
score: number;
@Field({ type: 'boolean', required: false, default: true })
isActive: boolean;
@Field({ type: 'array', required: false, default: [] })
tags: string[];
@Field({ type: 'number', required: false })
createdAt: number;
@Field({ type: 'number', required: false })
lastLoginAt: number;
}
@Model({
scope: 'user',
type: 'docstore'
})
class TestPost extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
@Field({ type: 'string', required: true })
content: string;
@Field({ type: 'string', required: true })
userId: string;
@Field({ type: 'array', required: false, default: [] })
tags: string[];
@Field({ type: 'boolean', required: false, default: true })
isPublished: boolean;
@Field({ type: 'number', required: false, default: 0 })
likeCount: number;
@Field({ type: 'number', required: false })
publishedAt: number;
}
describe('QueryBuilder', () => {
let mockServices: any;
beforeEach(() => {
mockServices = createMockServices();
jest.clearAllMocks();
});
describe('Basic Query Construction', () => {
it('should create a QueryBuilder instance', () => {
const queryBuilder = new QueryBuilder(TestUser);
expect(queryBuilder).toBeInstanceOf(QueryBuilder);
expect(queryBuilder.getModel()).toBe(TestUser);
});
it('should support method chaining', () => {
const queryBuilder = new QueryBuilder(TestUser)
.where('isActive', true)
.where('score', '>', 50)
.orderBy('username')
.limit(10);
expect(queryBuilder).toBeInstanceOf(QueryBuilder);
});
});
describe('Where Clauses', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle basic equality conditions', () => {
queryBuilder.where('username', 'testuser');
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(1);
expect(conditions[0]).toEqual({
field: 'username',
operator: 'eq',
value: 'testuser'
});
});
it('should handle explicit operators', () => {
queryBuilder
.where('score', '>', 50)
.where('score', '<=', 100)
.where('isActive', '!=', false);
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(3);
expect(conditions[0]).toEqual({
field: 'score',
operator: 'gt',
value: 50
});
expect(conditions[1]).toEqual({
field: 'score',
operator: 'lte',
value: 100
});
expect(conditions[2]).toEqual({
field: 'isActive',
operator: 'ne',
value: false
});
});
it('should handle IN and NOT IN operators', () => {
queryBuilder
.where('username', 'in', ['alice', 'bob', 'charlie'])
.where('status', 'not in', ['deleted', 'banned']);
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(2);
expect(conditions[0]).toEqual({
field: 'username',
operator: 'in',
value: ['alice', 'bob', 'charlie']
});
expect(conditions[1]).toEqual({
field: 'status',
operator: 'not in',
value: ['deleted', 'banned']
});
});
it('should handle LIKE and REGEX operators', () => {
queryBuilder
.where('username', 'like', 'test%')
.where('email', 'regex', /@gmail\.com$/);
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(2);
expect(conditions[0]).toEqual({
field: 'username',
operator: 'like',
value: 'test%'
});
expect(conditions[1]).toEqual({
field: 'email',
operator: 'regex',
value: /@gmail\.com$/
});
});
it('should handle NULL checks', () => {
queryBuilder
.where('lastLoginAt', 'is null')
.where('email', 'is not null');
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(2);
expect(conditions[0]).toEqual({
field: 'lastLoginAt',
operator: 'is null',
value: null
});
expect(conditions[1]).toEqual({
field: 'email',
operator: 'is not null',
value: null
});
});
it('should handle array operations', () => {
queryBuilder
.where('tags', 'includes', 'javascript')
.where('tags', 'includes any', ['react', 'vue', 'angular'])
.where('tags', 'includes all', ['frontend', 'framework']);
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(3);
expect(conditions[0]).toEqual({
field: 'tags',
operator: 'includes',
value: 'javascript'
});
expect(conditions[1]).toEqual({
field: 'tags',
operator: 'includes any',
value: ['react', 'vue', 'angular']
});
expect(conditions[2]).toEqual({
field: 'tags',
operator: 'includes all',
value: ['frontend', 'framework']
});
});
});
describe('OR Conditions', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle OR conditions', () => {
queryBuilder
.where('isActive', true)
.orWhere('lastLoginAt', '>', Date.now() - 24*60*60*1000);
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(2);
expect(conditions[0].operator).toBe('eq');
expect(conditions[1].operator).toBe('gt');
expect(conditions[1].logical).toBe('or');
});
it('should handle grouped OR conditions', () => {
queryBuilder
.where('isActive', true)
.where((query) => {
query.where('username', 'like', 'admin%')
.orWhere('email', 'like', '%@admin.com');
});
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(2);
expect(conditions[0].field).toBe('isActive');
expect(conditions[1].type).toBe('group');
expect(conditions[1].conditions).toHaveLength(2);
});
});
describe('Ordering', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle single field ordering', () => {
queryBuilder.orderBy('username');
const orderBy = queryBuilder.getOrderBy();
expect(orderBy).toHaveLength(1);
expect(orderBy[0]).toEqual({
field: 'username',
direction: 'asc'
});
});
it('should handle multiple field ordering', () => {
queryBuilder
.orderBy('score', 'desc')
.orderBy('username', 'asc');
const orderBy = queryBuilder.getOrderBy();
expect(orderBy).toHaveLength(2);
expect(orderBy[0]).toEqual({
field: 'score',
direction: 'desc'
});
expect(orderBy[1]).toEqual({
field: 'username',
direction: 'asc'
});
});
it('should handle random ordering', () => {
queryBuilder.orderBy('random');
const orderBy = queryBuilder.getOrderBy();
expect(orderBy).toHaveLength(1);
expect(orderBy[0]).toEqual({
field: 'random',
direction: 'asc'
});
});
});
describe('Pagination', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle limit', () => {
queryBuilder.limit(10);
expect(queryBuilder.getLimit()).toBe(10);
});
it('should handle offset', () => {
queryBuilder.offset(20);
expect(queryBuilder.getOffset()).toBe(20);
});
it('should handle limit and offset together', () => {
queryBuilder.limit(10).offset(20);
expect(queryBuilder.getLimit()).toBe(10);
expect(queryBuilder.getOffset()).toBe(20);
});
it('should handle cursor-based pagination', () => {
queryBuilder.after('cursor-value').limit(10);
expect(queryBuilder.getCursor()).toBe('cursor-value');
expect(queryBuilder.getLimit()).toBe(10);
});
});
describe('Relationship Loading', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle simple relationship loading', () => {
queryBuilder.with(['posts']);
const relationships = queryBuilder.getRelationships();
expect(relationships).toHaveLength(1);
expect(relationships[0]).toEqual({
relation: 'posts',
constraints: undefined
});
});
it('should handle nested relationship loading', () => {
queryBuilder.with(['posts.comments', 'profile']);
const relationships = queryBuilder.getRelationships();
expect(relationships).toHaveLength(2);
expect(relationships[0].relation).toBe('posts.comments');
expect(relationships[1].relation).toBe('profile');
});
it('should handle relationship loading with constraints', () => {
queryBuilder.with(['posts'], (query) => {
query.where('isPublished', true)
.orderBy('publishedAt', 'desc')
.limit(5);
});
const relationships = queryBuilder.getRelationships();
expect(relationships).toHaveLength(1);
expect(relationships[0].relation).toBe('posts');
expect(typeof relationships[0].constraints).toBe('function');
});
});
describe('Aggregation Methods', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should support count queries', async () => {
const countQuery = queryBuilder.where('isActive', true);
// Mock the count execution
jest.spyOn(countQuery, 'count').mockResolvedValue(42);
const count = await countQuery.count();
expect(count).toBe(42);
});
it('should support sum aggregation', async () => {
const sumQuery = queryBuilder.where('isActive', true);
// Mock the sum execution
jest.spyOn(sumQuery, 'sum').mockResolvedValue(1250);
const sum = await sumQuery.sum('score');
expect(sum).toBe(1250);
});
it('should support average aggregation', async () => {
const avgQuery = queryBuilder.where('isActive', true);
// Mock the average execution
jest.spyOn(avgQuery, 'average').mockResolvedValue(85.5);
const avg = await avgQuery.average('score');
expect(avg).toBe(85.5);
});
it('should support min/max aggregation', async () => {
const query = queryBuilder.where('isActive', true);
// Mock the min/max execution
jest.spyOn(query, 'min').mockResolvedValue(10);
jest.spyOn(query, 'max').mockResolvedValue(100);
const min = await query.min('score');
const max = await query.max('score');
expect(min).toBe(10);
expect(max).toBe(100);
});
});
describe('Query Execution', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should execute find queries', async () => {
const mockResults = [
{ id: '1', username: 'alice', email: 'alice@example.com' },
{ id: '2', username: 'bob', email: 'bob@example.com' }
];
// Mock the find execution
jest.spyOn(queryBuilder, 'find').mockResolvedValue(mockResults as any);
const results = await queryBuilder
.where('isActive', true)
.orderBy('username')
.find();
expect(results).toEqual(mockResults);
});
it('should execute findOne queries', async () => {
const mockResult = { id: '1', username: 'alice', email: 'alice@example.com' };
// Mock the findOne execution
jest.spyOn(queryBuilder, 'findOne').mockResolvedValue(mockResult as any);
const result = await queryBuilder
.where('username', 'alice')
.findOne();
expect(result).toEqual(mockResult);
});
it('should return null for findOne when no results', async () => {
// Mock the findOne execution to return null
jest.spyOn(queryBuilder, 'findOne').mockResolvedValue(null);
const result = await queryBuilder
.where('username', 'nonexistent')
.findOne();
expect(result).toBeNull();
});
it('should execute exists queries', async () => {
// Mock the exists execution
jest.spyOn(queryBuilder, 'exists').mockResolvedValue(true);
const exists = await queryBuilder
.where('username', 'alice')
.exists();
expect(exists).toBe(true);
});
});
describe('Caching', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should support query caching', () => {
queryBuilder.cache(300); // 5 minutes
expect(queryBuilder.getCacheOptions()).toEqual({
enabled: true,
ttl: 300,
key: undefined
});
});
it('should support custom cache keys', () => {
queryBuilder.cache(600, 'active-users');
expect(queryBuilder.getCacheOptions()).toEqual({
enabled: true,
ttl: 600,
key: 'active-users'
});
});
it('should disable caching', () => {
queryBuilder.noCache();
expect(queryBuilder.getCacheOptions()).toEqual({
enabled: false,
ttl: undefined,
key: undefined
});
});
});
describe('Complex Query Building', () => {
it('should handle complex queries with multiple conditions', () => {
const queryBuilder = new QueryBuilder(TestPost)
.where('isPublished', true)
.where('likeCount', '>=', 10)
.where('tags', 'includes any', ['javascript', 'typescript'])
.where((query) => {
query.where('title', 'like', '%tutorial%')
.orWhere('content', 'like', '%guide%');
})
.with(['user'])
.orderBy('likeCount', 'desc')
.orderBy('publishedAt', 'desc')
.limit(20)
.cache(300);
// Verify the query structure
const conditions = queryBuilder.getWhereConditions();
expect(conditions).toHaveLength(4);
const orderBy = queryBuilder.getOrderBy();
expect(orderBy).toHaveLength(2);
const relationships = queryBuilder.getRelationships();
expect(relationships).toHaveLength(1);
expect(queryBuilder.getLimit()).toBe(20);
expect(queryBuilder.getCacheOptions().enabled).toBe(true);
});
it('should handle pagination queries', async () => {
// Mock paginate execution
const mockPaginatedResult = {
data: [
{ id: '1', title: 'Post 1' },
{ id: '2', title: 'Post 2' }
],
total: 100,
page: 1,
perPage: 20,
totalPages: 5,
hasMore: true
};
const queryBuilder = new QueryBuilder(TestPost);
jest.spyOn(queryBuilder, 'paginate').mockResolvedValue(mockPaginatedResult as any);
const result = await queryBuilder
.where('isPublished', true)
.orderBy('publishedAt', 'desc')
.paginate(1, 20);
expect(result).toEqual(mockPaginatedResult);
});
});
describe('Query Builder State', () => {
it('should clone query builder state', () => {
const originalQuery = new QueryBuilder(TestUser)
.where('isActive', true)
.orderBy('username')
.limit(10);
const clonedQuery = originalQuery.clone();
expect(clonedQuery).not.toBe(originalQuery);
expect(clonedQuery.getWhereConditions()).toEqual(originalQuery.getWhereConditions());
expect(clonedQuery.getOrderBy()).toEqual(originalQuery.getOrderBy());
expect(clonedQuery.getLimit()).toEqual(originalQuery.getLimit());
});
it('should reset query builder state', () => {
const queryBuilder = new QueryBuilder(TestUser)
.where('isActive', true)
.orderBy('username')
.limit(10)
.cache(300);
queryBuilder.reset();
expect(queryBuilder.getWhereConditions()).toHaveLength(0);
expect(queryBuilder.getOrderBy()).toHaveLength(0);
expect(queryBuilder.getLimit()).toBeUndefined();
expect(queryBuilder.getCacheOptions().enabled).toBe(false);
});
});
describe('Error Handling', () => {
let queryBuilder: QueryBuilder<TestUser>;
beforeEach(() => {
queryBuilder = new QueryBuilder(TestUser);
});
it('should handle invalid operators', () => {
expect(() => {
queryBuilder.where('username', 'invalid-operator' as any, 'value');
}).toThrow();
});
it('should handle invalid field names', () => {
expect(() => {
queryBuilder.where('nonexistentField', 'value');
}).toThrow();
});
it('should handle invalid order directions', () => {
expect(() => {
queryBuilder.orderBy('username', 'invalid-direction' as any);
}).toThrow();
});
it('should handle negative limits', () => {
expect(() => {
queryBuilder.limit(-1);
}).toThrow();
});
it('should handle negative offsets', () => {
expect(() => {
queryBuilder.offset(-1);
}).toThrow();
});
});
});

View File

@ -0,0 +1,575 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { RelationshipManager, RelationshipLoadOptions } from '../../../src/framework/relationships/RelationshipManager';
import { BaseModel } from '../../../src/framework/models/BaseModel';
import { Model, Field, BelongsTo, HasMany, HasOne, ManyToMany } from '../../../src/framework/models/decorators';
import { QueryBuilder } from '../../../src/framework/query/QueryBuilder';
import { createMockServices } from '../../mocks/services';
// Test models for relationship testing
@Model({
scope: 'global',
type: 'docstore'
})
class User extends BaseModel {
@Field({ type: 'string', required: true })
username: string;
@Field({ type: 'string', required: true })
email: string;
@HasMany(() => Post, 'userId')
posts: Post[];
@HasOne(() => Profile, 'userId')
profile: Profile;
@ManyToMany(() => Role, 'user_roles', 'userId', 'roleId')
roles: Role[];
// Mock query methods
static where = jest.fn().mockReturnThis();
static whereIn = jest.fn().mockReturnThis();
static first = jest.fn();
static exec = jest.fn();
}
@Model({
scope: 'user',
type: 'docstore'
})
class Post extends BaseModel {
@Field({ type: 'string', required: true })
title: string;
@Field({ type: 'string', required: true })
content: string;
@Field({ type: 'string', required: true })
userId: string;
@BelongsTo(() => User, 'userId')
user: User;
// Mock query methods
static where = jest.fn().mockReturnThis();
static whereIn = jest.fn().mockReturnThis();
static first = jest.fn();
static exec = jest.fn();
}
@Model({
scope: 'global',
type: 'docstore'
})
class Profile extends BaseModel {
@Field({ type: 'string', required: true })
bio: string;
@Field({ type: 'string', required: true })
userId: string;
@BelongsTo(() => User, 'userId')
user: User;
// Mock query methods
static where = jest.fn().mockReturnThis();
static whereIn = jest.fn().mockReturnThis();
static first = jest.fn();
static exec = jest.fn();
}
@Model({
scope: 'global',
type: 'docstore'
})
class Role extends BaseModel {
@Field({ type: 'string', required: true })
name: string;
@ManyToMany(() => User, 'user_roles', 'roleId', 'userId')
users: User[];
// Mock query methods
static where = jest.fn().mockReturnThis();
static whereIn = jest.fn().mockReturnThis();
static first = jest.fn();
static exec = jest.fn();
}
@Model({
scope: 'global',
type: 'docstore'
})
class UserRole extends BaseModel {
@Field({ type: 'string', required: true })
userId: string;
@Field({ type: 'string', required: true })
roleId: string;
// Mock query methods
static where = jest.fn().mockReturnThis();
static whereIn = jest.fn().mockReturnThis();
static first = jest.fn();
static exec = jest.fn();
}
describe('RelationshipManager', () => {
let relationshipManager: RelationshipManager;
let mockFramework: any;
let user: User;
let post: Post;
let profile: Profile;
let role: Role;
beforeEach(() => {
const mockServices = createMockServices();
mockFramework = {
services: mockServices
};
relationshipManager = new RelationshipManager(mockFramework);
// Create test instances
user = new User();
user.id = 'user-123';
user.username = 'testuser';
user.email = 'test@example.com';
post = new Post();
post.id = 'post-123';
post.title = 'Test Post';
post.content = 'Test content';
post.userId = 'user-123';
profile = new Profile();
profile.id = 'profile-123';
profile.bio = 'Test bio';
profile.userId = 'user-123';
role = new Role();
role.id = 'role-123';
role.name = 'admin';
// Clear all mocks
jest.clearAllMocks();
});
describe('BelongsTo Relationships', () => {
it('should load belongsTo relationship correctly', async () => {
const mockUser = new User();
mockUser.id = 'user-123';
User.first.mockResolvedValue(mockUser);
const result = await relationshipManager.loadRelationship(post, 'user');
expect(User.where).toHaveBeenCalledWith('id', '=', 'user-123');
expect(User.first).toHaveBeenCalled();
expect(result).toBe(mockUser);
expect(post._loadedRelations.get('user')).toBe(mockUser);
});
it('should return null for belongsTo when foreign key is null', async () => {
post.userId = null as any;
const result = await relationshipManager.loadRelationship(post, 'user');
expect(result).toBeNull();
expect(User.where).not.toHaveBeenCalled();
});
it('should apply constraints to belongsTo queries', async () => {
const mockUser = new User();
User.first.mockResolvedValue(mockUser);
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(mockUser)
};
User.where.mockReturnValue(mockQueryBuilder);
const options: RelationshipLoadOptions = {
constraints: (query) => query.where('isActive', true)
};
await relationshipManager.loadRelationship(post, 'user', options);
expect(User.where).toHaveBeenCalledWith('id', '=', 'user-123');
expect(options.constraints).toBeDefined();
});
});
describe('HasMany Relationships', () => {
it('should load hasMany relationship correctly', async () => {
const mockPosts = [
{ id: 'post-1', title: 'Post 1', userId: 'user-123' },
{ id: 'post-2', title: 'Post 2', userId: 'user-123' }
];
Post.exec.mockResolvedValue(mockPosts);
const result = await relationshipManager.loadRelationship(user, 'posts');
expect(Post.where).toHaveBeenCalledWith('userId', '=', 'user-123');
expect(Post.exec).toHaveBeenCalled();
expect(result).toEqual(mockPosts);
expect(user._loadedRelations.get('posts')).toEqual(mockPosts);
});
it('should return empty array for hasMany when local key is null', async () => {
user.id = null as any;
const result = await relationshipManager.loadRelationship(user, 'posts');
expect(result).toEqual([]);
expect(Post.where).not.toHaveBeenCalled();
});
it('should apply ordering and limits to hasMany queries', async () => {
const mockPosts = [{ id: 'post-1', title: 'Post 1' }];
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(mockPosts)
};
Post.where.mockReturnValue(mockQueryBuilder);
const options: RelationshipLoadOptions = {
orderBy: { field: 'createdAt', direction: 'desc' },
limit: 5
};
await relationshipManager.loadRelationship(user, 'posts', options);
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('createdAt', 'desc');
expect(mockQueryBuilder.limit).toHaveBeenCalledWith(5);
});
});
describe('HasOne Relationships', () => {
it('should load hasOne relationship correctly', async () => {
const mockProfile = { id: 'profile-1', bio: 'Test bio', userId: 'user-123' };
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([mockProfile])
};
Profile.where.mockReturnValue(mockQueryBuilder);
const result = await relationshipManager.loadRelationship(user, 'profile');
expect(Profile.where).toHaveBeenCalledWith('userId', '=', 'user-123');
expect(mockQueryBuilder.limit).toHaveBeenCalledWith(1);
expect(result).toBe(mockProfile);
});
it('should return null for hasOne when no results found', async () => {
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([])
};
Profile.where.mockReturnValue(mockQueryBuilder);
const result = await relationshipManager.loadRelationship(user, 'profile');
expect(result).toBeNull();
});
});
describe('ManyToMany Relationships', () => {
it('should load manyToMany relationship correctly', async () => {
const mockJunctionRecords = [
{ userId: 'user-123', roleId: 'role-1' },
{ userId: 'user-123', roleId: 'role-2' }
];
const mockRoles = [
{ id: 'role-1', name: 'admin' },
{ id: 'role-2', name: 'editor' }
];
// Mock UserRole (junction table)
const mockJunctionQuery = {
where: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(mockJunctionRecords)
};
// Mock Role query
const mockRoleQuery = {
whereIn: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(mockRoles)
};
UserRole.where.mockReturnValue(mockJunctionQuery);
Role.whereIn.mockReturnValue(mockRoleQuery);
// Mock the relationship config to include the through model
const originalRelationships = User.relationships;
User.relationships = new Map();
User.relationships.set('roles', {
type: 'manyToMany',
model: Role,
through: UserRole,
foreignKey: 'roleId',
localKey: 'id',
propertyKey: 'roles'
});
const result = await relationshipManager.loadRelationship(user, 'roles');
expect(UserRole.where).toHaveBeenCalledWith('id', '=', 'user-123');
expect(Role.whereIn).toHaveBeenCalledWith('id', ['role-1', 'role-2']);
expect(result).toEqual(mockRoles);
// Restore original relationships
User.relationships = originalRelationships;
});
it('should handle empty junction table for manyToMany', async () => {
const mockJunctionQuery = {
where: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([])
};
UserRole.where.mockReturnValue(mockJunctionQuery);
// Mock the relationship config
const originalRelationships = User.relationships;
User.relationships = new Map();
User.relationships.set('roles', {
type: 'manyToMany',
model: Role,
through: UserRole,
foreignKey: 'roleId',
localKey: 'id',
propertyKey: 'roles'
});
const result = await relationshipManager.loadRelationship(user, 'roles');
expect(result).toEqual([]);
// Restore original relationships
User.relationships = originalRelationships;
});
it('should throw error for manyToMany without through model', async () => {
// Mock the relationship config without through model
const originalRelationships = User.relationships;
User.relationships = new Map();
User.relationships.set('roles', {
type: 'manyToMany',
model: Role,
through: null as any,
foreignKey: 'roleId',
localKey: 'id',
propertyKey: 'roles'
});
await expect(relationshipManager.loadRelationship(user, 'roles')).rejects.toThrow(
'Many-to-many relationships require a through model'
);
// Restore original relationships
User.relationships = originalRelationships;
});
});
describe('Eager Loading', () => {
it('should eager load multiple relationships for multiple instances', async () => {
const users = [user, new User()];
users[1].id = 'user-456';
const mockPosts = [
{ id: 'post-1', userId: 'user-123' },
{ id: 'post-2', userId: 'user-456' }
];
const mockProfiles = [
{ id: 'profile-1', userId: 'user-123' },
{ id: 'profile-2', userId: 'user-456' }
];
// Mock hasMany query for posts
const mockPostQuery = {
whereIn: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(mockPosts)
};
Post.whereIn.mockReturnValue(mockPostQuery);
// Mock hasOne query for profiles
const mockProfileQuery = {
whereIn: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(mockProfiles)
};
Profile.whereIn.mockReturnValue(mockProfileQuery);
await relationshipManager.eagerLoadRelationships(users, ['posts', 'profile']);
expect(Post.whereIn).toHaveBeenCalledWith('userId', ['user-123', 'user-456']);
expect(Profile.whereIn).toHaveBeenCalledWith('userId', ['user-123', 'user-456']);
// Check that relationships were loaded on instances
expect(users[0]._loadedRelations.has('posts')).toBe(true);
expect(users[0]._loadedRelations.has('profile')).toBe(true);
expect(users[1]._loadedRelations.has('posts')).toBe(true);
expect(users[1]._loadedRelations.has('profile')).toBe(true);
});
it('should handle empty instances array', async () => {
await relationshipManager.eagerLoadRelationships([], ['posts']);
expect(Post.whereIn).not.toHaveBeenCalled();
});
it('should skip non-existent relationships during eager loading', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
await relationshipManager.eagerLoadRelationships([user], ['nonExistentRelation']);
expect(consoleSpy).toHaveBeenCalledWith(
"Relationship 'nonExistentRelation' not found on User"
);
consoleSpy.mockRestore();
});
});
describe('Caching', () => {
it('should use cache when available', async () => {
const mockUser = new User();
// Mock cache hit
jest.spyOn(relationshipManager['cache'], 'get').mockReturnValue(mockUser);
jest.spyOn(relationshipManager['cache'], 'generateKey').mockReturnValue('cache-key');
const result = await relationshipManager.loadRelationship(post, 'user');
expect(result).toBe(mockUser);
expect(User.where).not.toHaveBeenCalled(); // Should not query database
});
it('should store in cache after loading', async () => {
const mockUser = new User();
User.first.mockResolvedValue(mockUser);
const setCacheSpy = jest.spyOn(relationshipManager['cache'], 'set');
const generateKeySpy = jest.spyOn(relationshipManager['cache'], 'generateKey').mockReturnValue('cache-key');
await relationshipManager.loadRelationship(post, 'user');
expect(setCacheSpy).toHaveBeenCalledWith('cache-key', mockUser, 'User', 'belongsTo');
expect(generateKeySpy).toHaveBeenCalled();
});
it('should skip cache when useCache is false', async () => {
const mockUser = new User();
User.first.mockResolvedValue(mockUser);
const getCacheSpy = jest.spyOn(relationshipManager['cache'], 'get');
const setCacheSpy = jest.spyOn(relationshipManager['cache'], 'set');
await relationshipManager.loadRelationship(post, 'user', { useCache: false });
expect(getCacheSpy).not.toHaveBeenCalled();
expect(setCacheSpy).not.toHaveBeenCalled();
});
});
describe('Cache Management', () => {
it('should invalidate relationship cache for specific relationship', () => {
const invalidateSpy = jest.spyOn(relationshipManager['cache'], 'invalidate').mockReturnValue(true);
const generateKeySpy = jest.spyOn(relationshipManager['cache'], 'generateKey').mockReturnValue('cache-key');
const result = relationshipManager.invalidateRelationshipCache(user, 'posts');
expect(generateKeySpy).toHaveBeenCalledWith(user, 'posts');
expect(invalidateSpy).toHaveBeenCalledWith('cache-key');
expect(result).toBe(1);
});
it('should invalidate all cache for instance when no relationship specified', () => {
const invalidateByInstanceSpy = jest.spyOn(relationshipManager['cache'], 'invalidateByInstance').mockReturnValue(3);
const result = relationshipManager.invalidateRelationshipCache(user);
expect(invalidateByInstanceSpy).toHaveBeenCalledWith(user);
expect(result).toBe(3);
});
it('should invalidate cache by model name', () => {
const invalidateByModelSpy = jest.spyOn(relationshipManager['cache'], 'invalidateByModel').mockReturnValue(5);
const result = relationshipManager.invalidateModelCache('User');
expect(invalidateByModelSpy).toHaveBeenCalledWith('User');
expect(result).toBe(5);
});
it('should get cache statistics', () => {
const mockStats = { cache: { hitRate: 0.85 }, performance: { avgLoadTime: 50 } };
jest.spyOn(relationshipManager['cache'], 'getStats').mockReturnValue(mockStats.cache);
jest.spyOn(relationshipManager['cache'], 'analyzePerformance').mockReturnValue(mockStats.performance);
const result = relationshipManager.getRelationshipCacheStats();
expect(result).toEqual(mockStats);
});
it('should warmup cache', async () => {
const warmupSpy = jest.spyOn(relationshipManager['cache'], 'warmup').mockResolvedValue();
await relationshipManager.warmupRelationshipCache([user], ['posts']);
expect(warmupSpy).toHaveBeenCalledWith([user], ['posts'], expect.any(Function));
});
it('should cleanup expired cache', () => {
const cleanupSpy = jest.spyOn(relationshipManager['cache'], 'cleanup').mockReturnValue(10);
const result = relationshipManager.cleanupExpiredCache();
expect(cleanupSpy).toHaveBeenCalled();
expect(result).toBe(10);
});
it('should clear all cache', () => {
const clearSpy = jest.spyOn(relationshipManager['cache'], 'clear');
relationshipManager.clearRelationshipCache();
expect(clearSpy).toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('should throw error for non-existent relationship', async () => {
await expect(relationshipManager.loadRelationship(user, 'nonExistentRelation')).rejects.toThrow(
"Relationship 'nonExistentRelation' not found on User"
);
});
it('should throw error for unsupported relationship type', async () => {
// Mock an invalid relationship type
const originalRelationships = User.relationships;
User.relationships = new Map();
User.relationships.set('invalidRelation', {
type: 'unsupported' as any,
model: Post,
foreignKey: 'userId',
propertyKey: 'invalidRelation'
});
await expect(relationshipManager.loadRelationship(user, 'invalidRelation')).rejects.toThrow(
'Unsupported relationship type: unsupported'
);
// Restore original relationships
User.relationships = originalRelationships;
});
});
});

View File

@ -0,0 +1,436 @@
import { describe, beforeEach, it, expect, jest } from '@jest/globals';
import { ShardManager, ShardInfo } from '../../../src/framework/sharding/ShardManager';
import { FrameworkOrbitDBService } from '../../../src/framework/services/OrbitDBService';
import { ShardingConfig } from '../../../src/framework/types/framework';
import { createMockServices } from '../../mocks/services';
describe('ShardManager', () => {
let shardManager: ShardManager;
let mockOrbitDBService: FrameworkOrbitDBService;
let mockDatabase: any;
beforeEach(() => {
const mockServices = createMockServices();
mockOrbitDBService = mockServices.orbitDBService;
// Create mock database
mockDatabase = {
address: { toString: () => 'mock-address-123' },
set: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(null),
del: jest.fn().mockResolvedValue(undefined),
put: jest.fn().mockResolvedValue('mock-hash'),
add: jest.fn().mockResolvedValue('mock-hash'),
query: jest.fn().mockReturnValue([])
};
// Mock OrbitDB service methods
jest.spyOn(mockOrbitDBService, 'openDatabase').mockResolvedValue(mockDatabase);
shardManager = new ShardManager();
shardManager.setOrbitDBService(mockOrbitDBService);
jest.clearAllMocks();
});
describe('Initialization', () => {
it('should set OrbitDB service correctly', () => {
const newShardManager = new ShardManager();
newShardManager.setOrbitDBService(mockOrbitDBService);
// No direct way to test this, but we can verify it works in other tests
expect(newShardManager).toBeInstanceOf(ShardManager);
});
it('should throw error when OrbitDB service not set', async () => {
const newShardManager = new ShardManager();
const config: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
await expect(newShardManager.createShards('TestModel', config)).rejects.toThrow(
'OrbitDB service not initialized'
);
});
});
describe('Shard Creation', () => {
it('should create shards with hash strategy', async () => {
const config: ShardingConfig = { strategy: 'hash', count: 3, key: 'id' };
await shardManager.createShards('TestModel', config, 'docstore');
// Should create 3 shards
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledTimes(3);
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('testmodel-shard-0', 'docstore');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('testmodel-shard-1', 'docstore');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('testmodel-shard-2', 'docstore');
const shards = shardManager.getAllShards('TestModel');
expect(shards).toHaveLength(3);
expect(shards[0]).toMatchObject({
name: 'testmodel-shard-0',
index: 0,
address: 'mock-address-123'
});
});
it('should create shards with range strategy', async () => {
const config: ShardingConfig = { strategy: 'range', count: 2, key: 'name' };
await shardManager.createShards('RangeModel', config, 'keyvalue');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledTimes(2);
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('rangemodel-shard-0', 'keyvalue');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('rangemodel-shard-1', 'keyvalue');
});
it('should create shards with user strategy', async () => {
const config: ShardingConfig = { strategy: 'user', count: 4, key: 'userId' };
await shardManager.createShards('UserModel', config);
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledTimes(4);
const shards = shardManager.getAllShards('UserModel');
expect(shards).toHaveLength(4);
});
it('should handle shard creation errors', async () => {
const config: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
jest.spyOn(mockOrbitDBService, 'openDatabase').mockRejectedValueOnce(new Error('Database creation failed'));
await expect(shardManager.createShards('FailModel', config)).rejects.toThrow('Database creation failed');
});
});
describe('Shard Routing', () => {
beforeEach(async () => {
const config: ShardingConfig = { strategy: 'hash', count: 4, key: 'id' };
await shardManager.createShards('TestModel', config);
});
it('should route keys to consistent shards with hash strategy', () => {
const key1 = 'user-123';
const key2 = 'user-456';
const key3 = 'user-123'; // Same as key1
const shard1 = shardManager.getShardForKey('TestModel', key1);
const shard2 = shardManager.getShardForKey('TestModel', key2);
const shard3 = shardManager.getShardForKey('TestModel', key3);
// Same keys should route to same shards
expect(shard1.index).toBe(shard3.index);
// Different keys may route to different shards
expect(shard1.index).toBeGreaterThanOrEqual(0);
expect(shard1.index).toBeLessThan(4);
expect(shard2.index).toBeGreaterThanOrEqual(0);
expect(shard2.index).toBeLessThan(4);
});
it('should route keys with range strategy', async () => {
const config: ShardingConfig = { strategy: 'range', count: 3, key: 'name' };
await shardManager.createShards('RangeModel', config);
const shardA = shardManager.getShardForKey('RangeModel', 'apple');
const shardM = shardManager.getShardForKey('RangeModel', 'middle');
const shardZ = shardManager.getShardForKey('RangeModel', 'zebra');
// Keys starting with different letters should potentially route to different shards
expect(shardA.index).toBeGreaterThanOrEqual(0);
expect(shardA.index).toBeLessThan(3);
expect(shardM.index).toBeGreaterThanOrEqual(0);
expect(shardM.index).toBeLessThan(3);
expect(shardZ.index).toBeGreaterThanOrEqual(0);
expect(shardZ.index).toBeLessThan(3);
});
it('should handle user strategy routing', async () => {
const config: ShardingConfig = { strategy: 'user', count: 2, key: 'userId' };
await shardManager.createShards('UserModel', config);
const shard1 = shardManager.getShardForKey('UserModel', 'user-abc');
const shard2 = shardManager.getShardForKey('UserModel', 'user-def');
const shard3 = shardManager.getShardForKey('UserModel', 'user-abc'); // Same as shard1
expect(shard1.index).toBe(shard3.index);
expect(shard1.index).toBeGreaterThanOrEqual(0);
expect(shard1.index).toBeLessThan(2);
});
it('should throw error for unsupported sharding strategy', async () => {
const config: ShardingConfig = { strategy: 'unsupported' as any, count: 2, key: 'id' };
await shardManager.createShards('UnsupportedModel', config);
expect(() => {
shardManager.getShardForKey('UnsupportedModel', 'test-key');
}).toThrow('Unsupported sharding strategy: unsupported');
});
it('should throw error when no shards exist for model', () => {
expect(() => {
shardManager.getShardForKey('NonExistentModel', 'test-key');
}).toThrow('No shards found for model NonExistentModel');
});
it('should throw error when no shard configuration exists', async () => {
// Manually clear the config to simulate this error
const config: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
await shardManager.createShards('ConfigTestModel', config);
// Access private property for testing (not ideal but necessary for this test)
(shardManager as any).shardConfigs.delete('ConfigTestModel');
expect(() => {
shardManager.getShardForKey('ConfigTestModel', 'test-key');
}).toThrow('No shard configuration found for model ConfigTestModel');
});
});
describe('Shard Management', () => {
beforeEach(async () => {
const config: ShardingConfig = { strategy: 'hash', count: 3, key: 'id' };
await shardManager.createShards('TestModel', config);
});
it('should get all shards for a model', () => {
const shards = shardManager.getAllShards('TestModel');
expect(shards).toHaveLength(3);
expect(shards[0].name).toBe('testmodel-shard-0');
expect(shards[1].name).toBe('testmodel-shard-1');
expect(shards[2].name).toBe('testmodel-shard-2');
});
it('should return empty array for non-existent model', () => {
const shards = shardManager.getAllShards('NonExistentModel');
expect(shards).toEqual([]);
});
it('should get shard by index', () => {
const shard0 = shardManager.getShardByIndex('TestModel', 0);
const shard1 = shardManager.getShardByIndex('TestModel', 1);
const shard2 = shardManager.getShardByIndex('TestModel', 2);
const shardInvalid = shardManager.getShardByIndex('TestModel', 5);
expect(shard0?.index).toBe(0);
expect(shard1?.index).toBe(1);
expect(shard2?.index).toBe(2);
expect(shardInvalid).toBeUndefined();
});
it('should get shard count', () => {
const count = shardManager.getShardCount('TestModel');
expect(count).toBe(3);
const nonExistentCount = shardManager.getShardCount('NonExistentModel');
expect(nonExistentCount).toBe(0);
});
it('should get all models with shards', () => {
const models = shardManager.getAllModelsWithShards();
expect(models).toContain('TestModel');
});
});
describe('Global Index Management', () => {
it('should create global index with shards', async () => {
await shardManager.createGlobalIndex('TestModel', 'username-index');
// Should create 4 index shards (default)
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledTimes(4);
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('username-index-shard-0', 'keyvalue');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('username-index-shard-1', 'keyvalue');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('username-index-shard-2', 'keyvalue');
expect(mockOrbitDBService.openDatabase).toHaveBeenCalledWith('username-index-shard-3', 'keyvalue');
const indexShards = shardManager.getAllShards('username-index');
expect(indexShards).toHaveLength(4);
});
it('should add to global index', async () => {
await shardManager.createGlobalIndex('TestModel', 'email-index');
await shardManager.addToGlobalIndex('email-index', 'user@example.com', 'user-123');
// Should call set on one of the index shards
expect(mockDatabase.set).toHaveBeenCalledWith('user@example.com', 'user-123');
});
it('should get from global index', async () => {
await shardManager.createGlobalIndex('TestModel', 'id-index');
mockDatabase.get.mockResolvedValue('user-456');
const result = await shardManager.getFromGlobalIndex('id-index', 'lookup-key');
expect(result).toBe('user-456');
expect(mockDatabase.get).toHaveBeenCalledWith('lookup-key');
});
it('should remove from global index', async () => {
await shardManager.createGlobalIndex('TestModel', 'remove-index');
await shardManager.removeFromGlobalIndex('remove-index', 'key-to-remove');
expect(mockDatabase.del).toHaveBeenCalledWith('key-to-remove');
});
it('should handle missing global index', async () => {
await expect(
shardManager.addToGlobalIndex('non-existent-index', 'key', 'value')
).rejects.toThrow('Global index non-existent-index not found');
await expect(
shardManager.getFromGlobalIndex('non-existent-index', 'key')
).rejects.toThrow('Global index non-existent-index not found');
await expect(
shardManager.removeFromGlobalIndex('non-existent-index', 'key')
).rejects.toThrow('Global index non-existent-index not found');
});
it('should handle global index operation errors', async () => {
await shardManager.createGlobalIndex('TestModel', 'error-index');
mockDatabase.set.mockRejectedValue(new Error('Database error'));
mockDatabase.get.mockRejectedValue(new Error('Database error'));
mockDatabase.del.mockRejectedValue(new Error('Database error'));
await expect(
shardManager.addToGlobalIndex('error-index', 'key', 'value')
).rejects.toThrow('Database error');
const result = await shardManager.getFromGlobalIndex('error-index', 'key');
expect(result).toBeNull(); // Should return null on error
await expect(
shardManager.removeFromGlobalIndex('error-index', 'key')
).rejects.toThrow('Database error');
});
});
describe('Query Operations', () => {
beforeEach(async () => {
const config: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
await shardManager.createShards('QueryModel', config);
});
it('should query all shards', async () => {
const mockQueryFn = jest.fn()
.mockResolvedValueOnce([{ id: '1', name: 'test1' }])
.mockResolvedValueOnce([{ id: '2', name: 'test2' }]);
const results = await shardManager.queryAllShards('QueryModel', mockQueryFn);
expect(mockQueryFn).toHaveBeenCalledTimes(2);
expect(results).toEqual([
{ id: '1', name: 'test1' },
{ id: '2', name: 'test2' }
]);
});
it('should handle query errors gracefully', async () => {
const mockQueryFn = jest.fn()
.mockResolvedValueOnce([{ id: '1', name: 'test1' }])
.mockRejectedValueOnce(new Error('Query failed'));
const results = await shardManager.queryAllShards('QueryModel', mockQueryFn);
expect(results).toEqual([{ id: '1', name: 'test1' }]);
});
it('should throw error when querying non-existent model', async () => {
const mockQueryFn = jest.fn();
await expect(
shardManager.queryAllShards('NonExistentModel', mockQueryFn)
).rejects.toThrow('No shards found for model NonExistentModel');
});
});
describe('Statistics and Monitoring', () => {
beforeEach(async () => {
const config: ShardingConfig = { strategy: 'hash', count: 3, key: 'id' };
await shardManager.createShards('StatsModel', config);
});
it('should get shard statistics', () => {
const stats = shardManager.getShardStatistics('StatsModel');
expect(stats).toEqual({
modelName: 'StatsModel',
shardCount: 3,
shards: [
{ name: 'statsmodel-shard-0', index: 0, address: 'mock-address-123' },
{ name: 'statsmodel-shard-1', index: 1, address: 'mock-address-123' },
{ name: 'statsmodel-shard-2', index: 2, address: 'mock-address-123' }
]
});
});
it('should return null for non-existent model statistics', () => {
const stats = shardManager.getShardStatistics('NonExistentModel');
expect(stats).toBeNull();
});
it('should list all models with shards', async () => {
const config1: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
const config2: ShardingConfig = { strategy: 'range', count: 3, key: 'name' };
await shardManager.createShards('Model1', config1);
await shardManager.createShards('Model2', config2);
const models = shardManager.getAllModelsWithShards();
expect(models).toContain('StatsModel'); // From beforeEach
expect(models).toContain('Model1');
expect(models).toContain('Model2');
expect(models.length).toBeGreaterThanOrEqual(3);
});
});
describe('Hash Function Consistency', () => {
it('should produce consistent hash results', () => {
// Test the hash function directly by creating shards and checking consistency
const testKeys = ['user-123', 'user-456', 'user-789', 'user-abc', 'user-def'];
const shardCount = 4;
// Get shard indices for each key multiple times
const config: ShardingConfig = { strategy: 'hash', count: shardCount, key: 'id' };
return shardManager.createShards('HashTestModel', config).then(() => {
testKeys.forEach(key => {
const shard1 = shardManager.getShardForKey('HashTestModel', key);
const shard2 = shardManager.getShardForKey('HashTestModel', key);
const shard3 = shardManager.getShardForKey('HashTestModel', key);
// Same key should always route to same shard
expect(shard1.index).toBe(shard2.index);
expect(shard2.index).toBe(shard3.index);
// Shard index should be within valid range
expect(shard1.index).toBeGreaterThanOrEqual(0);
expect(shard1.index).toBeLessThan(shardCount);
});
});
});
});
describe('Cleanup', () => {
it('should stop and clear all resources', async () => {
const config: ShardingConfig = { strategy: 'hash', count: 2, key: 'id' };
await shardManager.createShards('CleanupModel', config);
await shardManager.createGlobalIndex('CleanupModel', 'cleanup-index');
expect(shardManager.getAllShards('CleanupModel')).toHaveLength(2);
expect(shardManager.getAllShards('cleanup-index')).toHaveLength(4);
await shardManager.stop();
expect(shardManager.getAllShards('CleanupModel')).toHaveLength(0);
expect(shardManager.getAllShards('cleanup-index')).toHaveLength(0);
expect(shardManager.getAllModelsWithShards()).toHaveLength(0);
});
});
});