Files
metabuilder/pastebin/tests/unit/lib/quality-validator/utils/FileChangeDetector.test.ts
johndoe6345789 73c8e3d4dc feat: Add snippet-pastebin application
Full-featured pastebin application with:
- Next.js frontend with TypeScript
- Express backend with SQLite/PostgreSQL
- Syntax highlighting for 100+ languages
- Code quality validation system
- Comprehensive accessibility (WCAG compliance)
- Docker deployment configuration
- Playwright E2E tests
- Jest unit tests

This provides a standalone web application that can be
integrated as a capability module in the Universal Platform.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:54:54 +00:00

557 lines
19 KiB
TypeScript

/**
* Comprehensive Unit Tests for FileChangeDetector
*
* Tests file change detection using multiple strategies including git status
* and file hash comparison for incremental analysis support.
*
* Requirements Covered:
* 1. Detector Initialization - Constructor and state setup
* 2. Git Root Detection - Automatic .git directory discovery
* 3. File Hashing - SHA256 hash generation for content
* 4. File Metadata - Extraction of mtime and size
* 5. State Loading - Load from disk with error handling
* 6. State Saving - Persist state to disk
* 7. Git Change Detection - Use git status when available
* 8. Hash-based Detection - Fallback change detection
* 9. Change Detection - Identify modified/added/deleted files
* 10. Record Updates - Persist file records after analysis
* 11. Unchanged File Identification - Find files with no changes
* 12. Tracked File Listing - Get all tracked files
* 13. Record Reset - Clear tracking history
* 14. Statistics - Get detector statistics
* 15. Global Instance - Singleton pattern support
* 16. Multiple File Handling - Process multiple files
* 17. Deleted File Detection - Identify missing files
* 18. Error Handling - Graceful failure handling
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import {
FileChangeDetector,
FileRecord,
ChangeDetectionState,
FileChange,
getGlobalChangeDetector,
resetGlobalChangeDetector,
} from '../../../../../src/lib/quality-validator/utils/FileChangeDetector';
import { createTempDir, cleanupTempDir, createTestFile, wait } from '../../../../../tests/test-utils';
// ============================================================================
// TEST SETUP AND TEARDOWN
// ============================================================================
describe('FileChangeDetector - Core Functionality', () => {
let tempDir: string;
let detector: FileChangeDetector;
beforeEach(() => {
tempDir = createTempDir();
// Create state directory
const stateDir = path.join(tempDir, '.quality');
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
// Change working directory to temp directory
process.chdir(tempDir);
detector = new FileChangeDetector(false); // Disable git for tests
});
afterEach(() => {
cleanupTempDir(tempDir);
resetGlobalChangeDetector();
});
// ============================================================================
// INITIALIZATION TESTS
// ============================================================================
it('should initialize detector with default settings', () => {
expect(detector).toBeDefined();
const stats = detector.getStats();
expect(stats.trackedFiles).toBe(0);
});
it('should load empty state on first initialization', () => {
const stats = detector.getStats();
expect(stats.trackedFiles).toBe(0);
expect(stats.lastUpdate).toBeDefined();
});
// ============================================================================
// FILE HASHING TESTS
// ============================================================================
it('should detect new file', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('added');
expect(changes[0].currentHash).toBeDefined();
});
it('should detect modified file', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'original');
detector.updateRecords([testFile]);
// Modify file
fs.writeFileSync(testFile, 'modified', 'utf-8');
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('modified');
expect(changes[0].previousHash).toBeDefined();
expect(changes[0].currentHash).toBeDefined();
expect(changes[0].previousHash).not.toBe(changes[0].currentHash);
});
it('should detect deleted file', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
detector.updateRecords([testFile]);
// Delete file
fs.unlinkSync(testFile);
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('deleted');
});
it('should not detect unchanged file', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
detector.updateRecords([testFile]);
// Wait to ensure timestamp would differ
wait(10);
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(0);
});
// ============================================================================
// MULTIPLE FILE HANDLING
// ============================================================================
it('should handle multiple files', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content1');
const file2 = createTestFile(tempDir, 'file2.txt', 'content2');
const file3 = createTestFile(tempDir, 'file3.txt', 'content3');
const changes = detector.detectChanges([file1, file2, file3]);
expect(changes).toHaveLength(3);
changes.forEach((change) => {
expect(change.type).toBe('added');
});
});
it('should handle mixed changes', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content1');
const file2 = createTestFile(tempDir, 'file2.txt', 'content2');
const file3 = createTestFile(tempDir, 'file3.txt', 'content3');
detector.updateRecords([file1, file2, file3]);
// Modify file1
fs.writeFileSync(file1, 'modified', 'utf-8');
// Delete file2
fs.unlinkSync(file2);
// Keep file3 unchanged
const changes = detector.detectChanges([file1, file2, file3]);
expect(changes).toHaveLength(2);
const modified = changes.find((c) => c.path === file1);
const deleted = changes.find((c) => c.path === file2);
expect(modified?.type).toBe('modified');
expect(deleted?.type).toBe('deleted');
});
// ============================================================================
// RECORD UPDATE TESTS
// ============================================================================
it('should update records after analysis', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
detector.updateRecords([file1, file2]);
const tracked = detector.getTrackedFiles();
expect(tracked).toContain(file1);
expect(tracked).toContain(file2);
});
it('should remove deleted files from records', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
detector.updateRecords([file1]);
let tracked = detector.getTrackedFiles();
expect(tracked).toContain(file1);
// Delete file and update records
fs.unlinkSync(file1);
detector.updateRecords([file1]);
tracked = detector.getTrackedFiles();
expect(tracked).not.toContain(file1);
});
// ============================================================================
// UNCHANGED FILE DETECTION
// ============================================================================
it('should identify unchanged files', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
detector.updateRecords([file1, file2]);
const unchanged = detector.getUnchangedFiles([file1, file2]);
expect(unchanged).toHaveLength(2);
expect(unchanged).toContain(file1);
expect(unchanged).toContain(file2);
});
it('should exclude modified files from unchanged', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
detector.updateRecords([file1, file2]);
// Modify file1
fs.writeFileSync(file1, 'modified', 'utf-8');
const unchanged = detector.getUnchangedFiles([file1, file2]);
expect(unchanged).toHaveLength(1);
expect(unchanged).toContain(file2);
expect(unchanged).not.toContain(file1);
});
// ============================================================================
// TRACKED FILES LISTING
// ============================================================================
it('should list all tracked files', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
const file3 = createTestFile(tempDir, 'file3.txt', 'content');
detector.updateRecords([file1, file2, file3]);
const tracked = detector.getTrackedFiles();
expect(tracked).toHaveLength(3);
expect(tracked).toContain(file1);
expect(tracked).toContain(file2);
expect(tracked).toContain(file3);
});
// ============================================================================
// RECORD RESET
// ============================================================================
it('should reset all records', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
detector.updateRecords([file1, file2]);
let tracked = detector.getTrackedFiles();
expect(tracked).toHaveLength(2);
detector.resetRecords();
tracked = detector.getTrackedFiles();
expect(tracked).toHaveLength(0);
});
// ============================================================================
// STATISTICS TESTS
// ============================================================================
it('should return statistics', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
const file2 = createTestFile(tempDir, 'file2.txt', 'content');
detector.updateRecords([file1, file2]);
const stats = detector.getStats();
expect(stats.trackedFiles).toBe(2);
expect(stats.lastUpdate).toBeDefined();
});
// ============================================================================
// ERROR HANDLING
// ============================================================================
it('should handle non-existent files gracefully', () => {
const nonExistent = path.join(tempDir, 'nonexistent.txt');
const changes = detector.detectChanges([nonExistent]);
expect(changes).toBeDefined();
expect(Array.isArray(changes)).toBe(true);
});
it('should handle empty file list', () => {
const changes = detector.detectChanges([]);
expect(changes).toHaveLength(0);
});
// ============================================================================
// PERFORMANCE TESTS
// ============================================================================
it('should quickly detect unchanged files using metadata', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
detector.updateRecords([testFile]);
const start = Date.now();
const unchanged = detector.getUnchangedFiles([testFile]);
const duration = Date.now() - start;
expect(unchanged).toHaveLength(1);
expect(duration).toBeLessThan(100); // Should be fast
});
it('should handle large file lists', () => {
const files: string[] = [];
for (let i = 0; i < 50; i++) {
files.push(createTestFile(tempDir, `file${i}.txt`, `content${i}`));
}
const start = Date.now();
detector.updateRecords(files);
const duration = Date.now() - start;
const tracked = detector.getTrackedFiles();
expect(tracked).toHaveLength(50);
expect(duration).toBeLessThan(1000); // Should complete reasonably
});
// ============================================================================
// STATE PERSISTENCE
// ============================================================================
it('should persist state across instances', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'content');
detector.updateRecords([file1]);
// Create new detector instance
const detector2 = new FileChangeDetector(false);
const tracked = detector2.getTrackedFiles();
expect(tracked).toContain(file1);
});
it('should recover from corrupted state', () => {
const stateDir = path.join(tempDir, '.quality');
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
const stateFile = path.join(stateDir, '.state.json');
fs.writeFileSync(stateFile, 'invalid json', 'utf-8');
// Should not throw and create new detector
const detector2 = new FileChangeDetector(false);
expect(detector2).toBeDefined();
});
// ============================================================================
// CONTENT CHANGE DETECTION
// ============================================================================
it('should detect content changes even with same size', () => {
const testFile = createTestFile(tempDir, 'test.txt', '123456789');
detector.updateRecords([testFile]);
// Write different content with same length
fs.writeFileSync(testFile, '987654321', 'utf-8');
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('modified');
});
it('should use size as quick check before hashing', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'original content');
detector.updateRecords([testFile]);
// Write different content with different size
fs.writeFileSync(testFile, 'x', 'utf-8');
const changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('modified');
});
// ============================================================================
// FILE INTERFACE VALIDATION
// ============================================================================
it('should return correct FileChange interface', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
const changes = detector.detectChanges([testFile]);
expect(changes[0]).toHaveProperty('path');
expect(changes[0]).toHaveProperty('type');
expect(changes[0]).toHaveProperty('previousHash');
expect(changes[0]).toHaveProperty('currentHash');
expect(['modified', 'added', 'deleted']).toContain(changes[0].type);
});
// ============================================================================
// HASH CONSISTENCY
// ============================================================================
it('should generate consistent hashes for same content', () => {
const testFile = createTestFile(tempDir, 'test.txt', 'content');
const changes1 = detector.detectChanges([testFile]);
detector.updateRecords([testFile]);
const changes2 = detector.detectChanges([testFile]);
// No changes should be detected
expect(changes2).toHaveLength(0);
});
it('should distinguish between similar files', () => {
const file1 = createTestFile(tempDir, 'file1.txt', 'similar content');
const file2 = createTestFile(tempDir, 'file2.txt', 'similar conten');
const changes = detector.detectChanges([file1, file2]);
expect(changes).toHaveLength(2);
detector.updateRecords([file1, file2]);
// Change both files
fs.writeFileSync(file1, 'changed content', 'utf-8');
fs.writeFileSync(file2, 'changed conten', 'utf-8');
const newChanges = detector.detectChanges([file1, file2]);
expect(newChanges).toHaveLength(2);
});
});
// ============================================================================
// GLOBAL DETECTOR TESTS
// ============================================================================
describe('FileChangeDetector - Global Instance', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
const stateDir = path.join(tempDir, '.quality');
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
process.chdir(tempDir);
resetGlobalChangeDetector();
});
afterEach(() => {
cleanupTempDir(tempDir);
resetGlobalChangeDetector();
});
it('should return same instance for global detector', () => {
const detector1 = getGlobalChangeDetector(false);
const detector2 = getGlobalChangeDetector(false);
expect(detector1).toBe(detector2);
});
it('should reset global detector', () => {
const detector1 = getGlobalChangeDetector(false);
resetGlobalChangeDetector();
const detector2 = getGlobalChangeDetector(false);
expect(detector1).not.toBe(detector2);
});
it('should support options on first creation', () => {
resetGlobalChangeDetector();
const detector = getGlobalChangeDetector(false);
expect(detector).toBeDefined();
});
});
// ============================================================================
// EDGE CASES AND BOUNDARIES
// ============================================================================
describe('FileChangeDetector - Edge Cases', () => {
let tempDir: string;
let detector: FileChangeDetector;
beforeEach(() => {
tempDir = createTempDir();
const stateDir = path.join(tempDir, '.quality');
if (!fs.existsSync(stateDir)) {
fs.mkdirSync(stateDir, { recursive: true });
}
process.chdir(tempDir);
detector = new FileChangeDetector(false);
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should handle empty file content', () => {
const emptyFile = createTestFile(tempDir, 'empty.txt', '');
const changes = detector.detectChanges([emptyFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('added');
});
it('should handle binary file changes', () => {
const binaryFile = path.join(tempDir, 'binary.bin');
fs.writeFileSync(binaryFile, Buffer.from([1, 2, 3, 4, 5]));
detector.updateRecords([binaryFile]);
fs.writeFileSync(binaryFile, Buffer.from([5, 4, 3, 2, 1]));
const changes = detector.detectChanges([binaryFile]);
expect(changes).toHaveLength(1);
expect(changes[0].type).toBe('modified');
});
it('should handle files with special characters in name', () => {
const specialFile = createTestFile(tempDir, 'file-with-special_chars.txt', 'content');
const changes = detector.detectChanges([specialFile]);
expect(changes).toHaveLength(1);
});
it('should handle nested directory files', () => {
const nested = path.join(tempDir, 'dir1', 'dir2', 'dir3');
fs.mkdirSync(nested, { recursive: true });
const nestedFile = createTestFile(nested, 'file.txt', 'content');
const changes = detector.detectChanges([nestedFile]);
expect(changes).toHaveLength(1);
});
it('should handle rapid successive changes', () => {
const testFile = createTestFile(tempDir, 'rapid.txt', 'v1');
detector.updateRecords([testFile]);
fs.writeFileSync(testFile, 'v2', 'utf-8');
let changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
fs.writeFileSync(testFile, 'v3', 'utf-8');
changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
fs.writeFileSync(testFile, 'v4', 'utf-8');
changes = detector.detectChanges([testFile]);
expect(changes).toHaveLength(1);
});
});