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:
parent
067e462339
commit
1cbca09352
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,4 @@
|
|||||||
network.txt
|
network.txt
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
system.txt
|
|
||||||
.DS_Store
|
.DS_Store
|
34
jest.config.mjs
Normal file
34
jest.config.mjs
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
12
package.json
12
package.json
@ -18,7 +18,13 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"lint": "npx eslint src",
|
"lint": "npx eslint src",
|
||||||
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
|
"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": [
|
"keywords": [
|
||||||
"ipfs",
|
"ipfs",
|
||||||
@ -60,8 +66,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.24.0",
|
"@eslint/js": "^9.24.0",
|
||||||
|
"@jest/globals": "^30.0.1",
|
||||||
"@orbitdb/core-types": "^1.0.14",
|
"@orbitdb/core-types": "^1.0.14",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/node-forge": "^1.3.11",
|
"@types/node-forge": "^1.3.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||||
@ -71,9 +79,11 @@
|
|||||||
"eslint-plugin-prettier": "^5.2.6",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
|
"jest": "^30.0.1",
|
||||||
"lint-staged": "^15.5.0",
|
"lint-staged": "^15.5.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
"tsc-esm-fix": "^3.1.2",
|
"tsc-esm-fix": "^3.1.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.29.0"
|
"typescript-eslint": "^8.29.0"
|
||||||
|
8451
pnpm-lock.yaml
generated
8451
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1646
system.txt
Normal file
1646
system.txt
Normal file
File diff suppressed because it is too large
Load Diff
996
tests/e2e/blog-example.test.ts
Normal file
996
tests/e2e/blog-example.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
536
tests/integration/DebrosFramework.test.ts
Normal file
536
tests/integration/DebrosFramework.test.ts
Normal 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
244
tests/mocks/ipfs.ts
Normal 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
154
tests/mocks/orbitdb.ts
Normal 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
35
tests/mocks/services.ts
Normal 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
51
tests/setup.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
440
tests/unit/core/DatabaseManager.test.ts
Normal file
440
tests/unit/core/DatabaseManager.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
478
tests/unit/decorators/decorators.test.ts
Normal file
478
tests/unit/decorators/decorators.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
652
tests/unit/migrations/MigrationManager.test.ts
Normal file
652
tests/unit/migrations/MigrationManager.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
458
tests/unit/models/BaseModel.test.ts
Normal file
458
tests/unit/models/BaseModel.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
664
tests/unit/query/QueryBuilder.test.ts
Normal file
664
tests/unit/query/QueryBuilder.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
575
tests/unit/relationships/RelationshipManager.test.ts
Normal file
575
tests/unit/relationships/RelationshipManager.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
436
tests/unit/sharding/ShardManager.test.ts
Normal file
436
tests/unit/sharding/ShardManager.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Reference in New Issue
Block a user