Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions tests/unit/config/projects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));

vi.mock('../../../src/config/schema.js', () => ({
validateConfig: vi.fn((config: unknown) => config),
}));

import { existsSync, readFileSync } from 'node:fs';
import {
clearConfigCache,
findProjectByBoardId,
findProjectById,
findProjectByRepo,
getProjectGitHubToken,
loadProjectsConfig,
} from '../../../src/config/projects.js';

describe('projects config', () => {
const mockConfig = {
defaults: {
model: 'test-model',
maxIterations: 50,
},
projects: [
{
id: 'project1',
name: 'Project 1',
repo: 'owner/repo1',
baseBranch: 'main',
branchPrefix: 'feature/',
githubTokenEnv: 'GITHUB_TOKEN_1',
trello: {
boardId: 'board1',
lists: { todo: 'list1' },
labels: {},
},
},
{
id: 'project2',
name: 'Project 2',
repo: 'owner/repo2',
baseBranch: 'main',
branchPrefix: 'feature/',
githubTokenEnv: 'GITHUB_TOKEN',
trello: {
boardId: 'board2',
lists: { todo: 'list2' },
labels: {},
},
},
],
};

beforeEach(() => {
vi.clearAllMocks();
clearConfigCache();
});

afterEach(() => {
clearConfigCache();
});

describe('loadProjectsConfig', () => {
it('loads and validates config from file', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));

const result = loadProjectsConfig('/path/to/config.json');

expect(existsSync).toHaveBeenCalledWith('/path/to/config.json');
expect(readFileSync).toHaveBeenCalledWith('/path/to/config.json', 'utf-8');
expect(result).toEqual(mockConfig);
});

it('throws when config file does not exist', () => {
vi.mocked(existsSync).mockReturnValue(false);

expect(() => loadProjectsConfig('/missing/config.json')).toThrow(
'Config file not found: /missing/config.json',
);
});

it('caches config after first load', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));

loadProjectsConfig('/path/to/config.json');
loadProjectsConfig('/path/to/config.json');

expect(readFileSync).toHaveBeenCalledTimes(1);
});

it('reloads config after cache is cleared', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));

loadProjectsConfig('/path/to/config.json');
clearConfigCache();
loadProjectsConfig('/path/to/config.json');

expect(readFileSync).toHaveBeenCalledTimes(2);
});
});

describe('findProjectByBoardId', () => {
it('finds project by board ID', () => {
const result = findProjectByBoardId(mockConfig, 'board1');
expect(result?.id).toBe('project1');
});

it('returns undefined for unknown board ID', () => {
const result = findProjectByBoardId(mockConfig, 'unknown');
expect(result).toBeUndefined();
});
});

describe('findProjectById', () => {
it('finds project by ID', () => {
const result = findProjectById(mockConfig, 'project2');
expect(result?.id).toBe('project2');
});

it('returns undefined for unknown ID', () => {
const result = findProjectById(mockConfig, 'unknown');
expect(result).toBeUndefined();
});
});

describe('findProjectByRepo', () => {
it('finds project by repo full name', () => {
const result = findProjectByRepo(mockConfig, 'owner/repo1');
expect(result?.id).toBe('project1');
});

it('returns undefined for unknown repo', () => {
const result = findProjectByRepo(mockConfig, 'owner/unknown');
expect(result).toBeUndefined();
});
});

describe('getProjectGitHubToken', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('returns token from environment variable', () => {
process.env.GITHUB_TOKEN_1 = 'test-token-123';

const result = getProjectGitHubToken(mockConfig.projects[0]);

expect(result).toBe('test-token-123');
});

it('uses GITHUB_TOKEN as default when githubTokenEnv is not set', () => {
process.env.GITHUB_TOKEN = 'default-token';

const project = { ...mockConfig.projects[0], githubTokenEnv: undefined };
const result = getProjectGitHubToken(project);

expect(result).toBe('default-token');
});

it('throws when token environment variable is not set', () => {
process.env.GITHUB_TOKEN_1 = undefined;

expect(() => getProjectGitHubToken(mockConfig.projects[0])).toThrow(
'Missing GitHub token for project project1: GITHUB_TOKEN_1 not set',
);
});
});
});
169 changes: 169 additions & 0 deletions tests/unit/gadgets/todo-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));

