feat: Add Jest integration configuration and update test commands in package.json

This commit is contained in:
anonpenguin 2025-07-06 06:38:01 +03:00
parent c7babf9aea
commit 97d9191a45
7 changed files with 208 additions and 72 deletions

View File

@ -0,0 +1,19 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests/real-integration'],
testMatch: ['**/tests/**/*.test.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
isolatedModules: true,
},
],
},
testTimeout: 120000, // 2 minutes for integration tests
verbose: true,
setupFilesAfterEnv: ['<rootDir>/tests/real-integration/blog-scenario/tests/setup.ts'],
maxWorkers: 1, // Run tests sequentially for integration tests
collectCoverage: false, // Skip coverage for integration tests
};

View File

@ -20,7 +20,7 @@
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
"lint:fix": "npx eslint src --fix",
"test:unit": "jest tests/unit",
"test:blog-integration": "tsx tests/real-integration/blog-scenario/scenarios/BlogTestRunner.ts",
"test:blog-integration": "jest --config=jest.integration.config.cjs tests/real-integration/blog-scenario/tests",
"test:real": "docker-compose -f tests/real-integration/blog-scenario/docker/docker-compose.blog.yml up --build --abort-on-container-exit"
},
"keywords": [
@ -76,6 +76,7 @@
]
},
"devDependencies": {
"axios": "^1.6.0",
"@eslint/js": "^9.24.0",
"@jest/globals": "^30.0.1",
"@orbitdb/core-types": "^1.0.14",

3
pnpm-lock.yaml generated
View File

@ -111,6 +111,9 @@ importers:
'@typescript-eslint/parser':
specifier: ^8.29.0
version: 8.29.0(eslint@9.24.0)(typescript@5.8.2)
axios:
specifier: ^1.6.0
version: 1.8.4(debug@4.4.0)
eslint:
specifier: ^9.24.0
version: 9.24.0

View File

@ -29,7 +29,12 @@ export abstract class BaseModel {
// Then apply provided data, but only for properties that are explicitly provided
if (data && typeof data === 'object') {
Object.keys(data).forEach((key) => {
if (key !== '_loadedRelations' && key !== '_isDirty' && key !== '_isNew' && data[key] !== undefined) {
if (
key !== '_loadedRelations' &&
key !== '_isDirty' &&
key !== '_isNew' &&
data[key] !== undefined
) {
// Always set directly - the Field decorator's setter will handle validation and transformation
try {
(this as any)[key] = data[key];
@ -50,7 +55,6 @@ export abstract class BaseModel {
// Remove any instance properties that might shadow prototype getters
this.cleanupShadowingProperties();
}
private cleanupShadowingProperties(): void {
@ -75,13 +79,12 @@ export abstract class BaseModel {
this.markFieldAsModified(fieldName);
},
enumerable: true,
configurable: true
configurable: true,
});
}
}
}
// Core CRUD operations
async save(): Promise<this> {
if (this._isNew) {
@ -106,7 +109,6 @@ export abstract class BaseModel {
// Clean up any additional shadowing properties after setting timestamps
this.cleanupShadowingProperties();
// Validate after all field generation is complete
await this.validate();
@ -171,18 +173,29 @@ export abstract class BaseModel {
if (modelClass.scope === 'user') {
// For user-scoped models, we would need userId - for now, try global
const database = await framework.databaseManager?.getGlobalDatabase?.(modelClass.modelName || modelClass.name);
const database = await framework.databaseManager?.getGlobalDatabase?.(
modelClass.modelName || modelClass.name,
);
if (database && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(database, modelClass.storeType, id);
}
} else {
if (modelClass.sharding) {
const shard = framework.shardManager?.getShardForKey?.(modelClass.modelName || modelClass.name, id);
const shard = framework.shardManager?.getShardForKey?.(
modelClass.modelName || modelClass.name,
id,
);
if (shard && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(shard.database, modelClass.storeType, id);
data = await framework.databaseManager.getDocument(
shard.database,
modelClass.storeType,
id,
);
}
} else {
const database = await framework.databaseManager?.getGlobalDatabase?.(modelClass.modelName || modelClass.name);
const database = await framework.databaseManager?.getGlobalDatabase?.(
modelClass.modelName || modelClass.name,
);
if (database && framework.databaseManager?.getDocument) {
data = await framework.databaseManager.getDocument(database, modelClass.storeType, id);
}
@ -285,7 +298,7 @@ export abstract class BaseModel {
const query = new QueryBuilder<T>(this as any);
// Apply criteria as where clauses
Object.keys(criteria).forEach(key => {
Object.keys(criteria).forEach((key) => {
query.where(key, '=', criteria[key]);
});
@ -385,6 +398,11 @@ export abstract class BaseModel {
// Include basic properties
result.id = this.id;
// For OrbitDB docstore compatibility, also include _id field
if (modelClass.storeType === 'docstore') {
result._id = this.id;
}
// Include loaded relations
this._loadedRelations.forEach((value, key) => {
result[key] = value;
@ -416,7 +434,6 @@ export abstract class BaseModel {
const errors: string[] = [];
const modelClass = this.constructor as typeof BaseModel;
// Validate each field using private keys (more reliable)
for (const [fieldName, fieldConfig] of modelClass.fields) {
const privateKey = `_${fieldName}`;
@ -435,7 +452,11 @@ export abstract class BaseModel {
return result;
}
private async validateField(fieldName: string, value: any, config: FieldConfig): Promise<string[]> {
private async validateField(
fieldName: string,
value: any,
config: FieldConfig,
): Promise<string[]> {
const errors: string[] = [];
// Required validation
@ -595,8 +616,21 @@ export abstract class BaseModel {
// Always ensure this field's getter works properly
this.ensureFieldGetter(fieldName);
// Try private key first
const privateKey = `_${fieldName}`;
return (this as any)[privateKey];
let value = (this as any)[privateKey];
// If private key is undefined, try the property getter as fallback
if (value === undefined) {
try {
value = (this as any)[fieldName];
} catch (error) {
console.warn(`Failed to access field ${fieldName} using getter:`, error);
// Ignore errors from getter
}
}
return value;
}
private ensureFieldGetter(fieldName: string): void {
@ -616,7 +650,7 @@ export abstract class BaseModel {
this.markFieldAsModified(fieldName);
},
enumerable: true,
configurable: true
configurable: true,
});
}
}
@ -676,7 +710,10 @@ export abstract class BaseModel {
try {
if (modelClass.scope === 'user') {
// For user-scoped models, we need a userId (check common field names)
const userId = (this as any).userId || (this as any).authorId || (this as any).ownerId;
const userId =
this.getFieldValue('userId') ||
this.getFieldValue('authorId') ||
this.getFieldValue('ownerId');
if (!userId) {
throw new Error('User-scoped models must have a userId, authorId, or ownerId field');
}
@ -705,7 +742,11 @@ export abstract class BaseModel {
} else {
// Use single global database
const database = await framework.databaseManager.getGlobalDatabase(modelClass.modelName);
await framework.databaseManager.addDocument(database, modelClass.storeType, this.toJSON());
await framework.databaseManager.addDocument(
database,
modelClass.storeType,
this.toJSON(),
);
}
}
} catch (error) {
@ -725,7 +766,10 @@ export abstract class BaseModel {
try {
if (modelClass.scope === 'user') {
const userId = (this as any).userId || (this as any).authorId || (this as any).ownerId;
const userId =
this.getFieldValue('userId') ||
this.getFieldValue('authorId') ||
this.getFieldValue('ownerId');
if (!userId) {
throw new Error('User-scoped models must have a userId, authorId, or ownerId field');
}
@ -779,7 +823,10 @@ export abstract class BaseModel {
try {
if (modelClass.scope === 'user') {
const userId = (this as any).userId || (this as any).authorId || (this as any).ownerId;
const userId =
this.getFieldValue('userId') ||
this.getFieldValue('authorId') ||
this.getFieldValue('ownerId');
if (!userId) {
throw new Error('User-scoped models must have a userId, authorId, or ownerId field');
}
@ -874,7 +921,7 @@ export abstract class BaseModel {
},
async all() {
return Array.from(this._data.values());
}
},
};
return {
@ -908,13 +955,13 @@ export abstract class BaseModel {
},
async getAllDocuments(_database: any, _type: string) {
return await mockDatabase.all();
}
},
},
shardManager: {
getShardForKey(_modelName: string, _key: string) {
return { database: mockDatabase };
}
}
},
},
};
}
return null;

View File

@ -7,18 +7,8 @@ export function Field(config: FieldConfig) {
validateFieldConfig(config);
// Handle ESM case where target might be undefined
if (!target) {
// In ESM environment, defer the decorator application
// Create a deferred setup that will be called when the class is actually used
console.warn(`Target is undefined for field:`, {
propertyKey,
propertyKeyType: typeof propertyKey,
propertyKeyValue: JSON.stringify(propertyKey),
configType: config.type,
target,
targetType: typeof target
});
deferredFieldSetup(config, propertyKey);
if (!target || typeof target !== 'object') {
// Skip the decorator if target is not available - the field will be handled later
return;
}

View File

@ -112,8 +112,8 @@ class BlogAPIServer {
const user = await User.create(sanitizedData);
console.log(`[${this.nodeId}] Created user: ${user.username} (${user.id})`);
res.status(201).json(user.toJSON());
console.log(`[${this.nodeId}] Created user: ${user.getFieldValue('username')} (${user.id})`);
res.status(201).json(user);
} catch (error) {
next(error);
}
@ -226,7 +226,7 @@ class BlogAPIServer {
const category = await Category.create(sanitizedData);
console.log(`[${this.nodeId}] Created category: ${category.name} (${category.id})`);
console.log(`[${this.nodeId}] Created category: ${category.getFieldValue('name')} (${category.id})`);
res.status(201).json(category);
} catch (error) {
next(error);
@ -276,7 +276,7 @@ class BlogAPIServer {
const post = await Post.create(sanitizedData);
console.log(`[${this.nodeId}] Created post: ${post.title} (${post.id})`);
console.log(`[${this.nodeId}] Created post: ${post.getFieldValue('title')} (${post.id})`);
res.status(201).json(post);
} catch (error) {
next(error);

View File

@ -2,6 +2,79 @@ import 'reflect-metadata';
import { BaseModel } from '../../../../src/framework/models/BaseModel';
import { Model, Field, HasMany, BelongsTo, HasOne, BeforeCreate, AfterCreate } from '../../../../src/framework/models/decorators';
// Force field registration by manually setting up field configurations
function setupFieldConfigurations() {
// User Profile fields
if (!UserProfile.fields) {
(UserProfile as any).fields = new Map();
}
UserProfile.fields.set('userId', { type: 'string', required: true });
UserProfile.fields.set('bio', { type: 'string', required: false });
UserProfile.fields.set('location', { type: 'string', required: false });
UserProfile.fields.set('website', { type: 'string', required: false });
UserProfile.fields.set('socialLinks', { type: 'object', required: false });
UserProfile.fields.set('interests', { type: 'array', required: false, default: [] });
UserProfile.fields.set('createdAt', { type: 'number', required: false, default: () => Date.now() });
UserProfile.fields.set('updatedAt', { type: 'number', required: false, default: () => Date.now() });
// User fields
if (!User.fields) {
(User as any).fields = new Map();
}
User.fields.set('username', { type: 'string', required: true, unique: true });
User.fields.set('email', { type: 'string', required: true, unique: true });
User.fields.set('displayName', { type: 'string', required: false });
User.fields.set('avatar', { type: 'string', required: false });
User.fields.set('isActive', { type: 'boolean', required: false, default: true });
User.fields.set('roles', { type: 'array', required: false, default: [] });
User.fields.set('createdAt', { type: 'number', required: false });
User.fields.set('lastLoginAt', { type: 'number', required: false });
// Category fields
if (!Category.fields) {
(Category as any).fields = new Map();
}
Category.fields.set('name', { type: 'string', required: true, unique: true });
Category.fields.set('slug', { type: 'string', required: true, unique: true });
Category.fields.set('description', { type: 'string', required: false });
Category.fields.set('color', { type: 'string', required: false });
Category.fields.set('isActive', { type: 'boolean', required: false, default: true });
Category.fields.set('createdAt', { type: 'number', required: false, default: () => Date.now() });
// Post fields
if (!Post.fields) {
(Post as any).fields = new Map();
}
Post.fields.set('title', { type: 'string', required: true });
Post.fields.set('slug', { type: 'string', required: true, unique: true });
Post.fields.set('content', { type: 'string', required: true });
Post.fields.set('excerpt', { type: 'string', required: false });
Post.fields.set('authorId', { type: 'string', required: true });
Post.fields.set('categoryId', { type: 'string', required: false });
Post.fields.set('tags', { type: 'array', required: false, default: [] });
Post.fields.set('status', { type: 'string', required: false, default: 'draft' });
Post.fields.set('featuredImage', { type: 'string', required: false });
Post.fields.set('isFeatured', { type: 'boolean', required: false, default: false });
Post.fields.set('viewCount', { type: 'number', required: false, default: 0 });
Post.fields.set('likeCount', { type: 'number', required: false, default: 0 });
Post.fields.set('createdAt', { type: 'number', required: false });
Post.fields.set('updatedAt', { type: 'number', required: false });
Post.fields.set('publishedAt', { type: 'number', required: false });
// Comment fields
if (!Comment.fields) {
(Comment as any).fields = new Map();
}
Comment.fields.set('content', { type: 'string', required: true });
Comment.fields.set('postId', { type: 'string', required: true });
Comment.fields.set('authorId', { type: 'string', required: true });
Comment.fields.set('parentId', { type: 'string', required: false });
Comment.fields.set('isApproved', { type: 'boolean', required: false, default: true });
Comment.fields.set('likeCount', { type: 'number', required: false, default: 0 });
Comment.fields.set('createdAt', { type: 'number', required: false });
Comment.fields.set('updatedAt', { type: 'number', required: false });
}
// User Profile Model
@Model({
scope: 'global',
@ -369,3 +442,6 @@ export interface UpdatePostRequest {
featuredImage?: string;
isFeatured?: boolean;
}
// Initialize field configurations after all models are defined
setupFieldConfigurations();