/**
* Version History Workflow Integration Test
*
* Tests the complete versioning workflow through resource managers:
* - Version history tracking through update cycles
* - History retrieval, rollback, and comparison operations
* - Config hot-reload behavior
*
* Uses real:
* - VersionHistoryService (real version management)
* - Real temp filesystem for sidecar files
*
* Mocks:
* - Logger (capture output)
* - ConfigManager (controlled config)
*
* Classification: Integration (real modules, real temp filesystem)
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import os from 'node:os';
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { MockLogger } from '../../helpers/test-helpers.js';
import { VersionHistoryService } from '../../../src/modules/versioning/version-history-service.js';
import type { VersioningConfig, HistoryFile } from '../../../src/modules/versioning/types.js';
import type { VersioningConfigProvider } from '../../../src/modules/versioning/version-history-service.js';
import type { Logger } from '../../../src/infra/logging/index.js';
/**
* Mock ConfigManager implementing VersioningConfigProvider
*/
class MockVersioningConfigProvider implements VersioningConfigProvider {
private config: VersioningConfig;
constructor(config: VersioningConfig) {
this.config = config;
}
getVersioningConfig(): VersioningConfig {
return this.config;
}
setConfig(config: Partial<VersioningConfig>): void {
this.config = { ...this.config, ...config };
}
}
/**
* Simulates a resource manager that uses VersionHistoryService
* to track version history during CRUD operations.
*/
class SimulatedResourceManager {
private versionHistoryService: VersionHistoryService;
private resourceDir: string;
private resourceType: 'prompt' | 'gate' | 'methodology';
private resourceId: string;
private currentState: Record<string, unknown>;
constructor(deps: {
versionHistoryService: VersionHistoryService;
resourceDir: string;
resourceType: 'prompt' | 'gate' | 'methodology';
resourceId: string;
}) {
this.versionHistoryService = deps.versionHistoryService;
this.resourceDir = deps.resourceDir;
this.resourceType = deps.resourceType;
this.resourceId = deps.resourceId;
this.currentState = {};
}
async create(data: Record<string, unknown>): Promise<{ success: boolean }> {
this.currentState = { ...data, id: this.resourceId };
// Save initial version
await this.versionHistoryService.saveVersion(
this.resourceDir,
this.resourceType,
this.resourceId,
this.currentState,
{ description: 'Initial creation' }
);
return { success: true };
}
async update(
data: Record<string, unknown>,
options?: { skipVersion?: boolean }
): Promise<{ success: boolean }> {
// Save current state as version before update (unless skipped)
if (
!options?.skipVersion &&
this.versionHistoryService.isAutoVersionEnabled() &&
Object.keys(this.currentState).length > 0
) {
await this.versionHistoryService.saveVersion(
this.resourceDir,
this.resourceType,
this.resourceId,
this.currentState,
{ description: 'Pre-update snapshot' }
);
}
// Apply update
this.currentState = { ...this.currentState, ...data };
return { success: true };
}
async history(limit?: number): Promise<HistoryFile | null> {
const history = await this.versionHistoryService.loadHistory(this.resourceDir);
if (!history) return null;
if (limit && history.versions.length > limit) {
history.versions = history.versions.slice(0, limit);
}
return history;
}
async rollback(
version: number
): Promise<{ success: boolean; restoredState?: Record<string, unknown> }> {
const result = await this.versionHistoryService.rollback(
this.resourceDir,
this.resourceType,
this.resourceId,
version,
this.currentState
);
if (result.success && result.snapshot) {
this.currentState = result.snapshot as Record<string, unknown>;
return { success: true, restoredState: this.currentState };
}
return { success: false };
}
async compare(fromVersion: number, toVersion: number) {
const result = await this.versionHistoryService.compareVersions(
this.resourceDir,
fromVersion,
toVersion
);
if (!result.success) {
return { success: false, error: result.error };
}
// Compute changes from the two snapshots
const from = result.from!.snapshot;
const to = result.to!.snapshot;
const added = Object.keys(to).filter((k) => !(k in from));
const removed = Object.keys(from).filter((k) => !(k in to));
const modified = Object.keys(from).filter(
(k) => k in to && JSON.stringify(from[k]) !== JSON.stringify(to[k])
);
return {
success: true,
from_version: fromVersion,
to_version: toVersion,
changes: { added, removed, modified },
};
}
getCurrentState(): Record<string, unknown> {
return this.currentState;
}
}
describe('Version History Workflow Integration', () => {
let versionHistoryService: VersionHistoryService;
let mockLogger: MockLogger;
let mockConfigProvider: MockVersioningConfigProvider;
let tempDir: string;
beforeEach(async () => {
mockLogger = new MockLogger();
mockConfigProvider = new MockVersioningConfigProvider({
enabled: true,
max_versions: 10,
auto_version: true,
});
versionHistoryService = new VersionHistoryService({
logger: mockLogger as unknown as Logger,
configManager: mockConfigProvider,
});
// Create temp directory for test resources
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'version-workflow-test-'));
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('Complete CRUD → Version Lifecycle', () => {
it('should track versions through create → update → update workflow', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'my-prompt');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'my-prompt',
});
// Create
await manager.create({ name: 'My Prompt', template: 'Hello {{name}}' });
// Update 1
await manager.update({ template: 'Hello {{name}}! Welcome.' });
// Update 2
await manager.update({ template: 'Greetings {{name}}! How are you?' });
// Check history
const history = await manager.history();
expect(history).not.toBeNull();
expect(history!.versions).toHaveLength(3);
expect(history!.current_version).toBe(3);
// Verify version ordering (newest first)
expect(history!.versions[0].version).toBe(3);
expect(history!.versions[2].version).toBe(1);
});
it('should allow skipping version on minor updates', async () => {
const resourceDir = path.join(tempDir, 'gates', 'quality');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'gate',
resourceId: 'quality',
});
await manager.create({ name: 'Quality Gate', type: 'validation' });
await manager.update({ description: 'Added description' }); // Creates version
await manager.update({ typo: 'fixed' }, { skipVersion: true }); // Skipped
await manager.update({ enhanced: true }); // Creates version
const history = await manager.history();
// Should have 3 versions: initial + 2 updates (one skipped)
expect(history!.versions).toHaveLength(3);
});
});
describe('History Retrieval with Limits', () => {
it('should limit history results when requested', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'versioned');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'versioned',
});
// Create many versions
await manager.create({ name: 'v1' });
for (let i = 2; i <= 6; i++) {
await manager.update({ name: `v${i}` });
}
// Get full history
const fullHistory = await manager.history();
expect(fullHistory!.versions).toHaveLength(6);
// Get limited history
const limitedHistory = await manager.history(3);
expect(limitedHistory!.versions).toHaveLength(3);
expect(limitedHistory!.versions[0].version).toBe(6); // Still newest first
});
});
describe('Rollback Workflow', () => {
it('should rollback to previous version and restore state', async () => {
const resourceDir = path.join(tempDir, 'methodologies', 'custom');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'methodology',
resourceId: 'custom',
});
// Version history flow:
// create: saves v1 { phases: ['analyze'] }
// update 1: saves pre-update state as v2 { phases: ['analyze'] }, then updates to ['analyze', 'plan']
// update 2: saves pre-update state as v3 { phases: ['analyze', 'plan'] }, then updates to full
await manager.create({ name: 'Custom Framework', phases: ['analyze'] });
await manager.update({ phases: ['analyze', 'plan'] });
await manager.update({ phases: ['analyze', 'plan', 'execute'] });
// Current state should have 3 phases
expect(manager.getCurrentState()['phases']).toEqual(['analyze', 'plan', 'execute']);
// Rollback to v3 (which captured ['analyze', 'plan'] before last update)
const rollbackResult = await manager.rollback(3);
expect(rollbackResult.success).toBe(true);
expect(rollbackResult.restoredState!['phases']).toEqual(['analyze', 'plan']);
// Manager state should be restored
expect(manager.getCurrentState()['phases']).toEqual(['analyze', 'plan']);
// History should now have 4 versions (rollback creates pre-rollback snapshot)
const history = await manager.history();
expect(history!.versions.length).toBeGreaterThanOrEqual(4);
});
it('should fail rollback to non-existent version', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'test');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'test',
});
await manager.create({ name: 'Test' });
const result = await manager.rollback(999);
expect(result.success).toBe(false);
});
});
describe('Version Comparison', () => {
it('should compare two versions and identify changes', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'compare-test');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'compare-test',
});
await manager.create({ name: 'Original', field1: 'value1', removed: 'will be removed' });
await manager.update({ name: 'Changed', field1: 'value1' }); // removed 'removed', changed 'name'
await manager.update({ name: 'Changed', field1: 'modified', added: 'new' }); // modified field1, added 'added'
const diff = await manager.compare(1, 3);
expect(diff.success).toBe(true);
expect(diff.from_version).toBe(1);
expect(diff.to_version).toBe(3);
// Should detect changes
expect(diff.changes).toBeDefined();
});
it('should handle comparison with invalid version', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'invalid');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'invalid',
});
await manager.create({ name: 'Test' });
const diff = await manager.compare(1, 999);
expect(diff.success).toBe(false);
expect(diff.error).toBeDefined();
});
});
describe('Config Hot-Reload Behavior', () => {
it('should respect config changes during session', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'config-test');
await fs.mkdir(resourceDir, { recursive: true });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'config-test',
});
// Start enabled
await manager.create({ name: 'v1' });
await manager.update({ name: 'v2' });
let history = await manager.history();
expect(history!.versions).toHaveLength(2);
// Disable auto-versioning mid-session
mockConfigProvider.setConfig({ auto_version: false });
// This update should NOT create a version
await manager.update({ name: 'v3' });
history = await manager.history();
expect(history!.versions).toHaveLength(2); // No new version
// Re-enable
mockConfigProvider.setConfig({ auto_version: true });
await manager.update({ name: 'v4' });
history = await manager.history();
expect(history!.versions).toHaveLength(3); // New version created
});
it('should apply max_versions limit dynamically', async () => {
const resourceDir = path.join(tempDir, 'prompts', 'limit-test');
await fs.mkdir(resourceDir, { recursive: true });
// Start with high limit
mockConfigProvider.setConfig({ max_versions: 100 });
const manager = new SimulatedResourceManager({
versionHistoryService,
resourceDir,
resourceType: 'prompt',
resourceId: 'limit-test',
});
// Create 5 versions
await manager.create({ name: 'v1' });
for (let i = 2; i <= 5; i++) {
await manager.update({ name: `v${i}` });
}
let history = await manager.history();
expect(history!.versions).toHaveLength(5);
// Reduce limit to 3
mockConfigProvider.setConfig({ max_versions: 3 });
// Next update should trigger pruning
await manager.update({ name: 'v6' });
history = await manager.history();
expect(history!.versions).toHaveLength(3);
expect(history!.versions[0].version).toBe(6); // Newest
});
});
describe('Cross-Resource Type Isolation', () => {
it('should maintain separate version histories per resource', async () => {
const promptDir = path.join(tempDir, 'prompts', 'shared-name');
const gateDir = path.join(tempDir, 'gates', 'shared-name');
await fs.mkdir(promptDir, { recursive: true });
await fs.mkdir(gateDir, { recursive: true });
const promptManager = new SimulatedResourceManager({
versionHistoryService,
resourceDir: promptDir,
resourceType: 'prompt',
resourceId: 'shared-name',
});
const gateManager = new SimulatedResourceManager({
versionHistoryService,
resourceDir: gateDir,
resourceType: 'gate',
resourceId: 'shared-name',
});
// Create and update prompt
await promptManager.create({ type: 'prompt', name: 'Prompt v1' });
await promptManager.update({ name: 'Prompt v2' });
await promptManager.update({ name: 'Prompt v3' });
// Create gate (fewer updates)
await gateManager.create({ type: 'gate', name: 'Gate v1' });
// Verify isolation
const promptHistory = await promptManager.history();
const gateHistory = await gateManager.history();
expect(promptHistory!.resource_type).toBe('prompt');
expect(promptHistory!.versions).toHaveLength(3);
expect(gateHistory!.resource_type).toBe('gate');
expect(gateHistory!.versions).toHaveLength(1);
});
});
});