From 97d9191a45ce9d138389673871787df432882f86 Mon Sep 17 00:00:00 2001 From: anonpenguin Date: Sun, 6 Jul 2025 06:38:01 +0300 Subject: [PATCH] feat: Add Jest integration configuration and update test commands in package.json --- jest.integration.config.cjs | 19 +++ package.json | 3 +- pnpm-lock.yaml | 3 + src/framework/models/BaseModel.ts | 155 ++++++++++++------ src/framework/models/decorators/Field.ts | 14 +- .../blog-scenario/docker/blog-api-server.ts | 8 +- .../blog-scenario/models/BlogModels.ts | 78 ++++++++- 7 files changed, 208 insertions(+), 72 deletions(-) create mode 100644 jest.integration.config.cjs diff --git a/jest.integration.config.cjs b/jest.integration.config.cjs new file mode 100644 index 0000000..0e523d3 --- /dev/null +++ b/jest.integration.config.cjs @@ -0,0 +1,19 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests/real-integration'], + testMatch: ['**/tests/**/*.test.ts'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + isolatedModules: true, + }, + ], + }, + testTimeout: 120000, // 2 minutes for integration tests + verbose: true, + setupFilesAfterEnv: ['/tests/real-integration/blog-scenario/tests/setup.ts'], + maxWorkers: 1, // Run tests sequentially for integration tests + collectCoverage: false, // Skip coverage for integration tests +}; diff --git a/package.json b/package.json index 4ef86b9..9e02209 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a004fb7..942cabe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/framework/models/BaseModel.ts b/src/framework/models/BaseModel.ts index ec1f07f..254672f 100644 --- a/src/framework/models/BaseModel.ts +++ b/src/framework/models/BaseModel.ts @@ -22,14 +22,19 @@ export abstract class BaseModel { constructor(data: any = {}) { // Generate ID first this.id = this.generateId(); - + // Apply field defaults first this.applyFieldDefaults(); - + // 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]; @@ -41,28 +46,27 @@ export abstract class BaseModel { } } }); - + // Mark as existing if it has an ID in the data if (data.id) { this._isNew = false; } } - + // Remove any instance properties that might shadow prototype getters this.cleanupShadowingProperties(); - } private cleanupShadowingProperties(): void { const modelClass = this.constructor as typeof BaseModel; - + // For each field, ensure no instance properties are shadowing prototype getters for (const [fieldName] of modelClass.fields) { // If there's an instance property, remove it and create a working getter if (this.hasOwnProperty(fieldName)) { const _oldValue = (this as any)[fieldName]; delete (this as any)[fieldName]; - + // Define a working getter directly on the instance Object.defineProperty(this, fieldName, { get: () => { @@ -75,21 +79,20 @@ export abstract class BaseModel { this.markFieldAsModified(fieldName); }, enumerable: true, - configurable: true + configurable: true, }); } } } - // Core CRUD operations async save(): Promise { if (this._isNew) { // Clean up any instance properties before hooks run this.cleanupShadowingProperties(); - + await this.beforeCreate(); - + // Clean up any instance properties created by hooks this.cleanupShadowingProperties(); @@ -102,11 +105,10 @@ export abstract class BaseModel { const now = Date.now(); this.setFieldValue('createdAt', now); this.setFieldValue('updatedAt', now); - + // Clean up any additional shadowing properties after setting timestamps this.cleanupShadowingProperties(); - - + // Validate after all field generation is complete await this.validate(); @@ -117,7 +119,7 @@ export abstract class BaseModel { this.clearModifications(); await this.afterCreate(); - + // Clean up any shadowing properties created during save this.cleanupShadowingProperties(); } else if (this._isDirty) { @@ -125,7 +127,7 @@ export abstract class BaseModel { // Set timestamp using Field setter this.setFieldValue('updatedAt', Date.now()); - + // Validate after hooks have run await this.validate(); @@ -135,7 +137,7 @@ export abstract class BaseModel { this.clearModifications(); await this.afterUpdate(); - + // Clean up any shadowing properties created during save this.cleanupShadowingProperties(); } @@ -168,21 +170,32 @@ export abstract class BaseModel { try { const modelClass = this as any; let data = null; - + 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); } @@ -195,7 +208,7 @@ export abstract class BaseModel { instance.clearModifications(); return instance; } - + return null; } catch (error) { console.error('Failed to find by ID:', error); @@ -283,12 +296,12 @@ export abstract class BaseModel { criteria: any, ): Promise { const query = new QueryBuilder(this as any); - + // Apply criteria as where clauses - Object.keys(criteria).forEach(key => { + Object.keys(criteria).forEach((key) => { query.where(key, '=', criteria[key]); }); - + const results = await query.limit(1).exec(); return results.length > 0 ? results[0] : null; } @@ -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; @@ -396,7 +414,7 @@ export abstract class BaseModel { fromJSON(data: any): this { if (!data) return this; - // Set basic properties + // Set basic properties Object.keys(data).forEach((key) => { if (key !== '_loadedRelations' && key !== '_isDirty' && key !== '_isNew') { (this as any)[key] = data[key]; @@ -416,12 +434,11 @@ 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}`; const value = (this as any)[privateKey]; - + const fieldErrors = await this.validateField(fieldName, value, fieldConfig); errors.push(...fieldErrors); } @@ -435,7 +452,11 @@ export abstract class BaseModel { return result; } - private async validateField(fieldName: string, value: any, config: FieldConfig): Promise { + private async validateField( + fieldName: string, + value: any, + config: FieldConfig, + ): Promise { const errors: string[] = []; // Required validation @@ -544,18 +565,18 @@ export abstract class BaseModel { private applyFieldDefaults(): void { const modelClass = this.constructor as typeof BaseModel; - + // Ensure we have fields map if (!modelClass.fields) { return; } - + for (const [fieldName, fieldConfig] of modelClass.fields) { if (fieldConfig.default !== undefined) { const privateKey = `_${fieldName}`; const hasProperty = (this as any).hasOwnProperty(privateKey); const currentValue = (this as any)[privateKey]; - + // Always apply default value to private field if it's not set if (!hasProperty || currentValue === undefined) { // Apply default value to private field @@ -594,16 +615,29 @@ export abstract class BaseModel { getFieldValue(fieldName: string): any { // 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 { // If there's a shadowing instance property, remove it and create a working getter if (this.hasOwnProperty(fieldName)) { delete (this as any)[fieldName]; - + // Define a working getter directly on the instance Object.defineProperty(this, fieldName, { get: () => { @@ -616,7 +650,7 @@ export abstract class BaseModel { this.markFieldAsModified(fieldName); }, enumerable: true, - configurable: true + configurable: true, }); } } @@ -636,14 +670,14 @@ export abstract class BaseModel { getAllFieldValues(): Record { const modelClass = this.constructor as typeof BaseModel; const values: Record = {}; - + for (const [fieldName] of modelClass.fields) { const value = this.getFieldValue(fieldName); if (value !== undefined) { values[fieldName] = value; } } - + return values; } @@ -676,17 +710,20 @@ 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'); } // Ensure user databases exist before accessing them await this.ensureUserDatabasesExist(framework, userId); - + // Ensure user databases exist before accessing them await this.ensureUserDatabasesExist(framework, userId); - + const database = await framework.databaseManager.getUserDatabase( userId, modelClass.modelName, @@ -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,14 +766,17 @@ 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'); } // Ensure user databases exist before accessing them await this.ensureUserDatabasesExist(framework, userId); - + const database = await framework.databaseManager.getUserDatabase( userId, modelClass.modelName, @@ -779,14 +823,17 @@ 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'); } // Ensure user databases exist before accessing them await this.ensureUserDatabasesExist(framework, userId); - + const database = await framework.databaseManager.getUserDatabase( userId, modelClass.modelName, @@ -858,7 +905,7 @@ export abstract class BaseModel { if (!(globalThis as any).__mockDatabase) { (globalThis as any).__mockDatabase = new Map(); } - + const mockDatabase = { _data: (globalThis as any).__mockDatabase, async get(id: string) { @@ -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; diff --git a/src/framework/models/decorators/Field.ts b/src/framework/models/decorators/Field.ts index 53962d6..0c307c2 100644 --- a/src/framework/models/decorators/Field.ts +++ b/src/framework/models/decorators/Field.ts @@ -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; } diff --git a/tests/real-integration/blog-scenario/docker/blog-api-server.ts b/tests/real-integration/blog-scenario/docker/blog-api-server.ts index fab5e90..b71985f 100644 --- a/tests/real-integration/blog-scenario/docker/blog-api-server.ts +++ b/tests/real-integration/blog-scenario/docker/blog-api-server.ts @@ -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); diff --git a/tests/real-integration/blog-scenario/models/BlogModels.ts b/tests/real-integration/blog-scenario/models/BlogModels.ts index 4ef1e07..93ee741 100644 --- a/tests/real-integration/blog-scenario/models/BlogModels.ts +++ b/tests/real-integration/blog-scenario/models/BlogModels.ts @@ -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', @@ -368,4 +441,7 @@ export interface UpdatePostRequest { tags?: string[]; featuredImage?: string; isFeatured?: boolean; -} \ No newline at end of file +} + +// Initialize field configurations after all models are defined +setupFieldConfigurations();