Skip to content

Commit 88a7763

Browse files
ersinkocclaude
andcommitted
✅ test: add tests for souls-deploy route (9 tests)
Tests cover the atomic agent+soul deployment endpoint: - Minimal deploy with identity only - Autonomy level validation (range 0-4, integer check) - Heartbeat trigger creation when enabled - Invalid cron expression rejection - Duplicate name retry logic (up to 5 attempts) - Non-name transaction failure (500) - Default vs explicit provider/model Only chat-legacy-send remains untested (legacy fallback path, 425 lines requiring 15+ module mocks — skip). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51400e8 commit 88a7763

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Hono } from 'hono';
3+
4+
// ── Mocks ──
5+
6+
const mockSoulRepo = { create: vi.fn(async (data: Record<string, unknown>) => ({ id: 'soul-1', ...data })) };
7+
const mockAgentsRepo = { create: vi.fn(async () => ({ id: 'agent-1' })) };
8+
const mockTriggerRepo = { create: vi.fn(async () => ({ id: 'trigger-1' })) };
9+
const mockAdapter = { transaction: vi.fn(async (fn: () => Promise<unknown>) => fn()) };
10+
11+
vi.mock('../db/repositories/souls.js', () => ({
12+
getSoulsRepository: vi.fn(() => mockSoulRepo),
13+
}));
14+
vi.mock('../db/repositories/agents.js', () => ({
15+
agentsRepo: mockAgentsRepo,
16+
}));
17+
vi.mock('../db/repositories/triggers.js', () => ({
18+
createTriggersRepository: vi.fn(() => mockTriggerRepo),
19+
}));
20+
vi.mock('../db/adapters/index.js', () => ({
21+
getAdapterSync: vi.fn(() => mockAdapter),
22+
}));
23+
vi.mock('../db/repositories/index.js', () => ({
24+
settingsRepo: { get: vi.fn(() => null) },
25+
}));
26+
vi.mock('../services/log.js', () => ({
27+
getLog: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
28+
}));
29+
30+
const { soulDeployRoutes } = await import('./souls-deploy.js');
31+
32+
function createApp() {
33+
const app = new Hono();
34+
app.route('/souls', soulDeployRoutes);
35+
return app;
36+
}
37+
38+
// ── Tests ──
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
mockAdapter.transaction.mockImplementation(async (fn: () => Promise<unknown>) => fn());
43+
});
44+
45+
describe('POST /souls/deploy', () => {
46+
it('deploys a soul agent with minimal input', async () => {
47+
const app = createApp();
48+
const res = await app.request('/souls/deploy', {
49+
method: 'POST',
50+
headers: { 'Content-Type': 'application/json' },
51+
body: JSON.stringify({
52+
identity: { name: 'TestBot' },
53+
}),
54+
});
55+
56+
expect(res.status).toBe(201);
57+
const json = await res.json();
58+
expect(json.data.agentId).toBeTruthy();
59+
expect(json.data.soul).toBeTruthy();
60+
expect(mockAgentsRepo.create).toHaveBeenCalled();
61+
expect(mockSoulRepo.create).toHaveBeenCalledWith(
62+
expect.objectContaining({
63+
identity: expect.objectContaining({ name: 'TestBot' }),
64+
})
65+
);
66+
});
67+
68+
it('returns 400 for invalid autonomy.level', async () => {
69+
const app = createApp();
70+
const res = await app.request('/souls/deploy', {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' },
73+
body: JSON.stringify({
74+
identity: { name: 'Bot' },
75+
autonomy: { level: 10 },
76+
}),
77+
});
78+
79+
expect(res.status).toBe(400);
80+
const json = await res.json();
81+
expect(json.error.message).toContain('autonomy.level');
82+
});
83+
84+
it('returns 400 for non-integer autonomy.level', async () => {
85+
const app = createApp();
86+
const res = await app.request('/souls/deploy', {
87+
method: 'POST',
88+
headers: { 'Content-Type': 'application/json' },
89+
body: JSON.stringify({
90+
identity: { name: 'Bot' },
91+
autonomy: { level: 2.5 },
92+
}),
93+
});
94+
95+
expect(res.status).toBe(400);
96+
});
97+
98+
it('creates heartbeat trigger when enabled', async () => {
99+
const app = createApp();
100+
const res = await app.request('/souls/deploy', {
101+
method: 'POST',
102+
headers: { 'Content-Type': 'application/json' },
103+
body: JSON.stringify({
104+
identity: { name: 'HBBot' },
105+
heartbeat: { enabled: true, interval: '0 */6 * * *' },
106+
}),
107+
});
108+
109+
expect(res.status).toBe(201);
110+
const json = await res.json();
111+
expect(json.data.triggerCreated).toBe(true);
112+
expect(mockTriggerRepo.create).toHaveBeenCalled();
113+
});
114+
115+
it('returns 400 for invalid cron in heartbeat.interval', async () => {
116+
const app = createApp();
117+
const res = await app.request('/souls/deploy', {
118+
method: 'POST',
119+
headers: { 'Content-Type': 'application/json' },
120+
body: JSON.stringify({
121+
identity: { name: 'Bot' },
122+
heartbeat: { enabled: true, interval: 'not a cron' },
123+
}),
124+
});
125+
126+
expect(res.status).toBe(400);
127+
const json = await res.json();
128+
expect(json.error.message).toContain('cron');
129+
});
130+
131+
it('retries on duplicate name conflict', async () => {
132+
mockAdapter.transaction
133+
.mockRejectedValueOnce(new Error('duplicate key value violates unique constraint "agents_name_unique"'))
134+
.mockImplementationOnce(async (fn: () => Promise<unknown>) => fn());
135+
136+
const app = createApp();
137+
const res = await app.request('/souls/deploy', {
138+
method: 'POST',
139+
headers: { 'Content-Type': 'application/json' },
140+
body: JSON.stringify({ identity: { name: 'DupeBot' } }),
141+
});
142+
143+
expect(res.status).toBe(201);
144+
expect(mockAdapter.transaction).toHaveBeenCalledTimes(2);
145+
});
146+
147+
it('returns 500 when transaction fails with non-name error', async () => {
148+
mockAdapter.transaction.mockRejectedValue(new Error('connection refused'));
149+
150+
const app = createApp();
151+
const res = await app.request('/souls/deploy', {
152+
method: 'POST',
153+
headers: { 'Content-Type': 'application/json' },
154+
body: JSON.stringify({ identity: { name: 'Bot' } }),
155+
});
156+
157+
expect(res.status).toBe(500);
158+
});
159+
160+
it('uses default provider/model when not specified', async () => {
161+
const app = createApp();
162+
await app.request('/souls/deploy', {
163+
method: 'POST',
164+
headers: { 'Content-Type': 'application/json' },
165+
body: JSON.stringify({ identity: { name: 'Bot' } }),
166+
});
167+
168+
expect(mockAgentsRepo.create).toHaveBeenCalledWith(
169+
expect.objectContaining({ provider: 'default', model: 'default' })
170+
);
171+
});
172+
173+
it('uses specified provider/model', async () => {
174+
const app = createApp();
175+
await app.request('/souls/deploy', {
176+
method: 'POST',
177+
headers: { 'Content-Type': 'application/json' },
178+
body: JSON.stringify({
179+
identity: { name: 'Bot' },
180+
provider: 'anthropic',
181+
model: 'claude-3-5-sonnet',
182+
}),
183+
});
184+
185+
expect(mockAgentsRepo.create).toHaveBeenCalledWith(
186+
expect.objectContaining({ provider: 'anthropic', model: 'claude-3-5-sonnet' })
187+
);
188+
});
189+
});

0 commit comments

Comments
 (0)