vi.mock('../../../src/utils/repo.js', () => ({
getWorkspaceDir: vi.fn(() => '/workspace'),
}));

import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import {
type Todo,
formatTodoList,
getNextId,
getSessionId,
initTodoSession,
loadTodos,
saveTodos,
} from '../../../src/gadgets/todo/storage.js';

describe('todo storage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset session state by re-initializing
vi.mocked(existsSync).mockReturnValue(true);
});

describe('initTodoSession', () => {
it('sets session ID and ensures directory exists', () => {
vi.mocked(existsSync).mockReturnValue(false);

initTodoSession('test-session-123');

expect(getSessionId()).toBe('test-session-123');
expect(mkdirSync).toHaveBeenCalledWith(expect.stringContaining('todos'), { recursive: true });
});
});

describe('getSessionId', () => {
it('returns initialized session ID', () => {
initTodoSession('my-session');
expect(getSessionId()).toBe('my-session');
});
});

describe('loadTodos', () => {
it('returns empty array when session file does not exist', () => {
initTodoSession('load-test');
vi.mocked(existsSync)
.mockReturnValueOnce(true) // todos dir exists
.mockReturnValueOnce(false); // session file doesn't exist

const todos = loadTodos();

expect(todos).toEqual([]);
});

it('loads todos from session file', () => {
initTodoSession('load-test-2');
const mockTodos: Todo[] = [
{
id: '1',
content: 'First task',
status: 'pending',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockTodos));

const todos = loadTodos();

expect(todos).toEqual(mockTodos);
});
});

describe('saveTodos', () => {
it('writes todos to session file', () => {
initTodoSession('save-test');
const todos: Todo[] = [
{
id: '1',
content: 'Task 1',
status: 'done',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];

saveTodos(todos);

expect(writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('save-test.json'),
JSON.stringify(todos, null, 2),
);
});
});

describe('getNextId', () => {
it('returns "1" for empty list', () => {
expect(getNextId([])).toBe('1');
});

it('returns next sequential ID', () => {
const todos: Todo[] = [
{ id: '1', content: 'First', status: 'pending', createdAt: '', updatedAt: '' },
{ id: '3', content: 'Third', status: 'pending', createdAt: '', updatedAt: '' },
];

expect(getNextId(todos)).toBe('4');
});

it('handles non-numeric IDs gracefully', () => {
const todos: Todo[] = [
{ id: 'abc', content: 'Non-numeric', status: 'pending', createdAt: '', updatedAt: '' },
];

expect(getNextId(todos)).toBe('1');
});
});

describe('formatTodoList', () => {
it('returns empty message for no todos', () => {
expect(formatTodoList([])).toContain('empty');
});

it('formats todos with status icons', () => {
const todos: Todo[] = [
{ id: '1', content: 'Pending task', status: 'pending', createdAt: '', updatedAt: '' },
{
id: '2',
content: 'In progress task',
status: 'in_progress',
createdAt: '',
updatedAt: '',
},
{ id: '3', content: 'Done task', status: 'done', createdAt: '', updatedAt: '' },
];

const formatted = formatTodoList(todos);

expect(formatted).toContain('#1');
expect(formatted).toContain('Pending task');
expect(formatted).toContain('#2');
expect(formatted).toContain('In progress task');
expect(formatted).toContain('#3');
expect(formatted).toContain('Done task');
expect(formatted).toContain('1/3 done');
expect(formatted).toContain('1 in progress');
expect(formatted).toContain('1 pending');
});

it('includes progress summary', () => {
const todos: Todo[] = [
{ id: '1', content: 'Task 1', status: 'done', createdAt: '', updatedAt: '' },
{ id: '2', content: 'Task 2', status: 'done', createdAt: '', updatedAt: '' },
];

const formatted = formatTodoList(todos);

expect(formatted).toContain('2/2 done');
});
});
});
Loading