Initial commit: Due Diligence Tracker project
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
313
client/src/services/__tests__/dataService.test.ts
Normal file
313
client/src/services/__tests__/dataService.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DataService } from '../dataService';
|
||||
import { Database } from '../../database/database';
|
||||
|
||||
// Mock database
|
||||
const mockDb = {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
run: vi.fn(),
|
||||
} as unknown as Database;
|
||||
|
||||
describe('DataService', () => {
|
||||
let dataService: DataService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataService = new DataService(mockDb);
|
||||
});
|
||||
|
||||
describe('getDimensions', () => {
|
||||
it('should return dimensions ordered by order_index', () => {
|
||||
const mockResult = [{
|
||||
columns: ['id', 'name', 'icon', 'color', 'order_index', 'created_at'],
|
||||
values: [
|
||||
[1, 'Strategy', 'Briefcase', '#4F46E5', 1, '2023-01-01'],
|
||||
[2, 'Engineering', 'Code', '#F59E0B', 2, '2023-01-01']
|
||||
]
|
||||
}];
|
||||
|
||||
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||
|
||||
const result = dataService.getDimensions();
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM dimensions ORDER BY order_index');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Strategy',
|
||||
icon: 'Briefcase',
|
||||
color: '#4F46E5',
|
||||
order_index: 1,
|
||||
created_at: '2023-01-01'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Engineering',
|
||||
icon: 'Code',
|
||||
color: '#F59E0B',
|
||||
order_index: 2,
|
||||
created_at: '2023-01-01'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no dimensions exist', () => {
|
||||
(mockDb.exec as any).mockReturnValue([]);
|
||||
|
||||
const result = dataService.getDimensions();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle database errors', () => {
|
||||
(mockDb.exec as any).mockImplementation(() => {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
expect(() => dataService.getDimensions()).toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDimensionsWithStats', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getDimensions
|
||||
vi.spyOn(dataService, 'getDimensions').mockReturnValue([
|
||||
{ id: 1, name: 'Strategy', icon: 'Briefcase', color: '#4F46E5', order_index: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should calculate stats correctly', () => {
|
||||
// Mock KPI count query
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce([{ values: [[3]] }]) // KPI count
|
||||
.mockReturnValueOnce([{ values: [[10, 7]] }]); // total and completed checks
|
||||
|
||||
const result = dataService.getDimensionsWithStats();
|
||||
|
||||
expect(result).toEqual([{
|
||||
dimension: { id: 1, name: 'Strategy', icon: 'Briefcase', color: '#4F46E5', order_index: 1 },
|
||||
kpi_count: 3,
|
||||
total_checks: 10,
|
||||
completed_checks: 7,
|
||||
progress: 70
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should handle zero checks correctly', () => {
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce([{ values: [[2]] }]) // KPI count
|
||||
.mockReturnValueOnce([{ values: [[0, 0]] }]); // total and completed checks
|
||||
|
||||
const result = dataService.getDimensionsWithStats();
|
||||
|
||||
expect(result[0].progress).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null completed checks', () => {
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce([{ values: [[2]] }]) // KPI count
|
||||
.mockReturnValueOnce([{ values: [[5, null]] }]); // total and completed checks
|
||||
|
||||
const result = dataService.getDimensionsWithStats();
|
||||
|
||||
expect(result[0].completed_checks).toBe(0);
|
||||
expect(result[0].progress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKPIsByDimension', () => {
|
||||
it('should return KPIs for a dimension ordered by order_index', () => {
|
||||
const mockResult = [{
|
||||
columns: ['id', 'dimension_id', 'name', 'description', 'order_index', 'created_at'],
|
||||
values: [
|
||||
[1, 1, 'Strategic Alignment', 'Vision clarity', 1, '2023-01-01'],
|
||||
[2, 1, 'Market Position', 'Competitive analysis', 2, '2023-01-01']
|
||||
]
|
||||
}];
|
||||
|
||||
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||
|
||||
const result = dataService.getKPIsByDimension(1);
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM kpis WHERE dimension_id = 1 ORDER BY order_index');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Strategic Alignment');
|
||||
});
|
||||
|
||||
it('should return empty array when no KPIs exist', () => {
|
||||
(mockDb.exec as any).mockReturnValue([]);
|
||||
|
||||
const result = dataService.getKPIsByDimension(999);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChecksByKPI', () => {
|
||||
it('should return checks for a KPI ordered by created_at', () => {
|
||||
const mockResult = [{
|
||||
columns: ['id', 'kpi_id', 'question', 'check_type', 'current_value', 'expected_value',
|
||||
'reference_url', 'reference_file_id', 'comment', 'is_completed', 'created_at', 'updated_at'],
|
||||
values: [
|
||||
[1, 1, 'Is vision clear?', 'dropdown', 'Yes', 'Yes', null, null, 'Good', 1, '2023-01-01', '2023-01-01'],
|
||||
[2, 1, 'Goals documented?', 'dropdown', 'No', 'Yes', null, null, 'Needs work', 0, '2023-01-02', '2023-01-02']
|
||||
]
|
||||
}];
|
||||
|
||||
(mockDb.exec as any).mockReturnValue(mockResult);
|
||||
|
||||
const result = dataService.getChecksByKPI(1);
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('SELECT * FROM checks WHERE kpi_id = 1 ORDER BY created_at');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].is_completed).toBe(true);
|
||||
expect(result[1].is_completed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCheck', () => {
|
||||
it('should update check with escaped values', () => {
|
||||
const updates = {
|
||||
current_value: "Yes",
|
||||
is_completed: true,
|
||||
comment: "Test's comment"
|
||||
};
|
||||
|
||||
dataService.updateCheck(1, updates);
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||
expect.stringContaining("UPDATE checks SET")
|
||||
);
|
||||
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||
expect.stringContaining("WHERE id = 1")
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle boolean values correctly', () => {
|
||||
const updates = { is_completed: true };
|
||||
|
||||
dataService.updateCheck(1, updates);
|
||||
|
||||
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||
expect(sqlCall).toContain('is_completed = 1');
|
||||
});
|
||||
|
||||
it('should escape string values with quotes', () => {
|
||||
const updates = { comment: "It's working" };
|
||||
|
||||
dataService.updateCheck(1, updates);
|
||||
|
||||
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||
expect(sqlCall).toContain("comment = 'It''s working'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCheck', () => {
|
||||
it('should create new check with proper escaping', () => {
|
||||
const checkData = {
|
||||
kpi_id: 1,
|
||||
question: 'Test question?',
|
||||
check_type: 'text',
|
||||
current_value: 'Test value',
|
||||
expected_value: 'Expected value',
|
||||
reference_url: 'https://example.com',
|
||||
reference_file_id: undefined,
|
||||
comment: 'Test comment',
|
||||
is_completed: false
|
||||
};
|
||||
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce(undefined) // INSERT
|
||||
.mockReturnValueOnce([{ values: [[123]] }]); // SELECT last_insert_rowid()
|
||||
|
||||
const result = dataService.createCheck(checkData);
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO checks')
|
||||
);
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle null values correctly', () => {
|
||||
const checkData = {
|
||||
kpi_id: 1,
|
||||
question: 'Test question?',
|
||||
check_type: 'text',
|
||||
current_value: undefined,
|
||||
expected_value: null,
|
||||
reference_url: undefined,
|
||||
reference_file_id: undefined,
|
||||
comment: undefined,
|
||||
is_completed: false
|
||||
};
|
||||
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce([{ values: [[456]] }]);
|
||||
|
||||
const result = dataService.createCheck(checkData as any);
|
||||
|
||||
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||
expect(sqlCall).toContain('NULL');
|
||||
expect(result).toBe(456);
|
||||
});
|
||||
|
||||
it('should handle boolean completion status', () => {
|
||||
const checkData = {
|
||||
kpi_id: 1,
|
||||
question: 'Test?',
|
||||
check_type: 'text',
|
||||
is_completed: true
|
||||
};
|
||||
|
||||
(mockDb.exec as any)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce([{ values: [[789]] }]);
|
||||
|
||||
dataService.createCheck(checkData as any);
|
||||
|
||||
const sqlCall = (mockDb.exec as any).mock.calls[0][0];
|
||||
expect(sqlCall).toContain(', 1)'); // boolean true as 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCheck', () => {
|
||||
it('should delete check by ID', () => {
|
||||
dataService.deleteCheck(123);
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('DELETE FROM checks WHERE id = 123');
|
||||
});
|
||||
|
||||
it('should handle database errors', () => {
|
||||
(mockDb.exec as any).mockImplementation(() => {
|
||||
throw new Error('Delete failed');
|
||||
});
|
||||
|
||||
expect(() => dataService.deleteCheck(123)).toThrow('Delete failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle empty result sets gracefully', () => {
|
||||
(mockDb.exec as any).mockReturnValue([]);
|
||||
|
||||
expect(() => dataService.getDimensions()).not.toThrow();
|
||||
expect(() => dataService.getKPIsByDimension(1)).not.toThrow();
|
||||
expect(() => dataService.getChecksByKPI(1)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle malformed result sets', () => {
|
||||
(mockDb.exec as any).mockReturnValue([{ columns: [], values: [] }]);
|
||||
|
||||
const result = dataService.getDimensions();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined database responses', () => {
|
||||
(mockDb.exec as any).mockReturnValue(undefined);
|
||||
|
||||
expect(() => dataService.getDimensions()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
175
client/src/services/dataService.ts
Normal file
175
client/src/services/dataService.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Database } from '../database/database';
|
||||
import { Dimension, KPI, Check, DimensionWithStats } from '../types';
|
||||
|
||||
export class DataService {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
getDimensions(): Dimension[] {
|
||||
const result = this.db.exec('SELECT * FROM dimensions ORDER BY order_index');
|
||||
const dimensions: Dimension[] = [];
|
||||
|
||||
if (result.length > 0) {
|
||||
const columns = result[0].columns;
|
||||
const values = result[0].values;
|
||||
|
||||
values.forEach((row: any[]) => {
|
||||
const dimension: any = {};
|
||||
columns.forEach((column: string, index: number) => {
|
||||
dimension[column] = row[index];
|
||||
});
|
||||
|
||||
dimensions.push({
|
||||
id: dimension.id,
|
||||
name: dimension.name,
|
||||
icon: dimension.icon,
|
||||
color: dimension.color,
|
||||
order_index: dimension.order_index,
|
||||
created_at: dimension.created_at
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
getDimensionsWithStats(): DimensionWithStats[] {
|
||||
const dimensions = this.getDimensions();
|
||||
|
||||
return dimensions.map(dimension => {
|
||||
// Get KPI count
|
||||
const kpiResult = this.db.exec('SELECT COUNT(*) as count FROM kpis WHERE dimension_id = ' + dimension.id);
|
||||
const kpi_count = kpiResult.length > 0 ? kpiResult[0].values[0][0] as number : 0;
|
||||
|
||||
// Get total checks and completed checks
|
||||
const checksResult = this.db.exec(`
|
||||
SELECT
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN is_completed = 1 THEN 1 ELSE 0 END) as completed_checks
|
||||
FROM checks c
|
||||
JOIN kpis k ON c.kpi_id = k.id
|
||||
WHERE k.dimension_id = ${dimension.id}
|
||||
`);
|
||||
|
||||
const total_checks = checksResult.length > 0 ? (checksResult[0].values[0][0] as number || 0) : 0;
|
||||
const completed_checks = checksResult.length > 0 ? (checksResult[0].values[0][1] as number || 0) : 0;
|
||||
|
||||
const progress = total_checks > 0 ? (completed_checks / total_checks) * 100 : 0;
|
||||
|
||||
return {
|
||||
dimension,
|
||||
kpi_count,
|
||||
total_checks,
|
||||
completed_checks,
|
||||
progress
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getKPIsByDimension(dimensionId: number): KPI[] {
|
||||
const result = this.db.exec(`SELECT * FROM kpis WHERE dimension_id = ${dimensionId} ORDER BY order_index`);
|
||||
const kpis: KPI[] = [];
|
||||
|
||||
if (result.length > 0) {
|
||||
const columns = result[0].columns;
|
||||
const values = result[0].values;
|
||||
|
||||
values.forEach((row: any[]) => {
|
||||
const kpi: any = {};
|
||||
columns.forEach((column: string, index: number) => {
|
||||
kpi[column] = row[index];
|
||||
});
|
||||
|
||||
kpis.push({
|
||||
id: kpi.id,
|
||||
dimension_id: kpi.dimension_id,
|
||||
name: kpi.name,
|
||||
description: kpi.description,
|
||||
order_index: kpi.order_index,
|
||||
created_at: kpi.created_at
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return kpis;
|
||||
}
|
||||
|
||||
getChecksByKPI(kpiId: number): Check[] {
|
||||
const result = this.db.exec(`SELECT * FROM checks WHERE kpi_id = ${kpiId} ORDER BY created_at`);
|
||||
const checks: Check[] = [];
|
||||
|
||||
if (result.length > 0) {
|
||||
const columns = result[0].columns;
|
||||
const values = result[0].values;
|
||||
|
||||
values.forEach((row: any[]) => {
|
||||
const check: any = {};
|
||||
columns.forEach((column: string, index: number) => {
|
||||
check[column] = row[index];
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: check.id,
|
||||
kpi_id: check.kpi_id,
|
||||
question: check.question,
|
||||
check_type: check.check_type,
|
||||
current_value: check.current_value,
|
||||
expected_value: check.expected_value,
|
||||
reference_url: check.reference_url,
|
||||
reference_file_id: check.reference_file_id,
|
||||
comment: check.comment,
|
||||
is_completed: Boolean(check.is_completed),
|
||||
created_at: check.created_at,
|
||||
updated_at: check.updated_at
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
updateCheck(checkId: number, updates: Partial<Check>): void {
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id');
|
||||
const values = fields.map(key => {
|
||||
const value = updates[key as keyof Check];
|
||||
if (typeof value === 'boolean') return value ? 1 : 0;
|
||||
return value;
|
||||
});
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||
const sql = `UPDATE checks SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ${checkId}`;
|
||||
|
||||
// For sql.js, we need to use exec with direct values
|
||||
const finalSql = sql.replace(/\?/g, () => {
|
||||
const value = values.shift();
|
||||
return typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : String(value);
|
||||
});
|
||||
|
||||
this.db.exec(finalSql);
|
||||
}
|
||||
|
||||
createCheck(check: Omit<Check, 'id' | 'created_at' | 'updated_at'>): number {
|
||||
const escapeString = (str: string | undefined | null) => {
|
||||
if (!str) return 'NULL';
|
||||
return `'${str.replace(/'/g, "''")}'`;
|
||||
};
|
||||
|
||||
const sql = `
|
||||
INSERT INTO checks (kpi_id, question, check_type, current_value, expected_value,
|
||||
reference_url, reference_file_id, comment, is_completed)
|
||||
VALUES (${check.kpi_id}, ${escapeString(check.question)}, ${escapeString(check.check_type)},
|
||||
${escapeString(check.current_value)}, ${escapeString(check.expected_value)},
|
||||
${escapeString(check.reference_url)}, ${check.reference_file_id || 'NULL'},
|
||||
${escapeString(check.comment)}, ${check.is_completed ? 1 : 0})
|
||||
`;
|
||||
|
||||
this.db.exec(sql);
|
||||
|
||||
// Return the last inserted row ID
|
||||
const result = this.db.exec('SELECT last_insert_rowid() as id');
|
||||
return result[0].values[0][0] as number;
|
||||
}
|
||||
|
||||
deleteCheck(checkId: number): void {
|
||||
this.db.exec(`DELETE FROM checks WHERE id = ${checkId}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user