|
| 1 | +/** |
| 2 | + * Tests for MetricsStore |
| 3 | + */ |
| 4 | + |
| 5 | +import * as fs from 'node:fs'; |
| 6 | +import * as os from 'node:os'; |
| 7 | +import * as path from 'node:path'; |
| 8 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 9 | +import { createDetailedIndexStats } from '../../indexer/__tests__/test-factories.js'; |
| 10 | +import { MetricsStore } from '../store.js'; |
| 11 | + |
| 12 | +describe('MetricsStore', () => { |
| 13 | + let tempDbPath: string; |
| 14 | + let store: MetricsStore; |
| 15 | + |
| 16 | + beforeEach(() => { |
| 17 | + // Create temp database path |
| 18 | + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'metrics-test-')); |
| 19 | + tempDbPath = path.join(tempDir, 'test-metrics.db'); |
| 20 | + store = new MetricsStore(tempDbPath); |
| 21 | + }); |
| 22 | + |
| 23 | + afterEach(() => { |
| 24 | + // Clean up |
| 25 | + store.close(); |
| 26 | + if (fs.existsSync(tempDbPath)) { |
| 27 | + fs.unlinkSync(tempDbPath); |
| 28 | + const tempDir = path.dirname(tempDbPath); |
| 29 | + fs.rmSync(tempDir, { recursive: true }); |
| 30 | + } |
| 31 | + }); |
| 32 | + |
| 33 | + describe('recordSnapshot', () => { |
| 34 | + it('should record a snapshot successfully', () => { |
| 35 | + const stats = createDetailedIndexStats({ |
| 36 | + repositoryPath: '/test/repo', |
| 37 | + filesScanned: 10, |
| 38 | + documentsIndexed: 20, |
| 39 | + vectorsStored: 20, |
| 40 | + duration: 1000, |
| 41 | + }); |
| 42 | + |
| 43 | + const id = store.recordSnapshot(stats, 'index'); |
| 44 | + |
| 45 | + expect(id).toBeTruthy(); |
| 46 | + expect(typeof id).toBe('string'); |
| 47 | + }); |
| 48 | + |
| 49 | + it('should generate unique IDs for each snapshot', () => { |
| 50 | + const stats = createDetailedIndexStats(); |
| 51 | + |
| 52 | + const id1 = store.recordSnapshot(stats, 'index'); |
| 53 | + const id2 = store.recordSnapshot(stats, 'update'); |
| 54 | + |
| 55 | + expect(id1).not.toBe(id2); |
| 56 | + }); |
| 57 | + |
| 58 | + it('should store both index and update triggers', () => { |
| 59 | + const stats = createDetailedIndexStats(); |
| 60 | + |
| 61 | + const indexId = store.recordSnapshot(stats, 'index'); |
| 62 | + const updateId = store.recordSnapshot(stats, 'update'); |
| 63 | + |
| 64 | + const indexSnapshot = store.getSnapshot(indexId); |
| 65 | + const updateSnapshot = store.getSnapshot(updateId); |
| 66 | + |
| 67 | + expect(indexSnapshot?.trigger).toBe('index'); |
| 68 | + expect(updateSnapshot?.trigger).toBe('update'); |
| 69 | + }); |
| 70 | + }); |
| 71 | + |
| 72 | + describe('getSnapshot', () => { |
| 73 | + it('should retrieve a snapshot by ID', () => { |
| 74 | + const stats = createDetailedIndexStats({ |
| 75 | + repositoryPath: '/test/repo', |
| 76 | + filesScanned: 10, |
| 77 | + documentsIndexed: 20, |
| 78 | + }); |
| 79 | + |
| 80 | + const id = store.recordSnapshot(stats, 'index'); |
| 81 | + const snapshot = store.getSnapshot(id); |
| 82 | + |
| 83 | + expect(snapshot).toBeTruthy(); |
| 84 | + expect(snapshot?.id).toBe(id); |
| 85 | + expect(snapshot?.repositoryPath).toBe('/test/repo'); |
| 86 | + expect(snapshot?.stats.filesScanned).toBe(10); |
| 87 | + expect(snapshot?.stats.documentsIndexed).toBe(20); |
| 88 | + expect(snapshot?.trigger).toBe('index'); |
| 89 | + }); |
| 90 | + |
| 91 | + it('should return null for non-existent ID', () => { |
| 92 | + const snapshot = store.getSnapshot('non-existent-id'); |
| 93 | + expect(snapshot).toBeNull(); |
| 94 | + }); |
| 95 | + }); |
| 96 | + |
| 97 | + describe('getSnapshots', () => { |
| 98 | + beforeEach(() => { |
| 99 | + // Seed with multiple snapshots |
| 100 | + const repo1 = '/test/repo1'; |
| 101 | + const repo2 = '/test/repo2'; |
| 102 | + |
| 103 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo1 }), 'index'); |
| 104 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo1 }), 'update'); |
| 105 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: repo2 }), 'index'); |
| 106 | + }); |
| 107 | + |
| 108 | + it('should retrieve all snapshots with default limit', () => { |
| 109 | + const snapshots = store.getSnapshots({}); |
| 110 | + expect(snapshots.length).toBe(3); |
| 111 | + }); |
| 112 | + |
| 113 | + it('should filter by repository path', () => { |
| 114 | + const snapshots = store.getSnapshots({ repositoryPath: '/test/repo1' }); |
| 115 | + expect(snapshots.length).toBe(2); |
| 116 | + expect(snapshots.every((s) => s.repositoryPath === '/test/repo1')).toBe(true); |
| 117 | + }); |
| 118 | + |
| 119 | + it('should filter by trigger type', () => { |
| 120 | + const snapshots = store.getSnapshots({ trigger: 'index' }); |
| 121 | + expect(snapshots.length).toBe(2); |
| 122 | + expect(snapshots.every((s) => s.trigger === 'index')).toBe(true); |
| 123 | + }); |
| 124 | + |
| 125 | + it('should respect limit parameter', () => { |
| 126 | + const snapshots = store.getSnapshots({ limit: 2 }); |
| 127 | + expect(snapshots.length).toBe(2); |
| 128 | + }); |
| 129 | + |
| 130 | + it('should return snapshots in descending timestamp order', () => { |
| 131 | + const snapshots = store.getSnapshots({}); |
| 132 | + expect(snapshots.length).toBeGreaterThan(1); |
| 133 | + |
| 134 | + for (let i = 1; i < snapshots.length; i++) { |
| 135 | + expect(snapshots[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual( |
| 136 | + snapshots[i].timestamp.getTime() |
| 137 | + ); |
| 138 | + } |
| 139 | + }); |
| 140 | + |
| 141 | + it('should filter by since date', () => { |
| 142 | + const now = new Date(); |
| 143 | + const oneHourAgo = new Date(now.getTime() - 3600000); |
| 144 | + |
| 145 | + const snapshots = store.getSnapshots({ since: oneHourAgo }); |
| 146 | + expect(snapshots.length).toBeGreaterThan(0); |
| 147 | + }); |
| 148 | + |
| 149 | + it('should filter by until date', () => { |
| 150 | + const futureDate = new Date(Date.now() + 3600000); |
| 151 | + const snapshots = store.getSnapshots({ until: futureDate }); |
| 152 | + expect(snapshots.length).toBe(3); |
| 153 | + }); |
| 154 | + }); |
| 155 | + |
| 156 | + describe('getLatestSnapshot', () => { |
| 157 | + it('should return the most recent snapshot', async () => { |
| 158 | + const stats1 = createDetailedIndexStats({ filesScanned: 10 }); |
| 159 | + const stats2 = createDetailedIndexStats({ filesScanned: 20 }); |
| 160 | + |
| 161 | + store.recordSnapshot(stats1, 'index'); |
| 162 | + // Wait 1ms to ensure different timestamps |
| 163 | + await new Promise((resolve) => setTimeout(resolve, 1)); |
| 164 | + const latestId = store.recordSnapshot(stats2, 'update'); |
| 165 | + |
| 166 | + const latest = store.getLatestSnapshot(); |
| 167 | + expect(latest?.id).toBe(latestId); |
| 168 | + expect(latest?.stats.filesScanned).toBe(20); |
| 169 | + }); |
| 170 | + |
| 171 | + it('should filter by repository path', async () => { |
| 172 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'index'); |
| 173 | + // Wait 1ms to ensure different timestamps |
| 174 | + await new Promise((resolve) => setTimeout(resolve, 1)); |
| 175 | + const repo2Id = store.recordSnapshot( |
| 176 | + createDetailedIndexStats({ repositoryPath: '/repo2' }), |
| 177 | + 'index' |
| 178 | + ); |
| 179 | + |
| 180 | + const latest = store.getLatestSnapshot('/repo2'); |
| 181 | + expect(latest?.id).toBe(repo2Id); |
| 182 | + }); |
| 183 | + |
| 184 | + it('should return null when no snapshots exist', () => { |
| 185 | + const latest = store.getLatestSnapshot(); |
| 186 | + expect(latest).toBeNull(); |
| 187 | + }); |
| 188 | + }); |
| 189 | + |
| 190 | + describe('getCount', () => { |
| 191 | + it('should return correct count of all snapshots', () => { |
| 192 | + store.recordSnapshot(createDetailedIndexStats(), 'index'); |
| 193 | + store.recordSnapshot(createDetailedIndexStats(), 'update'); |
| 194 | + store.recordSnapshot(createDetailedIndexStats(), 'index'); |
| 195 | + |
| 196 | + expect(store.getCount()).toBe(3); |
| 197 | + }); |
| 198 | + |
| 199 | + it('should return correct count filtered by repository path', () => { |
| 200 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'index'); |
| 201 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo1' }), 'update'); |
| 202 | + store.recordSnapshot(createDetailedIndexStats({ repositoryPath: '/repo2' }), 'index'); |
| 203 | + |
| 204 | + expect(store.getCount('/repo1')).toBe(2); |
| 205 | + expect(store.getCount('/repo2')).toBe(1); |
| 206 | + }); |
| 207 | + |
| 208 | + it('should return 0 for empty database', () => { |
| 209 | + expect(store.getCount()).toBe(0); |
| 210 | + }); |
| 211 | + }); |
| 212 | + |
| 213 | + describe('pruneOldSnapshots', () => { |
| 214 | + it('should delete snapshots older than retention period', async () => { |
| 215 | + // Record a snapshot |
| 216 | + store.recordSnapshot(createDetailedIndexStats(), 'index'); |
| 217 | + |
| 218 | + // Wait 2ms to ensure the snapshot is in the past |
| 219 | + await new Promise((resolve) => setTimeout(resolve, 2)); |
| 220 | + |
| 221 | + // Prune snapshots older than 0 days (should delete all) |
| 222 | + const deleted = store.pruneOldSnapshots(0); |
| 223 | + expect(deleted).toBeGreaterThan(0); |
| 224 | + expect(store.getCount()).toBe(0); |
| 225 | + }); |
| 226 | + |
| 227 | + it('should not delete recent snapshots', () => { |
| 228 | + store.recordSnapshot(createDetailedIndexStats(), 'index'); |
| 229 | + store.recordSnapshot(createDetailedIndexStats(), 'update'); |
| 230 | + |
| 231 | + // Prune snapshots older than 90 days (should delete none) |
| 232 | + const deleted = store.pruneOldSnapshots(90); |
| 233 | + expect(deleted).toBe(0); |
| 234 | + expect(store.getCount()).toBe(2); |
| 235 | + }); |
| 236 | + |
| 237 | + it('should return 0 when no snapshots to prune', () => { |
| 238 | + const deleted = store.pruneOldSnapshots(30); |
| 239 | + expect(deleted).toBe(0); |
| 240 | + }); |
| 241 | + }); |
| 242 | + |
| 243 | + describe('close', () => { |
| 244 | + it('should close database without error', () => { |
| 245 | + expect(() => store.close()).not.toThrow(); |
| 246 | + }); |
| 247 | + |
| 248 | + it('should not throw when closed multiple times', () => { |
| 249 | + store.close(); |
| 250 | + expect(() => store.close()).not.toThrow(); |
| 251 | + }); |
| 252 | + }); |
| 253 | + |
| 254 | + describe('logger integration', () => { |
| 255 | + it('should work without a logger', () => { |
| 256 | + const storeWithoutLogger = new MetricsStore(tempDbPath); |
| 257 | + const stats = createDetailedIndexStats(); |
| 258 | + |
| 259 | + expect(() => { |
| 260 | + storeWithoutLogger.recordSnapshot(stats, 'index'); |
| 261 | + }).not.toThrow(); |
| 262 | + |
| 263 | + storeWithoutLogger.close(); |
| 264 | + }); |
| 265 | + |
| 266 | + it('should call logger methods when provided', () => { |
| 267 | + const mockLogger = { |
| 268 | + trace: vi.fn(), |
| 269 | + debug: vi.fn(), |
| 270 | + info: vi.fn(), |
| 271 | + warn: vi.fn(), |
| 272 | + error: vi.fn(), |
| 273 | + success: vi.fn(), |
| 274 | + fatal: vi.fn(), |
| 275 | + child: vi.fn(), |
| 276 | + startTimer: vi.fn(), |
| 277 | + isLevelEnabled: vi.fn(), |
| 278 | + level: 'info' as const, |
| 279 | + }; |
| 280 | + |
| 281 | + const tempDbPath2 = path.join(path.dirname(tempDbPath), 'test-metrics-2.db'); |
| 282 | + const storeWithLogger = new MetricsStore(tempDbPath2, mockLogger); |
| 283 | + const stats = createDetailedIndexStats(); |
| 284 | + |
| 285 | + storeWithLogger.recordSnapshot(stats, 'index'); |
| 286 | + |
| 287 | + expect(mockLogger.info).toHaveBeenCalled(); |
| 288 | + expect(mockLogger.debug).toHaveBeenCalled(); |
| 289 | + |
| 290 | + storeWithLogger.close(); |
| 291 | + fs.unlinkSync(tempDbPath2); |
| 292 | + }); |
| 293 | + }); |
| 294 | +}); |
0 commit comments