mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: Add unit tests for validation storage functionality
- Introduced comprehensive unit tests for the validation storage module, covering functions such as writeValidation, readValidation, getAllValidations, deleteValidation, and others. - Implemented tests to ensure correct behavior for validation creation, retrieval, deletion, and freshness checks. - Enhanced test coverage for edge cases, including handling of non-existent validations and directory structure validation.
This commit is contained in:
307
apps/server/tests/unit/lib/validation-storage.test.ts
Normal file
307
apps/server/tests/unit/lib/validation-storage.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
'group flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
||||
Reference in New Issue
Block a user