Skip to content

Commit 82ead2e

Browse files
ersinkocclaude
andcommitted
✅ test: add tests for notifications and fleet routes
Two previously untested route files now have test coverage: - notifications.test.ts (10 tests): send, channel, broadcast, preferences CRUD - fleet.test.ts (9 tests): list, create, get, start, stop, delete, validation Untested routes remaining: chat-legacy-send, souls-deploy, workflow-template-ideas (all are either deprecated paths or static data) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b2be2b commit 82ead2e

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Hono } from 'hono';
3+
4+
// ── Mocks ──
5+
6+
const mockFleetService = {
7+
listFleets: vi.fn(async () => []),
8+
createFleet: vi.fn(async (userId: string, input: Record<string, unknown>) => ({
9+
id: 'fleet-1',
10+
userId,
11+
...input,
12+
})),
13+
getFleet: vi.fn(async () => null),
14+
updateFleet: vi.fn(async () => null),
15+
deleteFleet: vi.fn(async () => true),
16+
getSession: vi.fn(async () => null),
17+
startFleet: vi.fn(async () => ({ state: 'running', cyclesCompleted: 0 })),
18+
stopFleet: vi.fn(async () => true),
19+
pauseFleet: vi.fn(async () => true),
20+
resumeFleet: vi.fn(async () => true),
21+
addTask: vi.fn(async () => ({ id: 'task-1' })),
22+
listTasks: vi.fn(async () => []),
23+
getHistory: vi.fn(async () => ({ entries: [], total: 0 })),
24+
};
25+
26+
vi.mock('../services/fleet-service.js', () => ({
27+
getFleetService: vi.fn(() => mockFleetService),
28+
}));
29+
30+
vi.mock('../ws/server.js', () => ({
31+
wsGateway: { broadcast: vi.fn() },
32+
}));
33+
34+
const { fleetRoutes } = await import('./fleet.js');
35+
36+
// ── App ──
37+
38+
function createApp() {
39+
const app = new Hono();
40+
app.use('*', async (c, next) => {
41+
c.set('userId', 'user-1');
42+
await next();
43+
});
44+
app.route('/fleet', fleetRoutes);
45+
return app;
46+
}
47+
48+
// ── Tests ──
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
});
53+
54+
describe('Fleet Routes', () => {
55+
describe('GET /fleet', () => {
56+
it('returns list of fleets', async () => {
57+
mockFleetService.listFleets.mockResolvedValue([
58+
{ id: 'f1', name: 'Fleet A', mission: 'Test' },
59+
]);
60+
const app = createApp();
61+
const res = await app.request('/fleet');
62+
expect(res.status).toBe(200);
63+
const json = await res.json();
64+
expect(json.data).toHaveLength(1);
65+
});
66+
67+
it('returns empty array when no fleets', async () => {
68+
const app = createApp();
69+
const res = await app.request('/fleet');
70+
expect(res.status).toBe(200);
71+
const json = await res.json();
72+
expect(Array.isArray(json.data)).toBe(true);
73+
});
74+
});
75+
76+
describe('POST /fleet', () => {
77+
it('creates a fleet', async () => {
78+
const app = createApp();
79+
const res = await app.request('/fleet', {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/json' },
82+
body: JSON.stringify({
83+
name: 'My Fleet',
84+
mission: 'Do stuff',
85+
workers: [{ name: 'w1', type: 'ai-chat' }],
86+
}),
87+
});
88+
89+
expect(res.status).toBe(201);
90+
const json = await res.json();
91+
expect(json.success).toBe(true);
92+
expect(mockFleetService.createFleet).toHaveBeenCalled();
93+
});
94+
95+
it('returns 400 when name is missing', async () => {
96+
const app = createApp();
97+
const res = await app.request('/fleet', {
98+
method: 'POST',
99+
headers: { 'Content-Type': 'application/json' },
100+
body: JSON.stringify({ mission: 'No name' }),
101+
});
102+
expect(res.status).toBe(400);
103+
});
104+
});
105+
106+
describe('GET /fleet/:id', () => {
107+
it('returns fleet config', async () => {
108+
mockFleetService.getFleet.mockResolvedValue({ id: 'f1', name: 'Fleet A' });
109+
const app = createApp();
110+
const res = await app.request('/fleet/f1');
111+
expect(res.status).toBe(200);
112+
});
113+
114+
it('returns 404 when not found', async () => {
115+
mockFleetService.getFleet.mockResolvedValue(null);
116+
const app = createApp();
117+
const res = await app.request('/fleet/missing');
118+
expect(res.status).toBe(404);
119+
});
120+
});
121+
122+
describe('POST /fleet/:id/start', () => {
123+
it('starts a fleet', async () => {
124+
mockFleetService.getFleet.mockResolvedValue({ id: 'f1' });
125+
const app = createApp();
126+
const res = await app.request('/fleet/f1/start', { method: 'POST' });
127+
expect(res.status).toBe(200);
128+
expect(mockFleetService.startFleet).toHaveBeenCalledWith('f1', 'user-1');
129+
});
130+
});
131+
132+
describe('POST /fleet/:id/stop', () => {
133+
it('stops a fleet', async () => {
134+
const app = createApp();
135+
const res = await app.request('/fleet/f1/stop', { method: 'POST' });
136+
expect(res.status).toBe(200);
137+
expect(mockFleetService.stopFleet).toHaveBeenCalledWith('f1', 'user-1');
138+
});
139+
});
140+
141+
describe('DELETE /fleet/:id', () => {
142+
it('deletes a fleet', async () => {
143+
const app = createApp();
144+
const res = await app.request('/fleet/f1', { method: 'DELETE' });
145+
expect(res.status).toBe(200);
146+
expect(mockFleetService.deleteFleet).toHaveBeenCalledWith('f1', 'user-1');
147+
});
148+
});
149+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Hono } from 'hono';
3+
4+
// ── Mocks ──
5+
6+
const mockRouter = {
7+
notify: vi.fn(async () => ({ sent: 1, channels: ['web'] })),
8+
notifyChannel: vi.fn(async () => 'msg-1'),
9+
broadcast: vi.fn(async () => ({ sent: 2, channels: ['web', 'telegram'] })),
10+
getPreferences: vi.fn(async () => ({
11+
channelPriority: ['web'],
12+
minPriority: 'low',
13+
})),
14+
setPreferences: vi.fn(async () => undefined),
15+
};
16+
17+
vi.mock('../services/notification-router.js', () => ({
18+
getNotificationRouter: vi.fn(() => mockRouter),
19+
createNotification: vi.fn((title: string, body: string, opts: Record<string, unknown> = {}) => ({
20+
id: 'notif-1',
21+
title,
22+
body,
23+
...opts,
24+
})),
25+
}));
26+
27+
const { notificationRoutes } = await import('./notifications.js');
28+
29+
// ── App ──
30+
31+
function createApp() {
32+
const app = new Hono();
33+
app.route('/notifications', notificationRoutes);
34+
return app;
35+
}
36+
37+
// ── Tests ──
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
describe('POST /notifications/send', () => {
44+
it('sends notification and returns result', async () => {
45+
const app = createApp();
46+
const res = await app.request('/notifications/send', {
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify({ title: 'Test', body: 'Hello' }),
50+
});
51+
52+
expect(res.status).toBe(200);
53+
const json = await res.json();
54+
expect(json.data.notification.id).toBe('notif-1');
55+
expect(mockRouter.notify).toHaveBeenCalledWith('default', expect.objectContaining({ title: 'Test' }));
56+
});
57+
58+
it('returns 400 when title is missing', async () => {
59+
const app = createApp();
60+
const res = await app.request('/notifications/send', {
61+
method: 'POST',
62+
headers: { 'Content-Type': 'application/json' },
63+
body: JSON.stringify({ body: 'No title' }),
64+
});
65+
expect(res.status).toBe(400);
66+
});
67+
68+
it('returns 400 when body is missing', async () => {
69+
const app = createApp();
70+
const res = await app.request('/notifications/send', {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' },
73+
body: JSON.stringify({ title: 'No body' }),
74+
});
75+
expect(res.status).toBe(400);
76+
});
77+
78+
it('passes userId when provided', async () => {
79+
const app = createApp();
80+
await app.request('/notifications/send', {
81+
method: 'POST',
82+
headers: { 'Content-Type': 'application/json' },
83+
body: JSON.stringify({ userId: 'user-42', title: 'Hi', body: 'There' }),
84+
});
85+
expect(mockRouter.notify).toHaveBeenCalledWith('user-42', expect.anything());
86+
});
87+
});
88+
89+
describe('POST /notifications/channel', () => {
90+
it('sends to specific channel', async () => {
91+
const app = createApp();
92+
const res = await app.request('/notifications/channel', {
93+
method: 'POST',
94+
headers: { 'Content-Type': 'application/json' },
95+
body: JSON.stringify({
96+
channelId: 'ch-1', chatId: 'chat-1', title: 'Alert', body: 'Details',
97+
}),
98+
});
99+
100+
expect(res.status).toBe(200);
101+
const json = await res.json();
102+
expect(json.data.messageId).toBe('msg-1');
103+
expect(mockRouter.notifyChannel).toHaveBeenCalledWith('ch-1', 'chat-1', expect.anything());
104+
});
105+
106+
it('returns 400 when channelId missing', async () => {
107+
const app = createApp();
108+
const res = await app.request('/notifications/channel', {
109+
method: 'POST',
110+
headers: { 'Content-Type': 'application/json' },
111+
body: JSON.stringify({ chatId: 'c', title: 'T', body: 'B' }),
112+
});
113+
expect(res.status).toBe(400);
114+
});
115+
});
116+
117+
describe('POST /notifications/broadcast', () => {
118+
it('broadcasts to all channels', async () => {
119+
const app = createApp();
120+
const res = await app.request('/notifications/broadcast', {
121+
method: 'POST',
122+
headers: { 'Content-Type': 'application/json' },
123+
body: JSON.stringify({ title: 'Broadcast', body: 'To all' }),
124+
});
125+
126+
expect(res.status).toBe(200);
127+
const json = await res.json();
128+
expect(json.data.result.sent).toBe(2);
129+
expect(mockRouter.broadcast).toHaveBeenCalled();
130+
});
131+
132+
it('returns 400 when title missing', async () => {
133+
const app = createApp();
134+
const res = await app.request('/notifications/broadcast', {
135+
method: 'POST',
136+
headers: { 'Content-Type': 'application/json' },
137+
body: JSON.stringify({ body: 'No title' }),
138+
});
139+
expect(res.status).toBe(400);
140+
});
141+
});
142+
143+
describe('GET /notifications/preferences/:userId', () => {
144+
it('returns preferences for user', async () => {
145+
const app = createApp();
146+
const res = await app.request('/notifications/preferences/user-1');
147+
148+
expect(res.status).toBe(200);
149+
const json = await res.json();
150+
expect(json.data.preferences.channelPriority).toEqual(['web']);
151+
expect(mockRouter.getPreferences).toHaveBeenCalledWith('user-1');
152+
});
153+
});
154+
155+
describe('PUT /notifications/preferences/:userId', () => {
156+
it('updates preferences', async () => {
157+
const app = createApp();
158+
const res = await app.request('/notifications/preferences/user-1', {
159+
method: 'PUT',
160+
headers: { 'Content-Type': 'application/json' },
161+
body: JSON.stringify({ channelPriority: ['telegram', 'web'], minPriority: 'medium' }),
162+
});
163+
164+
expect(res.status).toBe(200);
165+
expect(mockRouter.setPreferences).toHaveBeenCalledWith(
166+
expect.objectContaining({ userId: 'user-1', minPriority: 'medium' })
167+
);
168+
});
169+
});

0 commit comments

Comments
 (0)