mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +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 { getElectronAPI } from '@/lib/electron';
|
||||||
import type { Project, StoredValidation } 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) {
|
export function useUnviewedValidations(currentProject: Project | null) {
|
||||||
const [count, setCount] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!currentProject?.path) {
|
projectPathRef.current = currentProject?.path ?? null;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
// Function to decrement count when a validation is viewed
|
// Fetch and update count from server
|
||||||
const decrementCount = useCallback(() => {
|
const fetchUnviewedCount = useCallback(async () => {
|
||||||
setCount((prev) => Math.max(0, prev - 1));
|
const projectPath = projectPathRef.current;
|
||||||
}, []);
|
if (!projectPath) return;
|
||||||
|
|
||||||
// Function to refresh count (e.g., after marking as viewed)
|
|
||||||
const refreshCount = useCallback(async () => {
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api.github?.getValidations) {
|
if (api.github?.getValidations) {
|
||||||
const result = await api.github.getValidations(currentProject.path);
|
const result = await api.github.getValidations(projectPath);
|
||||||
if (result.success && result.validations) {
|
if (result.success && result.validations) {
|
||||||
const unviewed = result.validations.filter((v: StoredValidation) => {
|
const unviewed = result.validations.filter((v: StoredValidation) => {
|
||||||
if (v.viewedAt) return false;
|
if (v.viewedAt) return false;
|
||||||
|
// Check if not stale (< 24 hours)
|
||||||
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
|
const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60);
|
||||||
return hoursSince <= 24;
|
return hoursSince <= 24;
|
||||||
});
|
});
|
||||||
@@ -80,9 +35,45 @@ export function useUnviewedValidations(currentProject: Project | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 };
|
return { count, decrementCount, refreshCount };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
|
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [currentProject?.path, selectedIssue, showValidationDialog, validationModel, muteDoneSound]);
|
}, [currentProject?.path, selectedIssue, showValidationDialog, muteDoneSound]);
|
||||||
|
|
||||||
// Cleanup audio element on unmount to prevent memory leaks
|
// Cleanup audio element on unmount to prevent memory leaks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -803,7 +803,7 @@ function IssueRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
isSelected && 'bg-accent'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
Reference in New Issue
Block a user