- Fixed sync script disconnecting Mongoose (prevents production errors)
- Created text search index (fixes search in rule-manager)
- Enhanced inst_024 with closedown protocol, added inst_061
- Added sync infrastructure: API routes, dashboard widget, auto-sync
- Fixed MemoryProxy tests MongoDB connection
- Created ADR-001 and integration tests
Result: Production stable, 52 rules synced, search working
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
290 lines
9.6 KiB
JavaScript
290 lines
9.6 KiB
JavaScript
/**
|
|
* Integration Test: File-to-Database Sync
|
|
* Tests the dual governance architecture synchronization
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const mongoose = require('mongoose');
|
|
const { syncInstructions } = require('../../scripts/sync-instructions-to-db.js');
|
|
const GovernanceRule = require('../../src/models/GovernanceRule.model');
|
|
|
|
require('dotenv').config();
|
|
|
|
const INSTRUCTION_FILE = path.join(__dirname, '../../.claude/instruction-history.json');
|
|
const TEST_DB = 'tractatus_test_sync';
|
|
|
|
describe('Instruction Sync Integration Tests', () => {
|
|
let originalDb;
|
|
|
|
beforeAll(async () => {
|
|
// Connect to test database
|
|
const mongoUri = process.env.MONGODB_URI?.replace(/\/[^/]+$/, `/${TEST_DB}`) ||
|
|
`mongodb://localhost:27017/${TEST_DB}`;
|
|
await mongoose.connect(mongoUri);
|
|
originalDb = mongoose.connection.db.databaseName;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Clean up test database
|
|
await mongoose.connection.db.dropDatabase();
|
|
await mongoose.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clear database before each test
|
|
await GovernanceRule.deleteMany({});
|
|
});
|
|
|
|
describe('File Reading', () => {
|
|
test('instruction file exists', () => {
|
|
expect(fs.existsSync(INSTRUCTION_FILE)).toBe(true);
|
|
});
|
|
|
|
test('instruction file is valid JSON', () => {
|
|
const fileData = fs.readFileSync(INSTRUCTION_FILE, 'utf8');
|
|
expect(() => JSON.parse(fileData)).not.toThrow();
|
|
});
|
|
|
|
test('instruction file has expected structure', () => {
|
|
const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8'));
|
|
expect(fileData).toHaveProperty('version');
|
|
expect(fileData).toHaveProperty('instructions');
|
|
expect(Array.isArray(fileData.instructions)).toBe(true);
|
|
});
|
|
|
|
test('all instructions have required fields', () => {
|
|
const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8'));
|
|
fileData.instructions.forEach(inst => {
|
|
expect(inst).toHaveProperty('id');
|
|
expect(inst).toHaveProperty('text');
|
|
expect(inst).toHaveProperty('quadrant');
|
|
expect(inst).toHaveProperty('persistence');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Initial Sync', () => {
|
|
test('syncs all instructions from file to empty database', async () => {
|
|
const result = await syncInstructions({ silent: true });
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.added).toBeGreaterThan(0);
|
|
expect(result.updated).toBe(0); // First sync, nothing to update
|
|
expect(result.finalCount).toBeGreaterThan(0);
|
|
|
|
// Verify database has same count as file
|
|
const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8'));
|
|
const activeFileCount = fileData.instructions.filter(i => i.active !== false).length;
|
|
expect(result.finalCount).toBe(activeFileCount);
|
|
});
|
|
|
|
test('creates rules with correct schema', async () => {
|
|
await syncInstructions({ silent: true });
|
|
|
|
const rules = await GovernanceRule.find({}).lean();
|
|
expect(rules.length).toBeGreaterThan(0);
|
|
|
|
rules.forEach(rule => {
|
|
// Required fields
|
|
expect(rule).toHaveProperty('id');
|
|
expect(rule).toHaveProperty('text');
|
|
expect(rule).toHaveProperty('quadrant');
|
|
expect(rule).toHaveProperty('persistence');
|
|
expect(rule).toHaveProperty('source');
|
|
expect(rule).toHaveProperty('active');
|
|
|
|
// Source enum validation
|
|
expect(['user_instruction', 'framework_default', 'automated', 'migration', 'claude_md_migration', 'test'])
|
|
.toContain(rule.source);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Update Sync', () => {
|
|
test('updates existing rules without duplicates', async () => {
|
|
// First sync
|
|
const result1 = await syncInstructions({ silent: true });
|
|
const count1 = result1.finalCount;
|
|
|
|
// Second sync (should update, not add)
|
|
const result2 = await syncInstructions({ silent: true });
|
|
|
|
expect(result2.success).toBe(true);
|
|
expect(result2.added).toBe(0); // Nothing new to add
|
|
expect(result2.updated).toBe(count1); // All rules updated
|
|
expect(result2.finalCount).toBe(count1); // Same count
|
|
});
|
|
|
|
test('preserves validation scores on update', async () => {
|
|
// First sync
|
|
await syncInstructions({ silent: true });
|
|
|
|
// Update a rule with validation scores
|
|
const rule = await GovernanceRule.findOne({});
|
|
await GovernanceRule.findByIdAndUpdate(rule._id, {
|
|
clarityScore: 85,
|
|
specificityScore: 90,
|
|
actionabilityScore: 80,
|
|
validationStatus: 'VALIDATED',
|
|
lastValidated: new Date()
|
|
});
|
|
|
|
// Second sync
|
|
await syncInstructions({ silent: true });
|
|
|
|
// Verify scores preserved
|
|
const updatedRule = await GovernanceRule.findById(rule._id);
|
|
expect(updatedRule.clarityScore).toBe(85);
|
|
expect(updatedRule.specificityScore).toBe(90);
|
|
expect(updatedRule.actionabilityScore).toBe(80);
|
|
expect(updatedRule.validationStatus).toBe('VALIDATED');
|
|
});
|
|
});
|
|
|
|
describe('Orphan Handling', () => {
|
|
test('deactivates rules not in file', async () => {
|
|
// Create an orphan rule directly in DB
|
|
await GovernanceRule.create({
|
|
id: 'test_orphan_001',
|
|
text: 'This rule does not exist in the file',
|
|
scope: 'PROJECT_SPECIFIC',
|
|
applicableProjects: ['*'],
|
|
quadrant: 'TACTICAL',
|
|
persistence: 'MEDIUM',
|
|
category: 'test',
|
|
priority: 50,
|
|
active: true,
|
|
source: 'test',
|
|
createdBy: 'test'
|
|
});
|
|
|
|
// Sync from file
|
|
const result = await syncInstructions({ silent: true });
|
|
|
|
expect(result.deactivated).toBe(1);
|
|
|
|
// Verify orphan is inactive
|
|
const orphan = await GovernanceRule.findOne({ id: 'test_orphan_001' });
|
|
expect(orphan.active).toBe(false);
|
|
expect(orphan.notes).toContain('AUTO-DEACTIVATED');
|
|
});
|
|
|
|
test('exports orphans to backup file', async () => {
|
|
// Create orphan
|
|
await GovernanceRule.create({
|
|
id: 'test_orphan_002',
|
|
text: 'Another orphan rule',
|
|
scope: 'PROJECT_SPECIFIC',
|
|
applicableProjects: ['*'],
|
|
quadrant: 'TACTICAL',
|
|
persistence: 'MEDIUM',
|
|
category: 'test',
|
|
priority: 50,
|
|
active: true,
|
|
source: 'test',
|
|
createdBy: 'test'
|
|
});
|
|
|
|
// Sync
|
|
await syncInstructions({ silent: true });
|
|
|
|
// Check backup directory exists
|
|
const backupDir = path.join(__dirname, '../../.claude/backups');
|
|
expect(fs.existsSync(backupDir)).toBe(true);
|
|
|
|
// Check latest backup file contains orphan
|
|
const backupFiles = fs.readdirSync(backupDir)
|
|
.filter(f => f.startsWith('orphaned-rules-'))
|
|
.sort()
|
|
.reverse();
|
|
|
|
if (backupFiles.length > 0) {
|
|
const latestBackup = JSON.parse(
|
|
fs.readFileSync(path.join(backupDir, backupFiles[0]), 'utf8')
|
|
);
|
|
expect(latestBackup.rules.some(r => r.id === 'test_orphan_002')).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Source Mapping', () => {
|
|
test('maps file source values to MongoDB enum values', async () => {
|
|
// This test assumes there are instructions with different source values in the file
|
|
await syncInstructions({ silent: true });
|
|
|
|
const rules = await GovernanceRule.find({}).lean();
|
|
|
|
// All sources should be valid enum values
|
|
const validSources = ['user_instruction', 'framework_default', 'automated', 'migration', 'claude_md_migration', 'test'];
|
|
rules.forEach(rule => {
|
|
expect(validSources).toContain(rule.source);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('handles missing instruction file gracefully', async () => {
|
|
// Temporarily rename file
|
|
const tempFile = INSTRUCTION_FILE + '.tmp';
|
|
fs.renameSync(INSTRUCTION_FILE, tempFile);
|
|
|
|
try {
|
|
const result = await syncInstructions({ silent: true });
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('not found');
|
|
} finally {
|
|
// Restore file
|
|
fs.renameSync(tempFile, INSTRUCTION_FILE);
|
|
}
|
|
});
|
|
|
|
test('handles invalid JSON gracefully', async () => {
|
|
// Temporarily replace file with invalid JSON
|
|
const originalContent = fs.readFileSync(INSTRUCTION_FILE, 'utf8');
|
|
fs.writeFileSync(INSTRUCTION_FILE, 'INVALID JSON{{{');
|
|
|
|
try {
|
|
const result = await syncInstructions({ silent: true });
|
|
expect(result.success).toBe(false);
|
|
} finally {
|
|
// Restore file
|
|
fs.writeFileSync(INSTRUCTION_FILE, originalContent);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Programmatic Options', () => {
|
|
test('respects silent mode', async () => {
|
|
const consoleSpy = jest.spyOn(console, 'log');
|
|
|
|
await syncInstructions({ silent: true });
|
|
|
|
// Silent mode should not log
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
test('dry run does not modify database', async () => {
|
|
const result = await syncInstructions({ silent: true, dryRun: true });
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
// Database should still be empty
|
|
const count = await GovernanceRule.countDocuments({});
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Idempotency', () => {
|
|
test('multiple syncs produce same result', async () => {
|
|
const result1 = await syncInstructions({ silent: true });
|
|
const result2 = await syncInstructions({ silent: true });
|
|
const result3 = await syncInstructions({ silent: true });
|
|
|
|
expect(result1.finalCount).toBe(result2.finalCount);
|
|
expect(result2.finalCount).toBe(result3.finalCount);
|
|
});
|
|
});
|
|
});
|