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
183 changes: 183 additions & 0 deletions tests/unit/pm/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it, vi } from 'vitest';
import { getPMProvider, getPMProviderOrNull, withPMProvider } from '../../../src/pm/context.js';
import type { PMProvider } from '../../../src/pm/types.js';

describe('pm/context', () => {
// Create a minimal mock provider for testing
const createMockProvider = (): PMProvider => ({
type: 'trello',
addLabel: vi.fn(),
removeLabel: vi.fn(),
moveWorkItem: vi.fn(),
addComment: vi.fn(),
getWorkItem: vi.fn(),
getWorkItemComments: vi.fn(),
updateWorkItem: vi.fn(),
createWorkItem: vi.fn(),
listWorkItems: vi.fn(),
getChecklists: vi.fn(),
createChecklist: vi.fn(),
addChecklistItem: vi.fn(),
updateChecklistItem: vi.fn(),
getAttachments: vi.fn(),
addAttachment: vi.fn(),
addAttachmentFile: vi.fn(),
getCustomFieldNumber: vi.fn(),
updateCustomFieldNumber: vi.fn(),
getWorkItemUrl: vi.fn(),
getAuthenticatedUser: vi.fn(),
});

describe('withPMProvider', () => {
it('makes provider available within the async context', async () => {
const provider = createMockProvider();

await withPMProvider(provider, async () => {
const retrieved = getPMProvider();
expect(retrieved).toBe(provider);
});
});

it('isolates provider scope between concurrent calls', async () => {
const provider1 = createMockProvider();
const provider2 = createMockProvider();

// Run two contexts concurrently
const [result1, result2] = await Promise.all([
withPMProvider(provider1, async () => {
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 10));
return getPMProvider();
}),
withPMProvider(provider2, async () => {
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 5));
return getPMProvider();
}),
]);

// Each context should see its own provider
expect(result1).toBe(provider1);
expect(result2).toBe(provider2);
});

it('removes provider from context after callback completes', async () => {
const provider = createMockProvider();

await withPMProvider(provider, async () => {
expect(getPMProvider()).toBe(provider);
});

// Provider should not be available outside the context
expect(() => getPMProvider()).toThrow();
});

it('propagates errors from callback', async () => {
const provider = createMockProvider();
const error = new Error('Callback failed');

await expect(
withPMProvider(provider, async () => {
throw error;
}),
).rejects.toThrow('Callback failed');
});

it('returns the callback result', async () => {
const provider = createMockProvider();

const result = await withPMProvider(provider, async () => {
return { success: true, data: 'test' };
});

expect(result).toEqual({ success: true, data: 'test' });
});
});

describe('getPMProvider', () => {
it('returns provider when in context', async () => {
const provider = createMockProvider();

await withPMProvider(provider, async () => {
const retrieved = getPMProvider();
expect(retrieved).toBe(provider);
});
});

it('throws error when not in context', () => {
expect(() => getPMProvider()).toThrow(
'No PMProvider in scope. Wrap the call with withPMProvider() or ensure the webhook handler has established a PM context.',
);
});

it('throws error with helpful message', () => {
try {
getPMProvider();
// Should not reach here
expect(true).toBe(false);
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('withPMProvider()');
expect((error as Error).message).toContain('webhook handler');
}
});
});

describe('getPMProviderOrNull', () => {
it('returns provider when in context', async () => {
const provider = createMockProvider();

await withPMProvider(provider, async () => {
const retrieved = getPMProviderOrNull();
expect(retrieved).toBe(provider);
});
});

it('returns null when not in context', () => {
const result = getPMProviderOrNull();
expect(result).toBeNull();
});

it('does not throw error when not in context', () => {
expect(() => getPMProviderOrNull()).not.toThrow();
});
});

