feat(ui): Phase 8 UI/UX optimization and system documentation update

## Sidebar Enhancement
- Change tabs to icon + small label layout for better space utilization
- Add Teams tab with team collaboration entry point

## Settings Page Improvements
- Connect theme toggle to gatewayStore.saveQuickConfig for persistence
- Remove OpenFang backend download section, simplify UI
- Add time range filter to UsageStats (7d/30d/all)
- Add stat cards with icons (sessions, messages, input/output tokens)
- Add token usage overview bar chart
- Add 8 ZCLAW system skill definitions with categories

## Bug Fixes
- Fix ChannelList duplicate content with deduplication logic
- Integrate CreateTriggerModal in TriggersPanel
- Add independent SecurityStatusPanel with 12 default enabled layers
- Change workflow view to use SchedulerPanel as unified entry

## New Components
- CreateTriggerModal: Event trigger creation modal
- HandApprovalModal: Hand approval workflow dialog
- HandParamsForm: Enhanced Hand parameter form
- SecurityLayersPanel: 16-layer security status display

## Architecture
- Add TOML config parsing support (toml-utils.ts, config-parser.ts)
- Add request timeout and retry mechanism (request-helper.ts)
- Add secure token storage (secure-storage.ts, secure_storage.rs)

## Tests
- Add unit tests for config-parser, toml-utils, request-helper
- Add team-client and teamStore tests

## Documentation
- Update SYSTEM_ANALYSIS.md with Phase 8 completion
- UI completion: 100% (30/30 components)
- API coverage: 93% (63/68 endpoints)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 14:12:11 +08:00
parent bf79c06d4a
commit 3e81bd3e50
30 changed files with 8875 additions and 284 deletions

View File

