mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat(core,cli): surface rich AI implementation metadata for remote tasks (#1521)
This commit is contained in:
423
packages/tm-core/src/common/mappers/TaskMapper.spec.ts
Normal file
423
packages/tm-core/src/common/mappers/TaskMapper.spec.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for TaskMapper
|
||||
*
|
||||
* Tests the mapping of database task rows to internal Task format,
|
||||
* with focus on metadata extraction including AI implementation guidance fields.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TaskMapper } from './TaskMapper.js';
|
||||
import { MetadataFixtures } from '../../testing/task-fixtures.js';
|
||||
import type { Tables } from '../types/database.types.js';
|
||||
|
||||
type TaskRow = Tables<'tasks'>;
|
||||
|
||||
/**
|
||||
* Creates a mock database task row for testing
|
||||
*/
|
||||
function createMockTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||
return {
|
||||
id: 'uuid-123',
|
||||
display_id: 'HAM-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
brief_id: 'brief-123',
|
||||
parent_task_id: null,
|
||||
position: 1,
|
||||
subtask_position: 0,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
metadata: {},
|
||||
complexity: null,
|
||||
estimated_hours: null,
|
||||
actual_hours: 0,
|
||||
assignee_id: null,
|
||||
document_id: null,
|
||||
account_id: 'account-123',
|
||||
created_by: 'user-123',
|
||||
updated_by: 'user-123',
|
||||
completed_subtasks: 0,
|
||||
total_subtasks: 0,
|
||||
due_date: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskMapper', () => {
|
||||
describe('extractImplementationMetadata', () => {
|
||||
it('should extract all fields from complete metadata', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(
|
||||
MetadataFixtures.completeMetadata
|
||||
);
|
||||
|
||||
expect(result.relevantFiles).toEqual([
|
||||
{
|
||||
path: 'src/service.ts',
|
||||
description: 'Main service file',
|
||||
action: 'modify'
|
||||
}
|
||||
]);
|
||||
expect(result.codebasePatterns).toEqual([
|
||||
'Use dependency injection',
|
||||
'Follow SOLID principles'
|
||||
]);
|
||||
expect(result.existingInfrastructure).toEqual([
|
||||
{
|
||||
name: 'Logger',
|
||||
location: 'src/common/logger.ts',
|
||||
usage: 'Use for structured logging'
|
||||
}
|
||||
]);
|
||||
expect(result.scopeBoundaries).toEqual({
|
||||
included: 'Core functionality',
|
||||
excluded: 'UI changes'
|
||||
});
|
||||
expect(result.implementationApproach).toBe(
|
||||
'Step-by-step implementation guide'
|
||||
);
|
||||
expect(result.technicalConstraints).toEqual([
|
||||
'Must be backwards compatible'
|
||||
]);
|
||||
expect(result.acceptanceCriteria).toEqual([
|
||||
'Feature works as expected',
|
||||
'Tests pass'
|
||||
]);
|
||||
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
|
||||
expect(result.category).toBe('development');
|
||||
});
|
||||
|
||||
it('should return undefined for missing fields', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(
|
||||
MetadataFixtures.minimalMetadata
|
||||
);
|
||||
|
||||
expect(result.relevantFiles).toBeUndefined();
|
||||
expect(result.codebasePatterns).toBeUndefined();
|
||||
expect(result.existingInfrastructure).toBeUndefined();
|
||||
expect(result.scopeBoundaries).toBeUndefined();
|
||||
expect(result.implementationApproach).toBeUndefined();
|
||||
expect(result.technicalConstraints).toBeUndefined();
|
||||
expect(result.acceptanceCriteria).toBeUndefined();
|
||||
expect(result.skills).toBeUndefined();
|
||||
expect(result.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle null metadata gracefully', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(null);
|
||||
|
||||
expect(result.relevantFiles).toBeUndefined();
|
||||
expect(result.codebasePatterns).toBeUndefined();
|
||||
expect(result.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined metadata gracefully', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(undefined);
|
||||
|
||||
expect(result.relevantFiles).toBeUndefined();
|
||||
expect(result.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty metadata object', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(
|
||||
MetadataFixtures.emptyMetadata
|
||||
);
|
||||
|
||||
expect(result.relevantFiles).toBeUndefined();
|
||||
expect(result.codebasePatterns).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should filter invalid items from arrays', () => {
|
||||
const result = TaskMapper.extractImplementationMetadata(
|
||||
MetadataFixtures.malformedMetadata
|
||||
);
|
||||
|
||||
// codebasePatterns has [123, null, 'valid'] - should only keep 'valid'
|
||||
expect(result.codebasePatterns).toEqual(['valid']);
|
||||
|
||||
// relevantFiles is 'not-an-array' - should be undefined
|
||||
expect(result.relevantFiles).toBeUndefined();
|
||||
|
||||
// existingInfrastructure has invalid structure - should be undefined
|
||||
expect(result.existingInfrastructure).toBeUndefined();
|
||||
|
||||
// scopeBoundaries is 'not-an-object' - should be undefined
|
||||
expect(result.scopeBoundaries).toBeUndefined();
|
||||
|
||||
// category is 'invalid-category' - should be undefined
|
||||
expect(result.category).toBeUndefined();
|
||||
|
||||
// skills is an object - should be undefined
|
||||
expect(result.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate relevantFiles structure', () => {
|
||||
const metadata = {
|
||||
relevantFiles: [
|
||||
{ path: 'valid.ts', description: 'Valid file', action: 'modify' },
|
||||
{ path: 'missing-action.ts', description: 'Missing action' }, // Invalid
|
||||
{
|
||||
path: 'invalid-action.ts',
|
||||
description: 'Bad action',
|
||||
action: 'delete'
|
||||
}, // Invalid action
|
||||
{ description: 'No path', action: 'create' }, // Missing path
|
||||
'not-an-object' // Invalid type
|
||||
]
|
||||
};
|
||||
|
||||
const result = TaskMapper.extractImplementationMetadata(metadata);
|
||||
|
||||
expect(result.relevantFiles).toEqual([
|
||||
{ path: 'valid.ts', description: 'Valid file', action: 'modify' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should validate existingInfrastructure structure', () => {
|
||||
const metadata = {
|
||||
existingInfrastructure: [
|
||||
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' },
|
||||
{ name: 'Missing location', usage: 'Use it' }, // Invalid
|
||||
{ location: 'src/no-name.ts', usage: 'Use it' }, // Invalid
|
||||
{ name: 'No usage', location: 'src/test.ts' }, // Invalid
|
||||
'not-an-object' // Invalid type
|
||||
]
|
||||
};
|
||||
|
||||
const result = TaskMapper.extractImplementationMetadata(metadata);
|
||||
|
||||
expect(result.existingInfrastructure).toEqual([
|
||||
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle scopeBoundaries with partial data', () => {
|
||||
const metadataIncludedOnly = {
|
||||
scopeBoundaries: { included: 'Just included' }
|
||||
};
|
||||
const resultIncluded =
|
||||
TaskMapper.extractImplementationMetadata(metadataIncludedOnly);
|
||||
expect(resultIncluded.scopeBoundaries).toEqual({
|
||||
included: 'Just included'
|
||||
});
|
||||
|
||||
const metadataExcludedOnly = {
|
||||
scopeBoundaries: { excluded: 'Just excluded' }
|
||||
};
|
||||
const resultExcluded =
|
||||
TaskMapper.extractImplementationMetadata(metadataExcludedOnly);
|
||||
expect(resultExcluded.scopeBoundaries).toEqual({
|
||||
excluded: 'Just excluded'
|
||||
});
|
||||
|
||||
// Empty scopeBoundaries object should return undefined
|
||||
const metadataEmpty = {
|
||||
scopeBoundaries: {}
|
||||
};
|
||||
const resultEmpty =
|
||||
TaskMapper.extractImplementationMetadata(metadataEmpty);
|
||||
expect(resultEmpty.scopeBoundaries).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate category enum values', () => {
|
||||
const validCategories = [
|
||||
'research',
|
||||
'design',
|
||||
'development',
|
||||
'testing',
|
||||
'documentation',
|
||||
'review'
|
||||
] as const;
|
||||
|
||||
for (const category of validCategories) {
|
||||
const result = TaskMapper.extractImplementationMetadata({ category });
|
||||
expect(result.category).toBe(category);
|
||||
}
|
||||
|
||||
// Invalid category
|
||||
const invalidResult = TaskMapper.extractImplementationMetadata({
|
||||
category: 'invalid'
|
||||
});
|
||||
expect(invalidResult.category).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDatabaseTaskToTask', () => {
|
||||
it('should map basic task fields correctly', () => {
|
||||
const dbTask = createMockTaskRow();
|
||||
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
expect(result.id).toBe('HAM-1');
|
||||
expect(result.databaseId).toBe('uuid-123');
|
||||
expect(result.title).toBe('Test Task');
|
||||
expect(result.description).toBe('Test description');
|
||||
expect(result.status).toBe('pending'); // 'todo' maps to 'pending'
|
||||
expect(result.priority).toBe('medium');
|
||||
});
|
||||
|
||||
it('should extract implementation metadata from task', () => {
|
||||
const dbTask = createMockTaskRow({
|
||||
metadata: MetadataFixtures.completeMetadata
|
||||
});
|
||||
|
||||
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
expect(result.relevantFiles).toBeDefined();
|
||||
expect(result.relevantFiles?.[0].path).toBe('src/service.ts');
|
||||
expect(result.codebasePatterns).toEqual([
|
||||
'Use dependency injection',
|
||||
'Follow SOLID principles'
|
||||
]);
|
||||
expect(result.category).toBe('development');
|
||||
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
|
||||
});
|
||||
|
||||
it('should not add undefined metadata fields to result', () => {
|
||||
const dbTask = createMockTaskRow({
|
||||
metadata: MetadataFixtures.minimalMetadata
|
||||
});
|
||||
|
||||
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
// These should not be present as properties (not just undefined values)
|
||||
expect('relevantFiles' in result).toBe(false);
|
||||
expect('codebasePatterns' in result).toBe(false);
|
||||
expect('category' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should map subtasks with implementation metadata', () => {
|
||||
const parentTask = createMockTaskRow({ id: 'parent-uuid' });
|
||||
const subtask = createMockTaskRow({
|
||||
id: 'subtask-uuid',
|
||||
display_id: 'HAM-1.1',
|
||||
parent_task_id: 'parent-uuid',
|
||||
metadata: {
|
||||
details: 'Subtask details',
|
||||
testStrategy: 'Subtask tests',
|
||||
category: 'testing',
|
||||
skills: ['Jest', 'Vitest'],
|
||||
acceptanceCriteria: ['Tests pass', 'Coverage > 80%']
|
||||
}
|
||||
});
|
||||
|
||||
const result = TaskMapper.mapDatabaseTaskToTask(
|
||||
parentTask,
|
||||
[subtask],
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(result.subtasks).toHaveLength(1);
|
||||
const mappedSubtask = result.subtasks[0];
|
||||
expect(mappedSubtask.id).toBe('HAM-1.1');
|
||||
expect(mappedSubtask.category).toBe('testing');
|
||||
expect(mappedSubtask.skills).toEqual(['Jest', 'Vitest']);
|
||||
expect(mappedSubtask.acceptanceCriteria).toEqual([
|
||||
'Tests pass',
|
||||
'Coverage > 80%'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should map status correctly', () => {
|
||||
const todoTask = createMockTaskRow({ status: 'todo' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(todoTask, [], new Map()).status
|
||||
).toBe('pending');
|
||||
|
||||
const inProgressTask = createMockTaskRow({ status: 'in_progress' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(inProgressTask, [], new Map()).status
|
||||
).toBe('in-progress');
|
||||
|
||||
const doneTask = createMockTaskRow({ status: 'done' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(doneTask, [], new Map()).status
|
||||
).toBe('done');
|
||||
});
|
||||
|
||||
it('should map priority correctly', () => {
|
||||
const urgentTask = createMockTaskRow({ priority: 'urgent' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(urgentTask, [], new Map()).priority
|
||||
).toBe('critical');
|
||||
|
||||
const highTask = createMockTaskRow({ priority: 'high' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(highTask, [], new Map()).priority
|
||||
).toBe('high');
|
||||
|
||||
const mediumTask = createMockTaskRow({ priority: 'medium' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(mediumTask, [], new Map()).priority
|
||||
).toBe('medium');
|
||||
|
||||
const lowTask = createMockTaskRow({ priority: 'low' });
|
||||
expect(
|
||||
TaskMapper.mapDatabaseTaskToTask(lowTask, [], new Map()).priority
|
||||
).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDatabaseTasksToTasks', () => {
|
||||
it('should group subtasks under parent tasks', () => {
|
||||
const parentTask = createMockTaskRow({
|
||||
id: 'parent-uuid',
|
||||
display_id: 'HAM-1'
|
||||
});
|
||||
const subtask1 = createMockTaskRow({
|
||||
id: 'subtask-1-uuid',
|
||||
display_id: 'HAM-1.1',
|
||||
parent_task_id: 'parent-uuid',
|
||||
subtask_position: 1
|
||||
});
|
||||
const subtask2 = createMockTaskRow({
|
||||
id: 'subtask-2-uuid',
|
||||
display_id: 'HAM-1.2',
|
||||
parent_task_id: 'parent-uuid',
|
||||
subtask_position: 2
|
||||
});
|
||||
|
||||
const result = TaskMapper.mapDatabaseTasksToTasks(
|
||||
[parentTask, subtask1, subtask2],
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('HAM-1');
|
||||
expect(result[0].subtasks).toHaveLength(2);
|
||||
expect(result[0].subtasks[0].id).toBe('HAM-1.1');
|
||||
expect(result[0].subtasks[1].id).toBe('HAM-1.2');
|
||||
});
|
||||
|
||||
it('should handle empty task list', () => {
|
||||
const result = TaskMapper.mapDatabaseTasksToTasks([], new Map());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null task list', () => {
|
||||
const result = TaskMapper.mapDatabaseTasksToTasks(
|
||||
null as unknown as TaskRow[],
|
||||
new Map()
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include implementation metadata in mapped tasks', () => {
|
||||
const task = createMockTaskRow({
|
||||
metadata: {
|
||||
details: 'Task details',
|
||||
implementationApproach: 'Step by step guide',
|
||||
technicalConstraints: ['Constraint 1', 'Constraint 2']
|
||||
}
|
||||
});
|
||||
|
||||
const result = TaskMapper.mapDatabaseTasksToTasks([task], new Map());
|
||||
|
||||
expect(result[0].implementationApproach).toBe('Step by step guide');
|
||||
expect(result[0].technicalConstraints).toEqual([
|
||||
'Constraint 1',
|
||||
'Constraint 2'
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Database, Tables } from '../types/database.types.js';
|
||||
import { Subtask, Task } from '../types/index.js';
|
||||
import type {
|
||||
ExistingInfrastructure,
|
||||
RelevantFile,
|
||||
ScopeBoundaries,
|
||||
Subtask,
|
||||
Task,
|
||||
TaskCategory,
|
||||
TaskImplementationMetadata
|
||||
} from '../types/index.js';
|
||||
|
||||
type TaskRow = Tables<'tasks'>;
|
||||
|
||||
@@ -51,27 +59,35 @@ export class TaskMapper {
|
||||
dbSubtasks: TaskRow[],
|
||||
dependenciesByTaskId: Map<string, string[]>
|
||||
): Task {
|
||||
// Map subtasks
|
||||
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({
|
||||
id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
|
||||
parentId: dbTask.id,
|
||||
title: subtask.title,
|
||||
description: subtask.description || '',
|
||||
status: this.mapStatus(subtask.status),
|
||||
priority: this.mapPriority(subtask.priority),
|
||||
dependencies: dependenciesByTaskId.get(subtask.id) || [],
|
||||
details: this.extractMetadataField(subtask.metadata, 'details', ''),
|
||||
testStrategy: this.extractMetadataField(
|
||||
subtask.metadata,
|
||||
'testStrategy',
|
||||
''
|
||||
),
|
||||
createdAt: subtask.created_at,
|
||||
updatedAt: subtask.updated_at,
|
||||
assignee: subtask.assignee_id || undefined,
|
||||
complexity: subtask.complexity ?? undefined,
|
||||
databaseId: subtask.id // Include the actual database UUID
|
||||
}));
|
||||
// Map subtasks with implementation metadata
|
||||
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => {
|
||||
const implMeta = this.extractImplementationMetadata(subtask.metadata);
|
||||
return {
|
||||
id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
|
||||
parentId: dbTask.id,
|
||||
title: subtask.title,
|
||||
description: subtask.description || '',
|
||||
status: this.mapStatus(subtask.status),
|
||||
priority: this.mapPriority(subtask.priority),
|
||||
dependencies: dependenciesByTaskId.get(subtask.id) || [],
|
||||
details: this.extractMetadataField(subtask.metadata, 'details', ''),
|
||||
testStrategy: this.extractMetadataField(
|
||||
subtask.metadata,
|
||||
'testStrategy',
|
||||
''
|
||||
),
|
||||
createdAt: subtask.created_at,
|
||||
updatedAt: subtask.updated_at,
|
||||
assignee: subtask.assignee_id || undefined,
|
||||
complexity: subtask.complexity ?? undefined,
|
||||
databaseId: subtask.id, // Include the actual database UUID
|
||||
// Spread implementation metadata (only defined values)
|
||||
...this.filterUndefined(implMeta)
|
||||
};
|
||||
});
|
||||
|
||||
// Extract implementation metadata for the task
|
||||
const implMeta = this.extractImplementationMetadata(dbTask.metadata);
|
||||
|
||||
return {
|
||||
id: dbTask.display_id || dbTask.id, // Use display_id if available
|
||||
@@ -93,7 +109,9 @@ export class TaskMapper {
|
||||
assignee: dbTask.assignee_id || undefined,
|
||||
complexity: dbTask.complexity ?? undefined,
|
||||
effort: dbTask.estimated_hours || undefined,
|
||||
actualEffort: dbTask.actual_hours || undefined
|
||||
actualEffort: dbTask.actual_hours || undefined,
|
||||
// Spread implementation metadata (only defined values)
|
||||
...this.filterUndefined(implMeta)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,4 +233,182 @@ export class TaskMapper {
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts an optional string field from metadata
|
||||
*/
|
||||
private static extractOptionalString(
|
||||
metadata: unknown,
|
||||
field: string
|
||||
): string | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>)[field];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts an optional string array from metadata
|
||||
*/
|
||||
private static extractStringArray(
|
||||
metadata: unknown,
|
||||
field: string
|
||||
): string[] | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>)[field];
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
// Filter to only valid strings
|
||||
const strings = value.filter(
|
||||
(item): item is string => typeof item === 'string'
|
||||
);
|
||||
return strings.length > 0 ? strings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts RelevantFile[] from metadata
|
||||
*/
|
||||
private static extractRelevantFiles(
|
||||
metadata: unknown
|
||||
): RelevantFile[] | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>).relevantFiles;
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validFiles = value.filter((item): item is RelevantFile => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const obj = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.path === 'string' &&
|
||||
typeof obj.description === 'string' &&
|
||||
(obj.action === 'create' ||
|
||||
obj.action === 'modify' ||
|
||||
obj.action === 'reference')
|
||||
);
|
||||
});
|
||||
|
||||
return validFiles.length > 0 ? validFiles : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts ExistingInfrastructure[] from metadata
|
||||
*/
|
||||
private static extractExistingInfrastructure(
|
||||
metadata: unknown
|
||||
): ExistingInfrastructure[] | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>).existingInfrastructure;
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validInfra = value.filter((item): item is ExistingInfrastructure => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const obj = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.location === 'string' &&
|
||||
typeof obj.usage === 'string'
|
||||
);
|
||||
});
|
||||
|
||||
return validInfra.length > 0 ? validInfra : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts ScopeBoundaries from metadata
|
||||
*/
|
||||
private static extractScopeBoundaries(
|
||||
metadata: unknown
|
||||
): ScopeBoundaries | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>).scopeBoundaries;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: ScopeBoundaries = {};
|
||||
|
||||
if (typeof obj.included === 'string') {
|
||||
result.included = obj.included;
|
||||
}
|
||||
if (typeof obj.excluded === 'string') {
|
||||
result.excluded = obj.excluded;
|
||||
}
|
||||
|
||||
// Return undefined if no valid fields
|
||||
return result.included || result.excluded ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts TaskCategory from metadata
|
||||
*/
|
||||
private static extractCategory(metadata: unknown): TaskCategory | undefined {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const value = (metadata as Record<string, unknown>).category;
|
||||
const validCategories: TaskCategory[] = [
|
||||
'research',
|
||||
'design',
|
||||
'development',
|
||||
'testing',
|
||||
'documentation',
|
||||
'review'
|
||||
];
|
||||
return validCategories.includes(value as TaskCategory)
|
||||
? (value as TaskCategory)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all AI implementation metadata fields from database metadata
|
||||
*/
|
||||
static extractImplementationMetadata(
|
||||
metadata: unknown
|
||||
): TaskImplementationMetadata {
|
||||
return {
|
||||
relevantFiles: this.extractRelevantFiles(metadata),
|
||||
codebasePatterns: this.extractStringArray(metadata, 'codebasePatterns'),
|
||||
existingInfrastructure: this.extractExistingInfrastructure(metadata),
|
||||
scopeBoundaries: this.extractScopeBoundaries(metadata),
|
||||
implementationApproach: this.extractOptionalString(
|
||||
metadata,
|
||||
'implementationApproach'
|
||||
),
|
||||
technicalConstraints: this.extractStringArray(
|
||||
metadata,
|
||||
'technicalConstraints'
|
||||
),
|
||||
acceptanceCriteria: this.extractStringArray(
|
||||
metadata,
|
||||
'acceptanceCriteria'
|
||||
),
|
||||
skills: this.extractStringArray(metadata, 'skills'),
|
||||
category: this.extractCategory(metadata)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out undefined values from an object
|
||||
* Used to avoid adding undefined properties to task objects
|
||||
*/
|
||||
private static filterUndefined<T extends object>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined)
|
||||
) as Partial<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,80 @@ export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
*/
|
||||
export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex';
|
||||
|
||||
// ============================================================================
|
||||
// AI Metadata Types (from Supabase task metadata)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* File relevant to implementing a task/subtask
|
||||
*/
|
||||
export interface RelevantFile {
|
||||
/** File path relative to project root */
|
||||
path: string;
|
||||
/** What this file contains and how it relates to the task */
|
||||
description: string;
|
||||
/** Whether to create, modify, or just reference this file */
|
||||
action: 'create' | 'modify' | 'reference';
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing infrastructure to leverage
|
||||
*/
|
||||
export interface ExistingInfrastructure {
|
||||
/** Name of the existing service/module/infrastructure */
|
||||
name: string;
|
||||
/** Where it exists in the codebase */
|
||||
location: string;
|
||||
/** How to use or integrate with it */
|
||||
usage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope boundaries for a task
|
||||
*/
|
||||
export interface ScopeBoundaries {
|
||||
/** What is explicitly in scope for this task */
|
||||
included?: string;
|
||||
/** What is explicitly out of scope (belongs to other tasks/already exists) */
|
||||
excluded?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task work category
|
||||
*/
|
||||
export type TaskCategory =
|
||||
| 'research'
|
||||
| 'design'
|
||||
| 'development'
|
||||
| 'testing'
|
||||
| 'documentation'
|
||||
| 'review';
|
||||
|
||||
/**
|
||||
* AI-generated implementation guidance metadata
|
||||
* These fields provide rich context for AI agents and developers
|
||||
*/
|
||||
export interface TaskImplementationMetadata {
|
||||
/** Files relevant to implementing this task */
|
||||
relevantFiles?: RelevantFile[];
|
||||
/** Existing code patterns, conventions, or architectural principles to follow */
|
||||
codebasePatterns?: string[];
|
||||
/** Existing services, modules, or infrastructure to leverage */
|
||||
existingInfrastructure?: ExistingInfrastructure[];
|
||||
/** Clear boundaries of what this task should and should not do */
|
||||
scopeBoundaries?: ScopeBoundaries;
|
||||
/** Step-by-step implementation guidance or pseudo-code */
|
||||
implementationApproach?: string;
|
||||
/** Framework requirements, architecture decisions, or technical limitations */
|
||||
technicalConstraints?: string[];
|
||||
/** Acceptance criteria defining when this task is complete */
|
||||
acceptanceCriteria?: string[];
|
||||
/** Required technical skills to complete this task */
|
||||
skills?: string[];
|
||||
/** Category of work this task represents */
|
||||
category?: TaskCategory;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core Interfaces
|
||||
// ============================================================================
|
||||
@@ -54,7 +128,7 @@ export interface PlaceholderTask {
|
||||
/**
|
||||
* Base task interface
|
||||
*/
|
||||
export interface Task {
|
||||
export interface Task extends TaskImplementationMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
createSubtask,
|
||||
createTasksFile,
|
||||
TaskScenarios,
|
||||
MetadataFixtures,
|
||||
type TasksFile
|
||||
} from './task-fixtures.js';
|
||||
|
||||
|
||||
@@ -80,7 +80,29 @@ export function createTask(
|
||||
}),
|
||||
...(overrides.complexityReasoning && {
|
||||
complexityReasoning: overrides.complexityReasoning
|
||||
})
|
||||
}),
|
||||
// AI implementation metadata fields
|
||||
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
|
||||
...(overrides.codebasePatterns && {
|
||||
codebasePatterns: overrides.codebasePatterns
|
||||
}),
|
||||
...(overrides.existingInfrastructure && {
|
||||
existingInfrastructure: overrides.existingInfrastructure
|
||||
}),
|
||||
...(overrides.scopeBoundaries && {
|
||||
scopeBoundaries: overrides.scopeBoundaries
|
||||
}),
|
||||
...(overrides.implementationApproach && {
|
||||
implementationApproach: overrides.implementationApproach
|
||||
}),
|
||||
...(overrides.technicalConstraints && {
|
||||
technicalConstraints: overrides.technicalConstraints
|
||||
}),
|
||||
...(overrides.acceptanceCriteria && {
|
||||
acceptanceCriteria: overrides.acceptanceCriteria
|
||||
}),
|
||||
...(overrides.skills && { skills: overrides.skills }),
|
||||
...(overrides.category && { category: overrides.category })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +156,29 @@ export function createSubtask(
|
||||
}),
|
||||
...(overrides.complexityReasoning && {
|
||||
complexityReasoning: overrides.complexityReasoning
|
||||
})
|
||||
}),
|
||||
// AI implementation metadata fields
|
||||
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
|
||||
...(overrides.codebasePatterns && {
|
||||
codebasePatterns: overrides.codebasePatterns
|
||||
}),
|
||||
...(overrides.existingInfrastructure && {
|
||||
existingInfrastructure: overrides.existingInfrastructure
|
||||
}),
|
||||
...(overrides.scopeBoundaries && {
|
||||
scopeBoundaries: overrides.scopeBoundaries
|
||||
}),
|
||||
...(overrides.implementationApproach && {
|
||||
implementationApproach: overrides.implementationApproach
|
||||
}),
|
||||
...(overrides.technicalConstraints && {
|
||||
technicalConstraints: overrides.technicalConstraints
|
||||
}),
|
||||
...(overrides.acceptanceCriteria && {
|
||||
acceptanceCriteria: overrides.acceptanceCriteria
|
||||
}),
|
||||
...(overrides.skills && { skills: overrides.skills }),
|
||||
...(overrides.category && { category: overrides.category })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,5 +347,149 @@ export const TaskScenarios = {
|
||||
/**
|
||||
* Empty task list
|
||||
*/
|
||||
empty: () => createTasksFile({ tasks: [] })
|
||||
empty: () => createTasksFile({ tasks: [] }),
|
||||
|
||||
/**
|
||||
* Task with rich AI-generated implementation metadata
|
||||
*/
|
||||
taskWithImplementationMetadata: () =>
|
||||
createTasksFile({
|
||||
tasks: [
|
||||
createTask({
|
||||
id: 1,
|
||||
title: 'Implement User Authentication',
|
||||
description: 'Add JWT-based authentication to the API',
|
||||
details: 'Implement secure JWT authentication with refresh tokens',
|
||||
testStrategy:
|
||||
'Unit tests for auth functions, integration tests for flow',
|
||||
category: 'development',
|
||||
skills: ['TypeScript', 'JWT', 'Security'],
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth/auth.service.ts',
|
||||
description: 'Main authentication service',
|
||||
action: 'modify'
|
||||
},
|
||||
{
|
||||
path: 'src/auth/jwt.strategy.ts',
|
||||
description: 'JWT passport strategy',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
codebasePatterns: [
|
||||
'Use dependency injection for services',
|
||||
'Follow repository pattern for data access'
|
||||
],
|
||||
existingInfrastructure: [
|
||||
{
|
||||
name: 'UserRepository',
|
||||
location: 'src/users/user.repository.ts',
|
||||
usage: 'Use for user lookups during authentication'
|
||||
}
|
||||
],
|
||||
scopeBoundaries: {
|
||||
included: 'JWT token generation, validation, and refresh',
|
||||
excluded: 'OAuth integration (handled in task 2)'
|
||||
},
|
||||
implementationApproach:
|
||||
'1. Create JWT strategy\n2. Add auth guards\n3. Implement refresh token flow',
|
||||
technicalConstraints: [
|
||||
'Must use RS256 algorithm',
|
||||
'Tokens must expire in 15 minutes'
|
||||
],
|
||||
acceptanceCriteria: [
|
||||
'Users can login with email/password',
|
||||
'JWT tokens are issued on successful login',
|
||||
'Refresh tokens work correctly'
|
||||
],
|
||||
subtasks: [
|
||||
createSubtask({
|
||||
id: '1.1',
|
||||
title: 'Create JWT Strategy',
|
||||
category: 'development',
|
||||
skills: ['TypeScript', 'Passport.js'],
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth/jwt.strategy.ts',
|
||||
description: 'JWT passport strategy implementation',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
acceptanceCriteria: ['Strategy validates JWT tokens correctly']
|
||||
}),
|
||||
createSubtask({
|
||||
id: '1.2',
|
||||
title: 'Implement Auth Guards',
|
||||
category: 'development',
|
||||
implementationApproach: 'Create NestJS guards using JWT strategy',
|
||||
technicalConstraints: ['Must work with role-based access control']
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample metadata fixtures for testing metadata extraction
|
||||
*/
|
||||
export const MetadataFixtures = {
|
||||
/**
|
||||
* Complete metadata object with all fields populated
|
||||
*/
|
||||
completeMetadata: {
|
||||
details: 'Detailed task requirements and scope',
|
||||
testStrategy: 'Unit and integration tests',
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/service.ts',
|
||||
description: 'Main service file',
|
||||
action: 'modify' as const
|
||||
}
|
||||
],
|
||||
codebasePatterns: ['Use dependency injection', 'Follow SOLID principles'],
|
||||
existingInfrastructure: [
|
||||
{
|
||||
name: 'Logger',
|
||||
location: 'src/common/logger.ts',
|
||||
usage: 'Use for structured logging'
|
||||
}
|
||||
],
|
||||
scopeBoundaries: {
|
||||
included: 'Core functionality',
|
||||
excluded: 'UI changes'
|
||||
},
|
||||
implementationApproach: 'Step-by-step implementation guide',
|
||||
technicalConstraints: ['Must be backwards compatible'],
|
||||
acceptanceCriteria: ['Feature works as expected', 'Tests pass'],
|
||||
skills: ['TypeScript', 'Node.js'],
|
||||
category: 'development' as const
|
||||
},
|
||||
|
||||
/**
|
||||
* Minimal metadata with only required fields
|
||||
*/
|
||||
minimalMetadata: {
|
||||
details: 'Basic details',
|
||||
testStrategy: 'Basic tests'
|
||||
},
|
||||
|
||||
/**
|
||||
* Metadata with invalid/malformed data (for testing robustness)
|
||||
*/
|
||||
malformedMetadata: {
|
||||
details: 123, // Should be string
|
||||
testStrategy: null, // Should be string
|
||||
relevantFiles: 'not-an-array', // Should be array
|
||||
codebasePatterns: [123, null, 'valid'], // Mixed invalid types
|
||||
existingInfrastructure: [{ invalid: 'structure' }], // Missing required fields
|
||||
scopeBoundaries: 'not-an-object', // Should be object
|
||||
category: 'invalid-category', // Invalid enum value
|
||||
skills: { not: 'an-array' } // Should be array
|
||||
},
|
||||
|
||||
/**
|
||||
* Empty metadata object
|
||||
*/
|
||||
emptyMetadata: {}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* @fileoverview Integration tests for task metadata extraction across storage modes
|
||||
*
|
||||
* These tests verify that rich AI-generated implementation metadata is correctly
|
||||
* extracted and passed through the storage layer for both file and API storage modes.
|
||||
*
|
||||
* For API storage: Tests the flow from database rows -> TaskMapper -> Task type
|
||||
* For file storage: Tests that metadata is preserved in JSON serialization/deserialization
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TaskMapper } from '../../../src/common/mappers/TaskMapper.js';
|
||||
import type { Json, Tables } from '../../../src/common/types/database.types.js';
|
||||
import type {
|
||||
ExistingInfrastructure,
|
||||
RelevantFile,
|
||||
Task
|
||||
} from '../../../src/common/types/index.js';
|
||||
|
||||
type TaskRow = Tables<'tasks'>;
|
||||
|
||||
/**
|
||||
* Creates a realistic database task row with AI-generated metadata
|
||||
*/
|
||||
function createDatabaseTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||
return {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
display_id: 'HAM-1',
|
||||
title: 'Implement Authentication System',
|
||||
description: 'Add JWT-based authentication to the API',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
brief_id: 'brief-550e8400',
|
||||
parent_task_id: null,
|
||||
position: 1,
|
||||
subtask_position: 0,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
updated_at: '2024-01-15T14:30:00Z',
|
||||
metadata: {
|
||||
details:
|
||||
'Implement secure JWT authentication with refresh tokens and role-based access control',
|
||||
testStrategy:
|
||||
'Unit tests for auth functions, integration tests for login flow, E2E tests for protected routes',
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth/auth.service.ts',
|
||||
description: 'Main authentication service handling login/logout',
|
||||
action: 'modify'
|
||||
},
|
||||
{
|
||||
path: 'src/auth/jwt.strategy.ts',
|
||||
description: 'Passport JWT strategy for token validation',
|
||||
action: 'create'
|
||||
},
|
||||
{
|
||||
path: 'src/auth/guards/auth.guard.ts',
|
||||
description: 'NestJS guard for protected routes',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
codebasePatterns: [
|
||||
'Use dependency injection for all services',
|
||||
'Follow repository pattern for data access',
|
||||
'Use DTOs for request/response validation'
|
||||
],
|
||||
existingInfrastructure: [
|
||||
{
|
||||
name: 'UserRepository',
|
||||
location: 'src/users/user.repository.ts',
|
||||
usage: 'Use for user lookups during authentication'
|
||||
},
|
||||
{
|
||||
name: 'ConfigService',
|
||||
location: 'src/config/config.service.ts',
|
||||
usage: 'Access JWT secret and token expiry settings'
|
||||
}
|
||||
],
|
||||
scopeBoundaries: {
|
||||
included:
|
||||
'JWT token generation, validation, refresh token flow, auth guards',
|
||||
excluded:
|
||||
'OAuth/social login (separate task), password reset (separate task)'
|
||||
},
|
||||
implementationApproach: `1. Create JWT strategy extending PassportStrategy
|
||||
2. Implement AuthService with login/validateUser methods
|
||||
3. Create AuthGuard for route protection
|
||||
4. Add refresh token endpoint
|
||||
5. Update user entity with hashed password storage`,
|
||||
technicalConstraints: [
|
||||
'Must use RS256 algorithm for JWT signing',
|
||||
'Access tokens must expire in 15 minutes',
|
||||
'Refresh tokens must expire in 7 days',
|
||||
'Passwords must be hashed with bcrypt (cost factor 12)'
|
||||
],
|
||||
acceptanceCriteria: [
|
||||
'Users can register with email and password',
|
||||
'Users can login and receive JWT tokens',
|
||||
'Protected routes reject requests without valid tokens',
|
||||
'Refresh tokens can be used to get new access tokens',
|
||||
'All auth-related errors return appropriate HTTP status codes'
|
||||
],
|
||||
skills: ['TypeScript', 'NestJS', 'JWT', 'Passport.js', 'bcrypt'],
|
||||
category: 'development'
|
||||
} as Json,
|
||||
complexity: 7,
|
||||
estimated_hours: 16,
|
||||
actual_hours: 0,
|
||||
assignee_id: null,
|
||||
document_id: null,
|
||||
account_id: 'account-123',
|
||||
created_by: 'user-123',
|
||||
updated_by: 'user-123',
|
||||
completed_subtasks: 0,
|
||||
total_subtasks: 3,
|
||||
due_date: '2024-01-20T17:00:00Z',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a database subtask row with implementation metadata
|
||||
*/
|
||||
function createDatabaseSubtaskRow(
|
||||
parentId: string,
|
||||
subtaskNum: number
|
||||
): TaskRow {
|
||||
const subtaskMetadata: Record<number, object> = {
|
||||
1: {
|
||||
details: 'Create the JWT strategy class that validates tokens',
|
||||
testStrategy: 'Unit tests for token validation logic',
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth/jwt.strategy.ts',
|
||||
description: 'JWT strategy implementation',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
implementationApproach:
|
||||
'Extend PassportStrategy with jwt-passport, implement validate method',
|
||||
acceptanceCriteria: [
|
||||
'Strategy validates JWT tokens',
|
||||
'Invalid tokens are rejected'
|
||||
],
|
||||
skills: ['TypeScript', 'Passport.js'],
|
||||
category: 'development'
|
||||
},
|
||||
2: {
|
||||
details: 'Create guards for protecting routes',
|
||||
testStrategy: 'Integration tests with mock requests',
|
||||
relevantFiles: [
|
||||
{
|
||||
path: 'src/auth/guards/auth.guard.ts',
|
||||
description: 'Main auth guard',
|
||||
action: 'create'
|
||||
}
|
||||
],
|
||||
technicalConstraints: ['Must work with NestJS execution context'],
|
||||
acceptanceCriteria: ['Protected routes require valid JWT'],
|
||||
category: 'development'
|
||||
},
|
||||
3: {
|
||||
details: 'Implement refresh token flow',
|
||||
testStrategy: 'E2E tests for token refresh',
|
||||
scopeBoundaries: {
|
||||
included: 'Refresh token generation and validation',
|
||||
excluded: 'Token revocation (future enhancement)'
|
||||
},
|
||||
acceptanceCriteria: [
|
||||
'Refresh tokens can get new access tokens',
|
||||
'Expired refresh tokens are rejected'
|
||||
],
|
||||
category: 'development'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: `subtask-${subtaskNum}-uuid`,
|
||||
display_id: `HAM-1.${subtaskNum}`,
|
||||
title: `Subtask ${subtaskNum}`,
|
||||
description: `Description for subtask ${subtaskNum}`,
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
brief_id: 'brief-550e8400',
|
||||
parent_task_id: parentId,
|
||||
position: 1,
|
||||
subtask_position: subtaskNum,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
updated_at: '2024-01-15T10:00:00Z',
|
||||
metadata: subtaskMetadata[subtaskNum] as Json,
|
||||
complexity: null,
|
||||
estimated_hours: 4,
|
||||
actual_hours: 0,
|
||||
assignee_id: null,
|
||||
document_id: null,
|
||||
account_id: 'account-123',
|
||||
created_by: 'user-123',
|
||||
updated_by: 'user-123',
|
||||
completed_subtasks: 0,
|
||||
total_subtasks: 0,
|
||||
due_date: null
|
||||
};
|
||||
}
|
||||
|
||||
describe('Task Metadata Extraction - Integration Tests', () => {
|
||||
describe('API Storage Mode - TaskMapper Integration', () => {
|
||||
it('should extract complete implementation metadata from database task', () => {
|
||||
const dbTask = createDatabaseTaskRow();
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
// Verify core fields
|
||||
expect(task.id).toBe('HAM-1');
|
||||
expect(task.title).toBe('Implement Authentication System');
|
||||
expect(task.status).toBe('in-progress');
|
||||
expect(task.priority).toBe('high');
|
||||
|
||||
// Verify details and testStrategy
|
||||
expect(task.details).toContain('JWT authentication');
|
||||
expect(task.testStrategy).toContain('Unit tests');
|
||||
|
||||
// Verify implementation metadata
|
||||
expect(task.relevantFiles).toBeDefined();
|
||||
expect(task.relevantFiles).toHaveLength(3);
|
||||
expect(task.relevantFiles![0]).toEqual({
|
||||
path: 'src/auth/auth.service.ts',
|
||||
description: 'Main authentication service handling login/logout',
|
||||
action: 'modify'
|
||||
});
|
||||
|
||||
expect(task.codebasePatterns).toEqual([
|
||||
'Use dependency injection for all services',
|
||||
'Follow repository pattern for data access',
|
||||
'Use DTOs for request/response validation'
|
||||
]);
|
||||
|
||||
expect(task.existingInfrastructure).toHaveLength(2);
|
||||
expect(task.existingInfrastructure![0].name).toBe('UserRepository');
|
||||
|
||||
expect(task.scopeBoundaries).toEqual({
|
||||
included:
|
||||
'JWT token generation, validation, refresh token flow, auth guards',
|
||||
excluded:
|
||||
'OAuth/social login (separate task), password reset (separate task)'
|
||||
});
|
||||
|
||||
expect(task.implementationApproach).toContain('Create JWT strategy');
|
||||
expect(task.technicalConstraints).toContain(
|
||||
'Must use RS256 algorithm for JWT signing'
|
||||
);
|
||||
expect(task.acceptanceCriteria).toContain(
|
||||
'Users can register with email and password'
|
||||
);
|
||||
expect(task.skills).toEqual([
|
||||
'TypeScript',
|
||||
'NestJS',
|
||||
'JWT',
|
||||
'Passport.js',
|
||||
'bcrypt'
|
||||
]);
|
||||
expect(task.category).toBe('development');
|
||||
});
|
||||
|
||||
it('should extract metadata from subtasks', () => {
|
||||
const parentTask = createDatabaseTaskRow();
|
||||
const subtasks = [
|
||||
createDatabaseSubtaskRow(parentTask.id, 1),
|
||||
createDatabaseSubtaskRow(parentTask.id, 2),
|
||||
createDatabaseSubtaskRow(parentTask.id, 3)
|
||||
];
|
||||
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(
|
||||
parentTask,
|
||||
subtasks,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(task.subtasks).toHaveLength(3);
|
||||
|
||||
// Verify first subtask has metadata
|
||||
const subtask1 = task.subtasks[0];
|
||||
expect(subtask1.id).toBe('HAM-1.1');
|
||||
expect(subtask1.relevantFiles).toHaveLength(1);
|
||||
expect(subtask1.skills).toEqual(['TypeScript', 'Passport.js']);
|
||||
expect(subtask1.category).toBe('development');
|
||||
expect(subtask1.acceptanceCriteria).toContain(
|
||||
'Strategy validates JWT tokens'
|
||||
);
|
||||
|
||||
// Verify second subtask has different metadata
|
||||
const subtask2 = task.subtasks[1];
|
||||
expect(subtask2.technicalConstraints).toContain(
|
||||
'Must work with NestJS execution context'
|
||||
);
|
||||
|
||||
// Verify third subtask has scope boundaries
|
||||
const subtask3 = task.subtasks[2];
|
||||
expect(subtask3.scopeBoundaries).toBeDefined();
|
||||
expect(subtask3.scopeBoundaries!.included).toContain('Refresh token');
|
||||
});
|
||||
|
||||
it('should handle tasks without metadata gracefully', () => {
|
||||
const dbTask = createDatabaseTaskRow({
|
||||
metadata: {} as Json
|
||||
});
|
||||
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
expect(task.id).toBe('HAM-1');
|
||||
expect(task.details).toBe('');
|
||||
expect(task.testStrategy).toBe('');
|
||||
expect(task.relevantFiles).toBeUndefined();
|
||||
expect(task.codebasePatterns).toBeUndefined();
|
||||
expect(task.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle malformed metadata without crashing', () => {
|
||||
const dbTask = createDatabaseTaskRow({
|
||||
metadata: {
|
||||
details: 123, // Invalid: should be string
|
||||
relevantFiles: 'not-an-array', // Invalid: should be array
|
||||
category: 'invalid-category', // Invalid: not a valid enum value
|
||||
skills: { wrong: 'type' } // Invalid: should be array
|
||||
} as unknown as Json
|
||||
});
|
||||
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
// Should not crash and should use defaults
|
||||
expect(task.id).toBe('HAM-1');
|
||||
expect(task.details).toBe(''); // Falls back to empty string
|
||||
expect(task.relevantFiles).toBeUndefined();
|
||||
expect(task.category).toBeUndefined();
|
||||
expect(task.skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should map multiple tasks with their subtasks correctly', () => {
|
||||
const parentTask1 = createDatabaseTaskRow({
|
||||
id: 'parent-1-uuid',
|
||||
display_id: 'HAM-1'
|
||||
});
|
||||
const parentTask2 = createDatabaseTaskRow({
|
||||
id: 'parent-2-uuid',
|
||||
display_id: 'HAM-2',
|
||||
title: 'Second Task'
|
||||
});
|
||||
const subtask1_1 = createDatabaseSubtaskRow('parent-1-uuid', 1);
|
||||
const subtask2_1: TaskRow = {
|
||||
...createDatabaseSubtaskRow('parent-2-uuid', 1),
|
||||
display_id: 'HAM-2.1'
|
||||
};
|
||||
|
||||
const allTasks = [parentTask1, parentTask2, subtask1_1, subtask2_1];
|
||||
const tasks = TaskMapper.mapDatabaseTasksToTasks(allTasks, new Map());
|
||||
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0].id).toBe('HAM-1');
|
||||
expect(tasks[0].subtasks).toHaveLength(1);
|
||||
expect(tasks[0].subtasks[0].id).toBe('HAM-1.1');
|
||||
|
||||
expect(tasks[1].id).toBe('HAM-2');
|
||||
expect(tasks[1].subtasks).toHaveLength(1);
|
||||
expect(tasks[1].subtasks[0].id).toBe('HAM-2.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Storage Mode - JSON Serialization', () => {
|
||||
it('should preserve all metadata fields through JSON serialization', () => {
|
||||
// Create a task with full metadata (simulating what would be stored in tasks.json)
|
||||
const taskWithMetadata: Task = {
|
||||
id: '1',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Detailed requirements',
|
||||
testStrategy: 'Unit and integration tests',
|
||||
subtasks: [],
|
||||
relevantFiles: [
|
||||
{ path: 'src/test.ts', description: 'Test file', action: 'modify' }
|
||||
],
|
||||
codebasePatterns: ['Pattern 1', 'Pattern 2'],
|
||||
existingInfrastructure: [
|
||||
{ name: 'Service', location: 'src/service.ts', usage: 'Use for X' }
|
||||
],
|
||||
scopeBoundaries: { included: 'A', excluded: 'B' },
|
||||
implementationApproach: 'Step by step',
|
||||
technicalConstraints: ['Constraint 1'],
|
||||
acceptanceCriteria: ['Criteria 1', 'Criteria 2'],
|
||||
skills: ['TypeScript'],
|
||||
category: 'development'
|
||||
};
|
||||
|
||||
// Serialize and deserialize (simulating file storage)
|
||||
const serialized = JSON.stringify(taskWithMetadata);
|
||||
const deserialized: Task = JSON.parse(serialized);
|
||||
|
||||
// All fields should be preserved
|
||||
expect(deserialized.relevantFiles).toEqual(
|
||||
taskWithMetadata.relevantFiles
|
||||
);
|
||||
expect(deserialized.codebasePatterns).toEqual(
|
||||
taskWithMetadata.codebasePatterns
|
||||
);
|
||||
expect(deserialized.existingInfrastructure).toEqual(
|
||||
taskWithMetadata.existingInfrastructure
|
||||
);
|
||||
expect(deserialized.scopeBoundaries).toEqual(
|
||||
taskWithMetadata.scopeBoundaries
|
||||
);
|
||||
expect(deserialized.implementationApproach).toBe(
|
||||
taskWithMetadata.implementationApproach
|
||||
);
|
||||
expect(deserialized.technicalConstraints).toEqual(
|
||||
taskWithMetadata.technicalConstraints
|
||||
);
|
||||
expect(deserialized.acceptanceCriteria).toEqual(
|
||||
taskWithMetadata.acceptanceCriteria
|
||||
);
|
||||
expect(deserialized.skills).toEqual(taskWithMetadata.skills);
|
||||
expect(deserialized.category).toBe(taskWithMetadata.category);
|
||||
});
|
||||
|
||||
it('should handle tasks without optional metadata in JSON', () => {
|
||||
const minimalTask: Task = {
|
||||
id: '1',
|
||||
title: 'Minimal Task',
|
||||
description: 'Description',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
details: '',
|
||||
testStrategy: '',
|
||||
subtasks: []
|
||||
};
|
||||
|
||||
const serialized = JSON.stringify(minimalTask);
|
||||
const deserialized: Task = JSON.parse(serialized);
|
||||
|
||||
expect(deserialized.id).toBe('1');
|
||||
expect(deserialized.relevantFiles).toBeUndefined();
|
||||
expect(deserialized.category).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metadata Type Validation', () => {
|
||||
it('should correctly type relevantFiles entries', () => {
|
||||
const dbTask = createDatabaseTaskRow();
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
// TypeScript type checking - if these compile, types are correct
|
||||
const files: RelevantFile[] | undefined = task.relevantFiles;
|
||||
expect(files).toBeDefined();
|
||||
|
||||
if (files) {
|
||||
const firstFile: RelevantFile = files[0];
|
||||
expect(firstFile.path).toBe('src/auth/auth.service.ts');
|
||||
expect(firstFile.action).toBe('modify');
|
||||
expect(['create', 'modify', 'reference']).toContain(firstFile.action);
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly type existingInfrastructure entries', () => {
|
||||
const dbTask = createDatabaseTaskRow();
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
const infra: ExistingInfrastructure[] | undefined =
|
||||
task.existingInfrastructure;
|
||||
expect(infra).toBeDefined();
|
||||
|
||||
if (infra) {
|
||||
const firstInfra: ExistingInfrastructure = infra[0];
|
||||
expect(firstInfra.name).toBe('UserRepository');
|
||||
expect(firstInfra.location).toBe('src/users/user.repository.ts');
|
||||
expect(firstInfra.usage).toContain('user lookups');
|
||||
}
|
||||
});
|
||||
|
||||
it('should correctly type category field', () => {
|
||||
const dbTask = createDatabaseTaskRow();
|
||||
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
|
||||
|
||||
// Verify category is a valid enum value
|
||||
const validCategories = [
|
||||
'research',
|
||||
'design',
|
||||
'development',
|
||||
'testing',
|
||||
'documentation',
|
||||
'review'
|
||||
];
|
||||
expect(task.category).toBeDefined();
|
||||
expect(validCategories).toContain(task.category);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user