diff --git a/apps/server/tests/unit/lib/validation-storage.test.ts b/apps/server/tests/unit/lib/validation-storage.test.ts new file mode 100644 index 00000000..f135da76 --- /dev/null +++ b/apps/server/tests/unit/lib/validation-storage.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + writeValidation, + readValidation, + getAllValidations, + deleteValidation, + isValidationStale, + getValidationWithFreshness, + markValidationViewed, + getUnviewedValidationsCount, + type StoredValidation, +} from '@/lib/validation-storage.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('validation-storage.ts', () => { + let testProjectPath: string; + + beforeEach(async () => { + testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`); + await fs.mkdir(testProjectPath, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + const createMockValidation = (overrides: Partial = {}): StoredValidation => ({ + issueNumber: 123, + issueTitle: 'Test Issue', + validatedAt: new Date().toISOString(), + model: 'haiku', + result: { + verdict: 'valid', + confidence: 'high', + reasoning: 'Test reasoning', + }, + ...overrides, + }); + + describe('writeValidation', () => { + it('should write validation to storage', async () => { + const validation = createMockValidation(); + + await writeValidation(testProjectPath, 123, validation); + + // Verify file was created + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '123', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + + it('should create nested directories if they do not exist', async () => { + const validation = createMockValidation({ issueNumber: 456 }); + + await writeValidation(testProjectPath, 456, validation); + + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '456', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + }); + + describe('readValidation', () => { + it('should read validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await readValidation(testProjectPath, 123); + + expect(result).toEqual(validation); + }); + + it('should return null when validation does not exist', async () => { + const result = await readValidation(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('getAllValidations', () => { + it('should return all validations for a project', async () => { + const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' }); + const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' }); + const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual(validation1); + expect(result[1]).toEqual(validation2); + expect(result[2]).toEqual(validation3); + }); + + it('should return empty array when no validations exist', async () => { + const result = await getAllValidations(testProjectPath); + + expect(result).toEqual([]); + }); + + it('should skip non-numeric directories', async () => { + const validation = createMockValidation({ issueNumber: 1 }); + await writeValidation(testProjectPath, 1, validation); + + // Create a non-numeric directory + const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid'); + await fs.mkdir(invalidDir, { recursive: true }); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(validation); + }); + }); + + describe('deleteValidation', () => { + it('should delete validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await deleteValidation(testProjectPath, 123); + + expect(result).toBe(true); + + const readResult = await readValidation(testProjectPath, 123); + expect(readResult).toBeNull(); + }); + + it('should return true even when validation does not exist', async () => { + const result = await deleteValidation(testProjectPath, 999); + + expect(result).toBe(true); + }); + }); + + describe('isValidationStale', () => { + it('should return false for recent validation', () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + + it('should return true for validation older than 24 hours', () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(true); + }); + + it('should return false for validation exactly at 24 hours', () => { + const exactDate = new Date(); + exactDate.setHours(exactDate.getHours() - 24); + + const validation = createMockValidation({ + validatedAt: exactDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + }); + + describe('getValidationWithFreshness', () => { + it('should return validation with isStale false for recent validation', async () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.validation).toEqual(validation); + expect(result!.isStale).toBe(false); + }); + + it('should return validation with isStale true for old validation', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.isStale).toBe(true); + }); + + it('should return null when validation does not exist', async () => { + const result = await getValidationWithFreshness(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('markValidationViewed', () => { + it('should mark validation as viewed', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await markValidationViewed(testProjectPath, 123); + + expect(result).toBe(true); + + const updated = await readValidation(testProjectPath, 123); + expect(updated).not.toBeNull(); + expect(updated!.viewedAt).toBeDefined(); + }); + + it('should return false when validation does not exist', async () => { + const result = await markValidationViewed(testProjectPath, 999); + + expect(result).toBe(false); + }); + }); + + describe('getUnviewedValidationsCount', () => { + it('should return count of unviewed non-stale validations', async () => { + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ issueNumber: 2 }); + const validation3 = createMockValidation({ + issueNumber: 3, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(2); + }); + + it('should not count stale validations', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ + issueNumber: 2, + validatedAt: oldDate.toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(1); + }); + + it('should return 0 when no validations exist', async () => { + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + + it('should return 0 when all validations are viewed', async () => { + const validation = createMockValidation({ + issueNumber: 1, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + }); +}); diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts index 102b1fc9..da73ad15 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { getElectronAPI } from '@/lib/electron'; import type { Project, StoredValidation } from '@/lib/electron'; @@ -8,71 +8,26 @@ import type { Project, StoredValidation } from '@/lib/electron'; */ export function useUnviewedValidations(currentProject: Project | null) { const [count, setCount] = useState(0); + const projectPathRef = useRef(null); - // Load initial count + // Keep project path in ref for use in async functions useEffect(() => { - if (!currentProject?.path) { - setCount(0); - return; - } - - const loadCount = async () => { - try { - const api = getElectronAPI(); - if (api.github?.getValidations) { - const result = await api.github.getValidations(currentProject.path); - if (result.success && result.validations) { - const unviewed = result.validations.filter((v: StoredValidation) => { - if (v.viewedAt) return false; - // Check if not stale (< 24 hours) - const hoursSince = - (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60); - return hoursSince <= 24; - }); - setCount(unviewed.length); - } - } - } catch (err) { - console.error('[useUnviewedValidations] Failed to load count:', err); - } - }; - - loadCount(); - - // Subscribe to validation events to update count - const api = getElectronAPI(); - if (api.github?.onValidationEvent) { - const unsubscribe = api.github.onValidationEvent((event) => { - if (event.projectPath === currentProject.path) { - if (event.type === 'issue_validation_complete') { - // New validation completed - increment count - setCount((prev) => prev + 1); - } else if (event.type === 'issue_validation_viewed') { - // Validation was viewed - decrement count - setCount((prev) => Math.max(0, prev - 1)); - } - } - }); - return () => unsubscribe(); - } + projectPathRef.current = currentProject?.path ?? null; }, [currentProject?.path]); - // Function to decrement count when a validation is viewed - const decrementCount = useCallback(() => { - setCount((prev) => Math.max(0, prev - 1)); - }, []); - - // Function to refresh count (e.g., after marking as viewed) - const refreshCount = useCallback(async () => { - if (!currentProject?.path) return; + // Fetch and update count from server + const fetchUnviewedCount = useCallback(async () => { + const projectPath = projectPathRef.current; + if (!projectPath) return; try { const api = getElectronAPI(); if (api.github?.getValidations) { - const result = await api.github.getValidations(currentProject.path); + const result = await api.github.getValidations(projectPath); if (result.success && result.validations) { const unviewed = result.validations.filter((v: StoredValidation) => { if (v.viewedAt) return false; + // Check if not stale (< 24 hours) const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60); return hoursSince <= 24; }); @@ -80,9 +35,45 @@ export function useUnviewedValidations(currentProject: Project | null) { } } } catch (err) { - console.error('[useUnviewedValidations] Failed to refresh count:', err); + console.error('[useUnviewedValidations] Failed to load count:', err); } - }, [currentProject?.path]); + }, []); + + // Load initial count and subscribe to events + useEffect(() => { + if (!currentProject?.path) { + setCount(0); + return; + } + + // Load initial count + fetchUnviewedCount(); + + // Subscribe to validation events to update count + const api = getElectronAPI(); + if (api.github?.onValidationEvent) { + const unsubscribe = api.github.onValidationEvent((event) => { + if (event.projectPath === currentProject.path) { + if (event.type === 'issue_validation_complete') { + // New validation completed - refresh count from server for consistency + fetchUnviewedCount(); + } else if (event.type === 'issue_validation_viewed') { + // Validation was viewed - refresh count from server for consistency + fetchUnviewedCount(); + } + } + }); + return () => unsubscribe(); + } + }, [currentProject?.path, fetchUnviewedCount]); + + // Function to decrement count when a validation is viewed + const decrementCount = useCallback(() => { + setCount((prev) => Math.max(0, prev - 1)); + }, []); + + // Expose refreshCount as an alias to fetchUnviewedCount for external use + const refreshCount = fetchUnviewedCount; return { count, decrementCount, refreshCount }; } diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 4923407b..0ffbc10f 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -223,7 +223,7 @@ export function GitHubIssuesView() { const unsubscribe = api.github.onValidationEvent(handleValidationEvent); return () => unsubscribe(); - }, [currentProject?.path, selectedIssue, showValidationDialog, validationModel, muteDoneSound]); + }, [currentProject?.path, selectedIssue, showValidationDialog, muteDoneSound]); // Cleanup audio element on unmount to prevent memory leaks useEffect(() => { @@ -803,7 +803,7 @@ function IssueRow({ return (