super-productivity/packages/plugin-dev/clickup-issue-provider/src/clickup-api.test.ts
Johannes Millan 76239d3411 refactor(issue): migrate ClickUp issue provider from built-in to plugin
Migrate the ClickUp issue provider from a tightly-coupled Angular
service to the external plugin architecture, following the pattern
established by the GitHub provider migration.

- Delete all built-in ClickUp files (api service, models, components,
  tests, constants) from src/app/features/issue/providers/clickup/
- Create new plugin in packages/plugin-dev/clickup-issue-provider/
  with pure-function API layer, plugin registration, i18n, and tests
- Add migration in issue-provider.reducer.ts to convert old ClickUp
  provider config to plugin shape (preserves legacy fields for rollback)
- Move CLICKUP from BuiltInIssueProviderKey to MigratedIssueProviderKey
- Register clickup-issue-provider as bundled plugin
- Fix isDone detection: use status.type instead of status.status so
  custom closed statuses like "Shipped" are correctly detected
- Show disabled plugin issue providers in setup overview for
  discoverability — clicking auto-enables and opens config dialog
2026-03-19 15:03:45 +01:00

424 lines
12 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import type { PluginHttp } from '@super-productivity/plugin-api';
import {
parseTeamIds,
mapSearchResult,
getWithRetry,
searchTasksInTeam,
getTeamIds,
mapTaskToPluginIssue,
ClickUpTask,
ClickUpTaskReduced,
ClickUpStatus,
} from './clickup-api';
const mockStatus = (overrides: Partial<ClickUpStatus> = {}): ClickUpStatus => ({
status: 'open',
type: 'open',
orderindex: 0,
color: '#000',
...overrides,
});
const mockTaskReduced = (
overrides: Partial<ClickUpTaskReduced> = {},
): ClickUpTaskReduced => ({
id: 'abc123',
name: 'Test Task',
status: mockStatus(),
date_updated: '1600000000000',
url: 'https://app.clickup.com/t/abc123',
custom_id: null,
...overrides,
});
const mockTask = (overrides: Partial<ClickUpTask> = {}): ClickUpTask => ({
...mockTaskReduced(),
date_created: '1500000000000',
date_closed: null,
creator: {
id: 1,
username: 'creator',
color: null,
email: 'creator@example.com',
profilePicture: null,
},
assignees: [],
tags: [],
priority: null,
...overrides,
});
const mockHttp = (responses: Record<string, unknown> = {}): PluginHttp => ({
get: vi.fn(async (url: string) => {
for (const [pattern, response] of Object.entries(responses)) {
if (url.includes(pattern)) return response;
}
throw new Error(`No mock for ${url}`);
}),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
});
// --- parseTeamIds ---
describe('parseTeamIds', () => {
it('should return empty array when teamIds is undefined', () => {
expect(parseTeamIds({ apiKey: 'key' })).toEqual([]);
});
it('should return empty array when teamIds is empty string', () => {
expect(parseTeamIds({ apiKey: 'key', teamIds: '' })).toEqual([]);
});
it('should parse comma-separated team IDs', () => {
expect(parseTeamIds({ apiKey: 'key', teamIds: 'T1,T2,T3' })).toEqual([
'T1',
'T2',
'T3',
]);
});
it('should trim whitespace', () => {
expect(parseTeamIds({ apiKey: 'key', teamIds: ' T1 , T2 ' })).toEqual(['T1', 'T2']);
});
it('should filter empty segments', () => {
expect(parseTeamIds({ apiKey: 'key', teamIds: 'T1,,T2,' })).toEqual(['T1', 'T2']);
});
});
// --- mapSearchResult ---
describe('mapSearchResult', () => {
it('should map task to search result with status type', () => {
const task = mockTaskReduced({ id: '42', name: 'My Task' });
const result = mapSearchResult(task);
expect(result).toEqual({
id: '42',
title: 'My Task',
url: 'https://app.clickup.com/t/abc123',
status: 'open',
});
});
it('should use status.type for closed detection, not status.status', () => {
const task = mockTaskReduced({
id: '99',
name: 'Shipped Task',
status: mockStatus({ status: 'Shipped', type: 'closed' }),
});
const result = mapSearchResult(task);
expect(result.status).toBe('closed');
});
});
// --- mapTaskToPluginIssue ---
describe('mapTaskToPluginIssue', () => {
it('should map task fields correctly', () => {
const task = mockTask({
id: 'x1',
name: 'Important Task',
markdown_description: '# Hello',
date_updated: '1700000000000',
assignees: [
{
id: 1,
username: 'alice',
color: null,
email: 'alice@example.com',
profilePicture: null,
},
{
id: 2,
username: null,
color: null,
email: 'bob@example.com',
profilePicture: null,
},
],
tags: [{ name: 'bug' }, { name: 'urgent' }],
priority: { id: '1', priority: 'high', color: '#f00' },
status: mockStatus({ status: 'in progress', type: 'custom' }),
});
const result = mapTaskToPluginIssue(task);
expect(result.id).toBe('x1');
expect(result.title).toBe('Important Task');
expect(result.body).toBe('# Hello');
// state maps to status.type for correct isDone detection
expect(result.state).toBe('custom');
expect(result.lastUpdated).toBe(1700000000000);
expect(result.assignee).toBe('alice, bob@example.com');
expect(result.labels).toEqual(['bug', 'urgent']);
expect(result['statusName']).toBe('in progress');
expect(result['statusType']).toBe('custom');
expect(result['priority']).toBe('high');
expect(result['description']).toBe('# Hello');
});
it('should fall back to description when markdown_description is null', () => {
const task = mockTask({
markdown_description: null,
description: '<p>HTML desc</p>',
});
const result = mapTaskToPluginIssue(task);
expect(result.body).toBe('<p>HTML desc</p>');
});
it('should handle empty assignees and tags', () => {
const task = mockTask({ assignees: [], tags: [] });
const result = mapTaskToPluginIssue(task);
expect(result.assignee).toBe('');
expect(result.labels).toEqual([]);
});
it('should use status.type=closed for custom closed statuses like "Shipped"', () => {
const task = mockTask({
status: mockStatus({ status: 'Shipped', type: 'closed' }),
});
const result = mapTaskToPluginIssue(task);
// state should be 'closed' (from type), not 'Shipped' (from status name)
expect(result.state).toBe('closed');
// statusName should preserve the human-readable name for display
expect(result['statusName']).toBe('Shipped');
});
it('should map state to open for open status type', () => {
const task = mockTask({
status: mockStatus({ status: 'To Do', type: 'open' }),
});
const result = mapTaskToPluginIssue(task);
expect(result.state).toBe('open');
});
it('should handle undefined custom_id without error', () => {
const task = mockTask({ custom_id: undefined });
const result = mapTaskToPluginIssue(task);
expect(result.title).toBe('Test Task');
});
it('should use "Unknown" for assignees without username or email', () => {
const task = mockTask({
assignees: [
{
id: 1,
username: null,
color: null,
email: '' as string,
profilePicture: null,
},
],
});
const result = mapTaskToPluginIssue(task);
expect(result.assignee).toBe('Unknown');
});
});
// --- getWithRetry ---
describe('getWithRetry', () => {
it('should return data on first success', async () => {
const http = mockHttp({ '/task/1': { id: '1', name: 'Task' } });
const result = await getWithRetry(http, 'https://api.clickup.com/api/v2/task/1');
expect(result).toEqual({ id: '1', name: 'Task' });
expect(http.get).toHaveBeenCalledTimes(1);
});
it('should retry on 429 and succeed', async () => {
vi.useFakeTimers();
let callCount = 0;
const http: PluginHttp = {
get: vi.fn(async () => {
callCount++;
if (callCount === 1) throw { status: 429 };
return { ok: true };
}),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
const promise = getWithRetry(http, 'https://example.com/api');
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toEqual({ ok: true });
expect(http.get).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('should throw non-429 errors immediately', async () => {
const http: PluginHttp = {
get: vi.fn(async () => {
throw { status: 500, message: 'Server Error' };
}),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
await expect(getWithRetry(http, 'https://example.com/api')).rejects.toEqual({
status: 500,
message: 'Server Error',
});
expect(http.get).toHaveBeenCalledTimes(1);
});
it('should throw after max retries on persistent 429', async () => {
vi.useFakeTimers();
let callCount = 0;
const http: PluginHttp = {
get: vi.fn(async () => {
callCount++;
throw { status: 429 };
}),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
const promise = getWithRetry(http, 'https://example.com/api').catch((e) => e);
// Advance through all retry delays
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(5000);
}
const result = await promise;
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('ClickUp: Max retries exceeded');
expect(callCount).toBe(3);
vi.useRealTimers();
});
});
// --- searchTasksInTeam ---
describe('searchTasksInTeam', () => {
const cfg = { apiKey: 'key', userId: '42' };
it('should fetch tasks and pass userId as assignee filter', async () => {
const tasks = [mockTask({ id: '1', name: 'Task A' })];
const http = mockHttp({ '/team/T1/task': { tasks } });
const result = await searchTasksInTeam('', 'T1', cfg, http);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
expect(http.get).toHaveBeenCalledWith(
expect.stringContaining('/team/T1/task'),
expect.objectContaining({
params: expect.objectContaining({ 'assignees[]': '42' }),
}),
);
});
it('should filter by search term (name)', async () => {
const tasks = [
mockTask({ id: '1', name: 'Fix bug' }),
mockTask({ id: '2', name: 'Add feature' }),
];
const http = mockHttp({ '/team/T1/task': { tasks } });
const result = await searchTasksInTeam('bug', 'T1', cfg, http);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Fix bug');
});
it('should filter by search term (custom_id)', async () => {
const tasks = [
mockTask({ id: '1', name: 'Task A', custom_id: 'PROJ-123' }),
mockTask({ id: '2', name: 'Task B', custom_id: null }),
];
const http = mockHttp({ '/team/T1/task': { tasks } });
const result = await searchTasksInTeam('PROJ-123', 'T1', cfg, http);
expect(result).toHaveLength(1);
expect(result[0].custom_id).toBe('PROJ-123');
});
it('should filter by search term (task id)', async () => {
const tasks = [
mockTask({ id: 'abc123', name: 'Task A' }),
mockTask({ id: 'xyz789', name: 'Task B' }),
];
const http = mockHttp({ '/team/T1/task': { tasks } });
const result = await searchTasksInTeam('abc123', 'T1', cfg, http);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('abc123');
});
it('should return all tasks when search term is empty', async () => {
const tasks = [
mockTask({ id: '1', name: 'A' }),
mockTask({ id: '2', name: 'B' }),
];
const http = mockHttp({ '/team/T1/task': { tasks } });
const result = await searchTasksInTeam('', 'T1', cfg, http);
expect(result).toHaveLength(2);
});
it('should propagate errors so Promise.allSettled can handle per-team failures', async () => {
const http: PluginHttp = {
get: vi.fn(async () => {
throw { status: 500, message: 'Server Error' };
}),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
await expect(searchTasksInTeam('test', 'T1', cfg, http)).rejects.toEqual({
status: 500,
message: 'Server Error',
});
});
it('should not pass assignee filter when userId is absent', async () => {
const http = mockHttp({ '/team/T1/task': { tasks: [] } });
await searchTasksInTeam('', 'T1', { apiKey: 'key' }, http);
expect(http.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
params: expect.not.objectContaining({ 'assignees[]': expect.anything() }),
}),
);
});
});
// --- getTeamIds ---
describe('getTeamIds', () => {
it('should return configured team IDs without API call', async () => {
const http = mockHttp();
const result = await getTeamIds({ apiKey: 'key', teamIds: 'T1,T2' }, http);
expect(result).toEqual(['T1', 'T2']);
expect(http.get).not.toHaveBeenCalled();
});
it('should fetch teams from API when none configured', async () => {
const http = mockHttp({
'/team': { teams: [{ id: 'A1', name: 'Alpha' }, { id: 'B2', name: 'Beta' }] },
});
const result = await getTeamIds({ apiKey: 'key' }, http);
expect(result).toEqual(['A1', 'B2']);
expect(http.get).toHaveBeenCalledTimes(1);
});
it('should return empty array when API returns no teams', async () => {
const http = mockHttp({ '/team': { teams: [] } });
const result = await getTeamIds({ apiKey: 'key' }, http);
expect(result).toEqual([]);
});
});