Skip to main content
Glama
content-hash.test.ts19.8 kB
/** * Unit Tests for Content Hash Functionality * * Tests the content hash utilities and optimistic locking system * in isolation without requiring full server setup. */ import { test, describe, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert'; import { generateContentHash, createNoteTypeHashableContent, validateContentHash, ContentHashMismatchError, MissingContentHashError } from '../../src/utils/content-hash.js'; import { NoteManager } from '../../src/core/notes.js'; import { NoteTypeManager } from '../../src/core/note-types.js'; import { createTestWorkspace, cleanupTestWorkspace, createTestNotes, createTestNoteTypes, type TestContext } from './helpers/test-utils.js'; describe('Content Hash Unit Tests', () => { let context: TestContext; let noteManager: NoteManager; let noteTypeManager: NoteTypeManager; beforeEach(async () => { context = await createTestWorkspace('content-hash-unit'); noteManager = context.noteManager; noteTypeManager = context.noteTypeManager; }); afterEach(async () => { if (context) { await cleanupTestWorkspace(context); } }); describe('Content Hash Utilities', () => { test('should generate consistent SHA-256 hashes', () => { const content = 'Test content for hashing'; const hash1 = generateContentHash(content); const hash2 = generateContentHash(content); assert.strictEqual( hash1, hash2, 'Should generate identical hashes for same content' ); assert.ok(hash1.startsWith('sha256:'), 'Should use sha256 prefix'); assert.strictEqual(hash1.length, 71, 'Should be 64 hex chars + 7 char prefix'); }); test('should generate different hashes for different content', () => { const content1 = 'First content'; const content2 = 'Second content'; const hash1 = generateContentHash(content1); const hash2 = generateContentHash(content2); assert.notStrictEqual( hash1, hash2, 'Different content should generate different hashes' ); }); test('should handle empty content', () => { const hash = generateContentHash(''); assert.ok(hash.startsWith('sha256:'), 'Should handle empty content'); assert.strictEqual(hash.length, 71, 'Should have correct length for empty content'); }); test('should handle special characters and unicode', () => { const content = 'Content with émojis 🎉 and special chars: !@#$%^&*()'; const hash = generateContentHash(content); assert.ok(hash.startsWith('sha256:'), 'Should handle special characters'); assert.strictEqual(hash.length, 71, 'Should have correct length'); }); test('should be case sensitive', () => { const content1 = 'test content'; const content2 = 'Test Content'; const hash1 = generateContentHash(content1); const hash2 = generateContentHash(content2); assert.notStrictEqual(hash1, hash2, 'Should be case sensitive'); }); }); describe('Note Type Hashable Content', () => { test('should create deterministic JSON for note type', () => { const noteType = { description: 'Test description', agent_instructions: 'Test instructions', metadata_schema: { title: 'string' } }; const content1 = createNoteTypeHashableContent(noteType); const content2 = createNoteTypeHashableContent(noteType); assert.strictEqual(content1, content2, 'Should generate identical content'); assert.ok(content1.includes('Test description'), 'Should include description'); assert.ok(content1.includes('Test instructions'), 'Should include instructions'); }); test('should handle empty/null values consistently', () => { const noteType1 = { description: '', agent_instructions: undefined, metadata_schema: undefined }; const noteType2 = { description: '', agent_instructions: '', metadata_schema: {} }; const content1 = createNoteTypeHashableContent(noteType1); const content2 = createNoteTypeHashableContent(noteType2); // Should normalize null/undefined to empty values assert.strictEqual(content1, content2, 'Should handle null/undefined consistently'); }); test('should maintain deterministic order', () => { const noteType = { metadata_schema: { b: 'second', a: 'first' }, description: 'Description', agent_instructions: 'Instructions' }; const content1 = createNoteTypeHashableContent(noteType); const content2 = createNoteTypeHashableContent(noteType); assert.strictEqual(content1, content2, 'Should maintain consistent ordering'); }); }); describe('Content Hash Validation', () => { test('should pass validation with correct hash', () => { const content = 'Test content for validation'; const hash = generateContentHash(content); // Should not throw assert.doesNotThrow(() => { validateContentHash(content, hash); }, 'Should pass validation with correct hash'); }); test('should fail validation with incorrect hash', () => { const content = 'Test content'; const wrongHash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; assert.throws( () => { validateContentHash(content, wrongHash); }, ContentHashMismatchError, 'Should throw ContentHashMismatchError for wrong hash' ); }); test('should include hash details in error', () => { const content = 'Test content'; const correctHash = generateContentHash(content); const wrongHash = 'sha256:1111111111111111111111111111111111111111111111111111111111111111'; try { validateContentHash(content, wrongHash); assert.fail('Should have thrown error'); } catch (error) { assert.ok( error instanceof ContentHashMismatchError, 'Should be ContentHashMismatchError' ); assert.strictEqual( error.current_hash, correctHash, 'Should include current hash' ); assert.strictEqual( error.provided_hash, wrongHash, 'Should include provided hash' ); assert.ok( error.message.includes('Note content has been modified'), 'Should have descriptive message' ); } }); }); describe('Error Classes', () => { test('ContentHashMismatchError should serialize correctly', () => { const currentHash = 'sha256:aaaa'; const providedHash = 'sha256:bbbb'; const error = new ContentHashMismatchError(currentHash, providedHash); const serialized = error.toJSON(); assert.strictEqual(serialized.error, 'content_hash_mismatch'); assert.strictEqual(serialized.current_hash, currentHash); assert.strictEqual(serialized.provided_hash, providedHash); assert.ok(serialized.message, 'Should include message'); }); test('MissingContentHashError should have correct message', () => { const error = new MissingContentHashError('test operation'); assert.ok( error.message.includes('content_hash is required'), 'Should indicate content_hash is required' ); assert.ok( error.message.includes('test operation'), 'Should include operation name' ); assert.strictEqual(error.name, 'MissingContentHashError'); }); }); describe('Note Manager Content Hash Integration', () => { beforeEach(async () => { await createTestNotes(context); await createTestNoteTypes(context); }); test('should include content_hash in getNote response', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); assert.ok(note.content_hash, 'Should include content_hash'); assert.ok(note.content_hash.startsWith('sha256:'), 'Should use sha256 prefix'); }); test('should generate consistent hashes for same content', async () => { // Create two notes with identical content await noteManager.createNote('general', 'Hash Test 1', 'Identical content'); await noteManager.createNote('general', 'Hash Test 2', 'Identical content'); const note1 = await noteManager.getNote('general/hash-test-1'); const note2 = await noteManager.getNote('general/hash-test-2'); assert.ok(note1 && note2, 'Both notes should exist'); assert.strictEqual( note1.content_hash, note2.content_hash, 'Identical content should have identical hashes' ); }); test('should require content_hash for updateNote', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); // Test without content_hash await assert.rejects( async () => { await noteManager.updateNote('general/test-note-1', 'New content', ''); }, (error: Error) => error.message.includes('content_hash is required'), 'Should require content_hash' ); }); test('should validate content_hash in updateNote', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); const wrongHash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; await assert.rejects( async () => { await noteManager.updateNote('general/test-note-1', 'New content', wrongHash); }, (error: Error) => error.message.includes('Note content has been modified'), 'Should validate content_hash' ); }); test('should successfully update with correct content_hash', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); const result = await noteManager.updateNote( 'general/test-note-1', 'Updated content', note.content_hash ); assert.ok(result.updated, 'Should confirm update'); // Verify content was updated and hash changed const updatedNote = await noteManager.getNote('general/test-note-1'); assert.ok(updatedNote, 'Updated note should exist'); assert.ok( updatedNote.content.includes('Updated content'), 'Content should be updated' ); assert.notStrictEqual( updatedNote.content_hash, note.content_hash, 'Hash should change after update' ); }); test('should require content_hash for updateNoteWithMetadata', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); await assert.rejects( async () => { await noteManager.updateNoteWithMetadata( 'general/test-note-1', 'New content', { priority: 'high' }, '' ); }, (error: Error) => error.message.includes('content_hash is required'), 'Should require content_hash for metadata updates' ); }); test('should validate content_hash for metadata-only updates', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); const result = await noteManager.updateNoteWithMetadata( 'general/test-note-1', note.content, { priority: 'high' }, note.content_hash ); assert.ok(result.updated, 'Metadata-only update should succeed'); }); }); describe('Note Type Manager Content Hash Integration', () => { beforeEach(async () => { await createTestNoteTypes(context); }); test('should include content_hash in getNoteTypeDescription', async () => { // Create a test note type first await noteTypeManager.createNoteType('hash_test_type', 'Test description'); const noteType = await noteTypeManager.getNoteTypeDescription('hash_test_type'); assert.ok(noteType.content_hash, 'Should include content_hash'); assert.ok(noteType.content_hash.startsWith('sha256:'), 'Should use sha256 prefix'); }); test('should generate consistent hashes for same note type definition', async () => { // Create note types with identical content const description = 'Identical test description for hashing'; await noteTypeManager.createNoteType('hash_test_1', description); await noteTypeManager.createNoteType('hash_test_2', description); const type1 = await noteTypeManager.getNoteTypeDescription('hash_test_1'); const type2 = await noteTypeManager.getNoteTypeDescription('hash_test_2'); // Note: Even with identical descriptions, the note type creation process // includes the note type name in the description title (e.g., "# Hash_test_1"), // making each note type unique. This is correct behavior. // Verify that both have valid but different hashes assert.ok(type1.content_hash, 'Type 1 should have content hash'); assert.ok(type2.content_hash, 'Type 2 should have content hash'); assert.ok( type1.content_hash.startsWith('sha256:'), 'Type 1 should have valid hash format' ); assert.ok( type2.content_hash.startsWith('sha256:'), 'Type 2 should have valid hash format' ); assert.notStrictEqual( type1.content_hash, type2.content_hash, 'Note types with different names should have different hashes' ); }); test('should generate different hashes for different note type definitions', async () => { await noteTypeManager.createNoteType('type1', 'First description'); await noteTypeManager.createNoteType('type2', 'Second description'); const type1 = await noteTypeManager.getNoteTypeDescription('type1'); const type2 = await noteTypeManager.getNoteTypeDescription('type2'); assert.notStrictEqual( type1.content_hash, type2.content_hash, 'Different note type definitions should have different hashes' ); }); }); describe('Batch Operations Content Hash', () => { beforeEach(async () => { await createTestNotes(context); }); test('should require content_hash for all batch updates', async () => { const note = await noteManager.getNote('general/test-note-1'); assert.ok(note, 'Note should exist'); // Batch update without content_hash should fail await assert.rejects( async () => { await noteManager.batchUpdateNotes([ { identifier: 'general/test-note-1', content: 'New content', content_hash: '' // Missing hash } ]); }, (error: Error) => error.message.includes('content_hash is required'), 'Should require content_hash for batch operations' ); }); test('should validate all content_hashes in batch', async () => { const note1 = await noteManager.getNote('general/test-note-1'); const note2 = await noteManager.getNote('general/test-note-2'); assert.ok(note1 && note2, 'Notes should exist'); const wrongHash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; const result = await noteManager.batchUpdateNotes([ { identifier: 'general/test-note-1', content: 'Updated content 1', content_hash: note1.content_hash // Correct hash }, { identifier: 'general/test-note-2', content: 'Updated content 2', content_hash: wrongHash // Wrong hash } ]); assert.strictEqual(result.successful, 1, 'Should have one successful update'); assert.strictEqual(result.failed, 1, 'Should have one failed update'); const failedResult = result.results.find(r => !r.success); assert.ok(failedResult, 'Should have failed result'); if (failedResult && failedResult.hash_mismatch) { assert.ok(failedResult.hash_mismatch, 'Should include hash mismatch details'); assert.strictEqual(failedResult.hash_mismatch.provided_hash, wrongHash); } else { // If hash_mismatch is not present, check that the error message indicates the problem assert.ok( failedResult.error?.includes('Note content has been modified'), 'Should indicate content was modified' ); } }); test('should process successful updates even with some failures', async () => { const note1 = await noteManager.getNote('general/test-note-1'); const note2 = await noteManager.getNote('general/test-note-2'); assert.ok(note1 && note2, 'Notes should exist'); const result = await noteManager.batchUpdateNotes([ { identifier: 'general/test-note-1', content: 'Successfully updated content', content_hash: note1.content_hash }, { identifier: 'general/test-note-2', content: 'This should fail', content_hash: 'sha256:1111111111111111111111111111111111111111111111111111111111111111' } ]); assert.strictEqual(result.successful, 1, 'Should have one successful update'); assert.strictEqual(result.failed, 1, 'Should have one failed update'); // Verify the successful update actually happened const updatedNote = await noteManager.getNote('general/test-note-1'); assert.ok(updatedNote, 'Updated note should exist'); assert.ok( updatedNote.content.includes('Successfully updated content'), 'Content should be updated' ); }); }); describe('Performance and Edge Cases', () => { test('should handle very large content efficiently', async () => { // Create large content (1MB) const largeContent = 'x'.repeat(1024 * 1024); const startTime = Date.now(); const hash = generateContentHash(largeContent); const endTime = Date.now(); assert.ok(hash.startsWith('sha256:'), 'Should handle large content'); assert.ok(endTime - startTime < 1000, 'Should process large content quickly'); }); test('should handle content with various newline types', () => { const content1 = 'Line 1\nLine 2\nLine 3'; const content2 = 'Line 1\r\nLine 2\r\nLine 3'; const content3 = 'Line 1\rLine 2\rLine 3'; const hash1 = generateContentHash(content1); const hash2 = generateContentHash(content2); const hash3 = generateContentHash(content3); // All should be different because they have different byte representations assert.notStrictEqual( hash1, hash2, 'Different newlines should produce different hashes' ); assert.notStrictEqual( hash1, hash3, 'Different newlines should produce different hashes' ); assert.notStrictEqual( hash2, hash3, 'Different newlines should produce different hashes' ); }); test('should handle concurrent hash generation', async () => { const content = 'Test content for concurrent hashing'; // Generate multiple hashes concurrently const promises = Array.from({ length: 10 }, () => Promise.resolve(generateContentHash(content)) ); const hashes = await Promise.all(promises); // All hashes should be identical const firstHash = hashes[0]; assert.ok( hashes.every(hash => hash === firstHash), 'All concurrent hashes should be identical' ); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/disnet/flint-note'

If you have feedback or need assistance with the MCP directory API, please join our Discord server