describe('nested contexts', () => {
it('inner context overrides outer context', async () => {
const outerProvider = createMockProvider();
const innerProvider = createMockProvider();

await withPMProvider(outerProvider, async () => {
expect(getPMProvider()).toBe(outerProvider);

await withPMProvider(innerProvider, async () => {
expect(getPMProvider()).toBe(innerProvider);
});

// After inner context, outer provider is restored
expect(getPMProvider()).toBe(outerProvider);
});
});

it('handles errors in nested contexts without affecting outer context', async () => {
const outerProvider = createMockProvider();
const innerProvider = createMockProvider();

await withPMProvider(outerProvider, async () => {
expect(getPMProvider()).toBe(outerProvider);

try {
await withPMProvider(innerProvider, async () => {
throw new Error('Inner error');
});
} catch (error) {
// Expected error
}

// Outer context should still be valid
expect(getPMProvider()).toBe(outerProvider);
});
});
});
});
128 changes: 128 additions & 0 deletions tests/unit/pm/factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest';
import { createPMProvider } from '../../../src/pm/factory.js';
import type { ProjectConfig } from '../../../src/types/index.js';

// Mock the adapters
vi.mock('../../../src/pm/trello/adapter.js', () => ({
TrelloPMProvider: vi.fn().mockImplementation(() => ({
type: 'trello',
addLabel: vi.fn(),
removeLabel: vi.fn(),
})),
}));

vi.mock('../../../src/pm/jira/adapter.js', () => ({
JiraPMProvider: vi.fn().mockImplementation((config) => ({
type: 'jira',
config,
addLabel: vi.fn(),
removeLabel: vi.fn(),
})),
}));

import { JiraPMProvider } from '../../../src/pm/jira/adapter.js';
import { TrelloPMProvider } from '../../../src/pm/trello/adapter.js';

describe('pm/factory', () => {
describe('createPMProvider', () => {
it('returns TrelloPMProvider when pm.type is trello', () => {
const project: ProjectConfig = {
id: 'proj1',
orgId: 'org1',
name: 'Trello Project',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
pm: { type: 'trello' },
trello: {
boardId: 'board123',
labels: { processing: 'label-id' },
lists: { todo: 'list-id' },
},
};

const provider = createPMProvider(project);

expect(TrelloPMProvider).toHaveBeenCalled();
expect(provider.type).toBe('trello');
});

it('returns TrelloPMProvider when pm.type is undefined (defaults to trello)', () => {
const project: ProjectConfig = {
id: 'proj1',
orgId: 'org1',
name: 'Default Project',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
trello: {
boardId: 'board123',
labels: { processing: 'label-id' },
lists: { todo: 'list-id' },
},
};

const provider = createPMProvider(project);

expect(TrelloPMProvider).toHaveBeenCalled();
expect(provider.type).toBe('trello');
});

it('returns JiraPMProvider when pm.type is jira', () => {
const project: ProjectConfig = {
id: 'proj1',
orgId: 'org1',
name: 'JIRA Project',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
pm: { type: 'jira' },
jira: {
projectKey: 'PROJ',
statuses: {
inProgress: 'In Progress',
inReview: 'Code Review',
done: 'Done',
merged: 'Merged',
},
},
};

const provider = createPMProvider(project);

expect(JiraPMProvider).toHaveBeenCalledWith(project.jira);
expect(provider.type).toBe('jira');
});

it('throws error when pm.type is jira but jira config is missing', () => {
const project: ProjectConfig = {
id: 'proj1',
orgId: 'org1',
name: 'Invalid JIRA Project',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
pm: { type: 'jira' },
// No jira config
};

expect(() => createPMProvider(project)).toThrow(
"Project 'proj1' has pm.type=jira but no jira config",
);
});

it('throws error for unknown pm.type', () => {
const project = {
id: 'proj1',
orgId: 'org1',
name: 'Unknown PM Project',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
pm: { type: 'unknown' },
} as ProjectConfig;

expect(() => createPMProvider(project)).toThrow('Unknown PM type: unknown');
});
});
});
Loading