@@ -0,0 +1,417 @@
/**
* Request Helper Tests
*
* Tests for request timeout, automatic retry with exponential backoff,
* and request cancellation support.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
requestWithRetry,
requestJson,
RequestManager,
RequestError,
RequestCancelledError,
DEFAULT_REQUEST_CONFIG,
get,
post,
put,
del,
patch,
} from '../../src/lib/request-helper';
// Mock fetch globally
const mockFetch = vi.fn();
const originalFetch = global.fetch;
describe('request-helper', () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
});
afterEach(() => {
global.fetch = originalFetch;
mockFetch.mockReset();
});
describe('DEFAULT_REQUEST_CONFIG', () => {
it('should have correct default values', () => {
expect(DEFAULT_REQUEST_CONFIG.timeout).toBe(30000);
expect(DEFAULT_REQUEST_CONFIG.retries).toBe(3);
expect(DEFAULT_REQUEST_CONFIG.retryDelay).toBe(1000);
expect(DEFAULT_REQUEST_CONFIG.retryOn).toEqual([408, 429, 500, 502, 503, 504]);
expect(DEFAULT_REQUEST_CONFIG.maxRetryDelay).toBe(30000);
});
});
describe('RequestError', () => {
it('should create error with all properties', () => {
const error = new RequestError('Test error', 500, 'Server Error', 'response body');
expect(error.message).toBe('Test error');
expect(error.status).toBe(500);
expect(error.statusText).toBe('Server Error');
expect(error.responseBody).toBe('response body');
});
it('should detect retryable status codes', () => {
const error = new RequestError('Test', 500, 'Error');
expect(error.isRetryable()).toBe(true);
expect(error.isRetryable([500, 502])).toBe(true);
expect(error.isRetryable([401])).toBe(false);
});
it('should detect timeout errors', () => {
const timeoutError = new RequestError('timeout', 408, 'Request Timeout');
expect(timeoutError.isTimeout()).toBe(true);
const const otherError = new RequestError('other', 500, 'Error');
expect(otherError.isTimeout()).toBe(false);
});
it('should detect auth errors', () => {
const authError = new RequestError('Unauthorized', 401, 'Unauthorized');
expect(authError.isAuthError()).toBe(true);
const forbiddenError = new RequestError('Forbidden', 403, 'Forbidden');
expect(forbiddenError.isAuthError()).toBe(true);
const otherError = new RequestError('Server Error', 500, 'Error');
expect(otherError.isAuthError()).toBe(false);
});
});
describe('RequestCancelledError', () => {
it('should create cancellation error', () => {
const error = new RequestCancelledError('Request was cancelled');
expect(error.message).toBe('Request was cancelled');
expect(error.name).toBe('RequestCancelledError');
});
it('should use default message', () => {
const error = new RequestCancelledError();
expect(error.message).toBe('Request cancelled');
});
});
describe('requestWithRetry', () => {
it('should return response on success', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ data: 'success' }),
});
const response = await requestWithRetry('https://api.example.com/test');
const data = await response.json();
expect(data).toEqual({ data: 'success' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should retry on retryable status codes', async () => {
// First call fails with 503
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
text: async () => 'Error body',
});
// Second call succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ data: 'retried success' }),
});
const response = await requestWithRetry('https://api.example.com/test', {}, {
retries: 2,
retryDelay: 10, // Small delay for testing
});
const data = await response.json();
expect(data).toEqual({ data: 'retried success' });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should not retry on non-retryable status codes', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => '{"error": "Unauthorized"}',
});
await expect(requestWithRetry('https://api.example.com/test')).rejects(RequestError);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should throw after all retries exhausted', async () => {
// All calls fail with 503
mockFetch.mockResolvedValue({
ok: false,
status: 503,
statusText: 'Service Unavailable',
text: async () => 'Error',
});
await expect(
requestWithRetry('https://api.example.com/test', {}, { retries: 2, retryDelay: 10 })
).rejects(RequestError);
});
it('should handle timeout correctly', async () => {
// Create a promise that never resolves to simulate timeout
mockFetch.mockImplementationOnce(() => new Promise(() => {}));
await expect(
requestWithRetry('https://api.example.com/test', {}, { timeout: 50, retries: 1 })
).rejects(RequestError);
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(requestWithRetry('https://api.example.com/test')).rejects(RequestError);
});
it('should pass through request options', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await requestWithRetry('https://api.example.com/test', {
method: 'POST',
headers: { 'X-Custom': 'value' },
body: JSON.stringify({ test: 'data' }),
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-Custom': 'value',
}),
body: '{"test":"data"}',
signal: expect.any(AbortSignal),
})
);
});
});
describe('requestJson', () => {
it('should parse JSON response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ message: 'hello' }),
});
const result = await requestJson<{ message: string }>('https://api.example.com/test');
expect(result).toEqual({ message: 'hello' });
});
it('should throw on invalid JSON', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
text: async () => 'not valid json',
});
await expect(requestJson('https://api.example.com/test')).rejects(RequestError);
});
});
describe('RequestManager', () => {
let manager: RequestManager;
beforeEach(() => {
manager = new RequestManager();
});
it('should track active requests', () => {
const controller = manager.createRequest('test-1');
expect(manager.isRequestActive('test-1')).toBe(true);
expect(manager.activeCount).toBe(1);
});
it('should cancel request', () => {
const controller = manager.createRequest('test-1');
expect(manager.cancelRequest('test-1')).toBe(true);
expect(manager.isRequestActive('test-1')).toBe(false);
});
it('should return false when cancelling non-existent request', () => {
expect(manager.cancelRequest('non-existent')).toBe(false);
});
it('should replace existing request with same ID', () => {
const controller1 = manager.createRequest('test-1');
const controller2 = manager.createRequest('test-1');
expect(controller1.signal.aborted).toBe(true);
expect(manager.isRequestActive('test-1')).toBe(true);
});
it('should get all active request IDs', () => {
manager.createRequest('test-1');
manager.createRequest('test-2');
manager.createRequest('test-3');
const ids = manager.getActiveRequestIds();
expect(ids).toHaveLength(3);
expect(ids).toContain('test-1');
expect(ids).toContain('test-2');
expect(ids).toContain('test-3');
});
it('should cancel all requests', () => {
manager.createRequest('test-1');
manager.createRequest('test-2');
manager.createRequest('test-3');
manager.cancelAll();
expect(manager.activeCount).toBe(0);
});
it('should execute managed request successfully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true }),
});
const response = await manager.executeManaged('test-1', 'https://api.example.com/test');
const data = await response.json();
expect(data).toEqual({ success: true });
expect(manager.isRequestActive('test-1')).toBe(false);
});
it('should clean up on error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Test error'));
await expect(
manager.executeManaged('test-1', 'https://api.example.com/test')
).rejects();
expect(manager.isRequestActive('test-1')).toBe(false);
});
it('should execute managed JSON request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ data: 'test' }),
});
const result = await manager.executeManagedJson<{ data: string }>(
'test-1',
'https://api.example.com/test'
);
expect(result).toEqual({ data: 'test' });
});
});
describe('Convenience functions', () => {
it('should make GET request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await get('https://api.example.com/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({ method: 'GET' })
);
});
it('should make POST request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await post('https://api.example.com/test', { data: 'test' });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'POST',
body: '{"data":"test"}',
})
);
});
it('should make PUT request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await put('https://api.example.com/test', { data: 'test' });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'PUT',
body: '{"data":"test"}',
})
);
});
it('should make DELETE request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await del('https://api.example.com/test');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({ method: 'DELETE' })
);
});
it('should make PATCH request', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({}),
});
await patch('https://api.example.com/test', { data: 'test' });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test',
expect.objectContaining({
method: 'PATCH',
body: '{"data":"test"}',
})
);
});
});
});

View File

@@ -0,0 +1,564 @@
/**
* Team Client Tests
*
* Tests for OpenFang Team API client.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
TeamAPIError,
listTeams,
getTeam
createTeam
updateTeam
deleteTeam
addTeamMember
removeTeamMember
updateMemberRole
addTeamTask
updateTaskStatus
assignTask
submitDeliverable
startDevQALoop
submitReview
updateLoopState
getTeamMetrics
getTeamEvents
subscribeToTeamEvents
teamClient,
} from '../../src/lib/team-client';
import type { Team, TeamMember, TeamTask, TeamMemberRole, DevQALoop } from '../../src/types/team';
// Mock fetch globally
const mockFetch = vi.fn();
const originalFetch = global.fetch;
describe('team-client', () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
});
afterEach(() => {
global.fetch = originalFetch;
mockFetch.mockReset();
});
describe('TeamAPIError', () => {
it('should create error with all properties', () => {
const error = new TeamAPIError('Test error', 404, '/teams/test', { detail: 'test detail' });
expect(error.message).toBe('Test error');
expect(error.statusCode).toBe(404);
expect(error.endpoint).toBe('/teams/test');
expect(error.details).toEqual({ detail: 'test detail' });
});
});
describe('listTeams', () => {
it('should fetch teams list', async () => {
const mockTeams: Team[] = [
{
id: 'team-1',
name: 'Test Team',
members: [],
tasks: [],
pattern: 'sequential',
activeLoops: [],
status: 'active',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
];
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ teams: mockTeams, total: 1 }),
});
const result = await listTeams();
expect(mockFetch).toHaveBeenCalledWith('/api/teams');
expect(result).toEqual({ teams: mockTeams, total: 1 });
});
});
describe('getTeam', () => {
it('should fetch single team', async () => {
const mockTeam = {
team: {
id: 'team-1',
name: 'Test Team',
members: [],
tasks: [],
pattern: 'sequential' as const,
activeLoops: [],
status: 'active',
createdAt: '2024-01-01T00:00:00Z',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockTeam,
});
const result = await getTeam('team-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1');
expect(result).toEqual(mockTeam);
});
});
describe('createTeam', () => {
it('should create a new team', async () => {
const createRequest = {
name: 'New Team',
description: 'Test description',
memberAgents: [],
pattern: 'parallel' as const,
};
const mockResponse = {
team: {
id: 'new-team-id',
name: 'New Team',
description: 'Test description',
members: [],
tasks: [],
pattern: 'parallel',
activeLoops: [],
status: 'active',
createdAt: '2024-01-01T00:00:00Z',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
json: async () => mockResponse,
});
const result = await createTeam(createRequest);
expect(mockFetch).toHaveBeenCalledWith('/api/teams', expect.objectContaining({
method: 'POST',
}));
expect(result).toEqual(mockResponse);
});
});
describe('updateTeam', () => {
it('should update a team', async () => {
const updateData = { name: 'Updated Team' };
const mockResponse = {
team: {
id: 'team-1',
name: 'Updated Team',
description: 'Test',
members: [],
tasks: [],
pattern: 'sequential' as const,
activeLoops: [],
status: 'active',
createdAt: '2024-01-01T00:00:00Z',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await updateTeam('team-1', updateData);
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1', expect.objectContaining({
method: 'PUT',
}));
expect(result).toEqual(mockResponse);
});
});
describe('deleteTeam', () => {
it('should delete a team', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true }),
});
const result = await deleteTeam('team-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1', expect.objectContaining({
method: 'DELETE',
}));
expect(result).toEqual({ success: true });
});
});
describe('addTeamMember', () => {
it('should add a member to team', async () => {
const mockResponse = {
member: {
id: 'member-1',
agentId: 'agent-1',
name: 'Agent 1',
role: 'developer',
skills: [],
workload: 0,
status: 'idle',
maxConcurrentTasks: 2,
currentTasks: [],
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await addTeamMember('team-1', 'agent-1', 'developer');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members');
expect(result).toEqual(mockResponse);
});
});
describe('removeTeamMember', () => {
it('should remove a member from team', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true }),
});
const result = await removeTeamMember('team-1', 'member-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members/member-1');
expect(result).toEqual({ success: true });
});
});
describe('updateMemberRole', () => {
it('should update member role', async () => {
const mockResponse = {
member: {
id: 'member-1',
agentId: 'agent-1',
name: 'Agent 1',
role: 'reviewer',
skills: [],
workload: 0,
status: 'idle',
maxConcurrentTasks: 2,
currentTasks: [],
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await updateMemberRole('team-1', 'member-1', 'reviewer');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members/member-1');
expect(result).toEqual(mockResponse);
});
});
describe('addTeamTask', () => {
it('should add a task to team', async () => {
const taskRequest = {
teamId: 'team-1',
title: 'Test Task',
description: 'Test task description',
priority: 'high',
type: 'implementation',
};
const mockResponse = {
task: {
id: 'task-1',
title: 'Test Task',
description: 'Test task description',
status: 'pending',
priority: 'high',
dependencies: [],
type: 'implementation',
createdAt: '2024-01-01T00:00:00Z',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
json: async () => mockResponse,
});
const result = await addTeamTask(taskRequest);
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks');
expect(result).toEqual(mockResponse);
});
});
describe('updateTaskStatus', () => {
it('should update task status', async () => {
const mockResponse = {
task: {
id: 'task-1',
title: 'Test Task',
status: 'in_progress',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await updateTaskStatus('team-1', 'task-1', 'in_progress');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1');
expect(result).toEqual(mockResponse);
});
});
describe('assignTask', () => {
it('should assign task to member', async () => {
const mockResponse = {
task: {
id: 'task-1',
title: 'Test Task',
assigneeId: 'member-1',
status: 'assigned',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await assignTask('team-1', 'task-1', 'member-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1/assign');
expect(result).toEqual(mockResponse);
});
});
describe('submitDeliverable', () => {
it('should submit deliverable for task', async () => {
const deliverable = {
type: 'code',
description: 'Test deliverable',
files: ['/test/file.ts'],
};
const mockResponse = {
task: {
id: 'task-1',
title: 'Test Task',
deliverable,
status: 'review',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await submitDeliverable('team-1', 'task-1', deliverable);
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1/deliverable');
expect(result).toEqual(mockResponse);
});
});
describe('startDevQALoop', () => {
it('should start a DevQA loop', async () => {
const mockResponse = {
loop: {
id: 'loop-1',
developerId: 'dev-1',
reviewerId: 'reviewer-1',
taskId: 'task-1',
state: 'developing',
iterationCount: 0,
maxIterations: 3,
feedbackHistory: [],
startedAt: '2024-01-01T00:00:00Z',
lastUpdatedAt: '2024-01-01T00:00:00Z',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
json: async () => mockResponse,
});
const result = await startDevQALoop('team-1', 'task-1', 'dev-1', 'reviewer-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops');
expect(result).toEqual(mockResponse);
});
});
describe('submitReview', () => {
it('should submit a review', async () => {
const feedback = {
verdict: 'approved',
comments: ['LGTM!'],
issues: [],
};
const mockResponse = {
loop: {
id: 'loop-1',
state: 'approved',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await submitReview('team-1', 'loop-1', feedback);
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops/loop-1/review');
expect(result).toEqual(mockResponse);
});
});
describe('updateLoopState', () => {
it('should update loop state', async () => {
const mockResponse = {
loop: {
id: 'loop-1',
state: 'reviewing',
},
success: true,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockResponse,
});
const result = await updateLoopState('team-1', 'loop-1', 'reviewing');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops/loop-1');
expect(result).toEqual(mockResponse);
});
});
describe('getTeamMetrics', () => {
it('should get team metrics', async () => {
const mockMetrics = {
tasksCompleted: 10,
avgCompletionTime: 1000,
passRate: 85,
avgIterations: 1.5,
escalations: 0,
efficiency: 80,
};
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => mockMetrics,
});
const result = await getTeamMetrics('team-1');
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/metrics');
expect(result).toEqual(mockMetrics);
});
});
describe('getTeamEvents', () => {
it('should get team events', async () => {
const mockEvents = [
{
type: 'task_assigned',
teamId: 'team-1',
sourceAgentId: 'agent-1',
payload: {},
timestamp: '2024-01-01T00:00:00Z',
},
];
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ events: mockEvents, total: 1 }),
});
const result = await getTeamEvents('team-1', 10);
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/events?limit=10');
expect(result).toEqual({ events: mockEvents, total: 1 });
});
});
describe('subscribeToTeamEvents', () => {
it('should subscribe to team events', () => {
const mockWs = {
readyState: WebSocket.OPEN,
send: vi.fn(),
addEventListener: vi.fn((event, handler) => {
handler('message');
return mockWs;
}),
removeEventListener: vi.fn(),
};
const callback = vi.fn();
const unsubscribe = subscribeToTeamEvents('team-1', callback, mockWs as unknown as WebSocket);
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({
type: 'subscribe',
topic: 'team:team-1',
}));
unsubscribe();
expect(mockWs.removeEventListenerEventListener).toHaveBeenCalled();
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({
type: 'unsubscribe',
topic: 'team:team-1',
}));
});
});
describe('teamClient', () => {
it('should export all API functions', () => {
expect(teamClient.listTeams).toBe(listTeams);
expect(teamClient.getTeam).toBe(getTeam);
expect(teamClient.createTeam).toBe(createTeam);
expect(teamClient.updateTeam).toBe(updateTeam);
expect(teamClient.deleteTeam).toBe(deleteTeam);
expect(teamClient.addTeamMember).toBe(addTeamMember);
expect(teamClient.removeTeamMember).toBe(removeTeamMember);
expect(teamClient.updateMemberRole).toBe(updateMemberRole);
expect(teamClient.addTeamTask).toBe(addTeamTask);
expect(teamClient.updateTaskStatus).toBe(updateTaskStatus);
expect(teamClient.assignTask).toBe(assignTask);
expect(teamClient.submitDeliverable).toBe(submitDeliverable);
expect(teamClient.startDevQALoop).toBe(startDevQALoop);
expect(teamClient.submitReview).toBe(submitReview);
expect(teamClient.updateLoopState).toBe(updateLoopState);
expect(teamClient.getTeamMetrics).toBe(getTeamMetrics);
expect(teamClient.getTeamEvents).toBe(getTeamEvents);
expect(teamClient.subscribeToTeamEvents).toBe(subscribeToTeamEvents);
});
});
});