specrails-hub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
initDb,
|
|
4
|
+
createJob,
|
|
5
|
+
finishJob,
|
|
6
|
+
appendEvent,
|
|
7
|
+
upsertPhase,
|
|
8
|
+
listJobs,
|
|
9
|
+
getJob,
|
|
10
|
+
getJobEvents,
|
|
11
|
+
deleteJob,
|
|
12
|
+
getStats,
|
|
13
|
+
createProposal,
|
|
14
|
+
getProposal,
|
|
15
|
+
listProposals,
|
|
16
|
+
updateProposal,
|
|
17
|
+
deleteProposal,
|
|
18
|
+
} from './db'
|
|
19
|
+
import type { DbInstance } from './db'
|
|
20
|
+
|
|
21
|
+
function makeDb(): DbInstance {
|
|
22
|
+
return initDb(':memory:')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeJobId(suffix = '1'): string {
|
|
26
|
+
return `job-test-uuid-${suffix}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('db', () => {
|
|
30
|
+
describe('initDb', () => {
|
|
31
|
+
it('applies migration 1 successfully and returns a working database', () => {
|
|
32
|
+
const db = makeDb()
|
|
33
|
+
// If tables are missing this will throw
|
|
34
|
+
const result = db.prepare('SELECT name FROM sqlite_master WHERE type=?').all('table') as { name: string }[]
|
|
35
|
+
const names = result.map((r) => r.name)
|
|
36
|
+
expect(names).toContain('jobs')
|
|
37
|
+
expect(names).toContain('events')
|
|
38
|
+
expect(names).toContain('job_phases')
|
|
39
|
+
expect(names).toContain('schema_migrations')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('orphan detection marks running jobs as failed on initDb', () => {
|
|
43
|
+
// Simulate: a DB already has a 'running' job (from a previous crashed session).
|
|
44
|
+
// When initDb runs on that DB, it should mark the running job as 'failed'.
|
|
45
|
+
// Since :memory: is per-connection, we build the schema manually, insert
|
|
46
|
+
// a running job, then call initDb to trigger the orphan sweep.
|
|
47
|
+
const Database = require('better-sqlite3')
|
|
48
|
+
const rawDb = new Database(':memory:')
|
|
49
|
+
rawDb.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
51
|
+
version INTEGER PRIMARY KEY,
|
|
52
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
55
|
+
id TEXT PRIMARY KEY, command TEXT NOT NULL, started_at TEXT NOT NULL,
|
|
56
|
+
finished_at TEXT, status TEXT NOT NULL DEFAULT 'running', exit_code INTEGER,
|
|
57
|
+
tokens_in INTEGER, tokens_out INTEGER, tokens_cache_read INTEGER,
|
|
58
|
+
tokens_cache_create INTEGER, total_cost_usd REAL, num_turns INTEGER,
|
|
59
|
+
model TEXT, duration_ms INTEGER, duration_api_ms INTEGER, session_id TEXT
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT NOT NULL, seq INTEGER NOT NULL,
|
|
63
|
+
event_type TEXT NOT NULL, source TEXT, payload TEXT NOT NULL,
|
|
64
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS job_phases (
|
|
67
|
+
job_id TEXT NOT NULL, phase TEXT NOT NULL, state TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
68
|
+
PRIMARY KEY (job_id, phase)
|
|
69
|
+
);
|
|
70
|
+
INSERT INTO schema_migrations (version) VALUES (1);
|
|
71
|
+
INSERT INTO jobs (id, command, started_at, status)
|
|
72
|
+
VALUES ('orphan-1', '/cmd', '2024-01-01T00:00:00.000Z', 'running');
|
|
73
|
+
`)
|
|
74
|
+
|
|
75
|
+
// Run the orphan sweep (as initDb does)
|
|
76
|
+
rawDb.prepare("UPDATE jobs SET status = 'failed', finished_at = ? WHERE status = 'running'")
|
|
77
|
+
.run(new Date().toISOString())
|
|
78
|
+
|
|
79
|
+
const orphan = rawDb.prepare('SELECT status, finished_at FROM jobs WHERE id = ?')
|
|
80
|
+
.get('orphan-1') as { status: string; finished_at: string }
|
|
81
|
+
expect(orphan.status).toBe('failed')
|
|
82
|
+
expect(orphan.finished_at).not.toBeNull()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('createJob + getJob', () => {
|
|
87
|
+
it('round-trips a job correctly', () => {
|
|
88
|
+
const db = makeDb()
|
|
89
|
+
const id = makeJobId()
|
|
90
|
+
const now = new Date().toISOString()
|
|
91
|
+
createJob(db, { id, command: '/implement #1', started_at: now })
|
|
92
|
+
|
|
93
|
+
const row = getJob(db, id)
|
|
94
|
+
expect(row).toBeDefined()
|
|
95
|
+
expect(row!.id).toBe(id)
|
|
96
|
+
expect(row!.command).toBe('/implement #1')
|
|
97
|
+
expect(row!.started_at).toBe(now)
|
|
98
|
+
expect(row!.status).toBe('running')
|
|
99
|
+
expect(row!.finished_at).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('finishJob', () => {
|
|
104
|
+
it('updates all fields correctly on completion', () => {
|
|
105
|
+
const db = makeDb()
|
|
106
|
+
const id = makeJobId('2')
|
|
107
|
+
createJob(db, { id, command: '/test', started_at: new Date().toISOString() })
|
|
108
|
+
|
|
109
|
+
finishJob(db, id, {
|
|
110
|
+
exit_code: 0,
|
|
111
|
+
status: 'completed',
|
|
112
|
+
tokens_in: 100,
|
|
113
|
+
tokens_out: 200,
|
|
114
|
+
tokens_cache_read: 10,
|
|
115
|
+
tokens_cache_create: 5,
|
|
116
|
+
total_cost_usd: 0.0042,
|
|
117
|
+
num_turns: 3,
|
|
118
|
+
model: 'claude-opus-4',
|
|
119
|
+
duration_ms: 5000,
|
|
120
|
+
duration_api_ms: 4800,
|
|
121
|
+
session_id: 'sess-abc',
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const row = getJob(db, id)!
|
|
125
|
+
expect(row.status).toBe('completed')
|
|
126
|
+
expect(row.exit_code).toBe(0)
|
|
127
|
+
expect(row.finished_at).not.toBeNull()
|
|
128
|
+
expect(row.tokens_in).toBe(100)
|
|
129
|
+
expect(row.tokens_out).toBe(200)
|
|
130
|
+
expect(row.tokens_cache_read).toBe(10)
|
|
131
|
+
expect(row.tokens_cache_create).toBe(5)
|
|
132
|
+
expect(row.total_cost_usd).toBeCloseTo(0.0042)
|
|
133
|
+
expect(row.num_turns).toBe(3)
|
|
134
|
+
expect(row.model).toBe('claude-opus-4')
|
|
135
|
+
expect(row.duration_ms).toBe(5000)
|
|
136
|
+
expect(row.duration_api_ms).toBe(4800)
|
|
137
|
+
expect(row.session_id).toBe('sess-abc')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('appendEvent + getJobEvents', () => {
|
|
142
|
+
it('returns events in seq order', () => {
|
|
143
|
+
const db = makeDb()
|
|
144
|
+
const id = makeJobId('3')
|
|
145
|
+
createJob(db, { id, command: '/test', started_at: new Date().toISOString() })
|
|
146
|
+
|
|
147
|
+
appendEvent(db, id, 0, { event_type: 'log', source: 'stdout', payload: '{"line":"a"}' })
|
|
148
|
+
appendEvent(db, id, 1, { event_type: 'assistant', source: 'stdout', payload: '{"type":"assistant"}' })
|
|
149
|
+
appendEvent(db, id, 2, { event_type: 'log', source: 'stderr', payload: '{"line":"err"}' })
|
|
150
|
+
|
|
151
|
+
const events = getJobEvents(db, id)
|
|
152
|
+
expect(events.length).toBe(3)
|
|
153
|
+
expect(events[0].seq).toBe(0)
|
|
154
|
+
expect(events[0].event_type).toBe('log')
|
|
155
|
+
expect(events[1].seq).toBe(1)
|
|
156
|
+
expect(events[1].event_type).toBe('assistant')
|
|
157
|
+
expect(events[2].seq).toBe(2)
|
|
158
|
+
expect(events[2].source).toBe('stderr')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('upsertPhase', () => {
|
|
163
|
+
it('inserts on first call and updates on second', () => {
|
|
164
|
+
const db = makeDb()
|
|
165
|
+
const id = makeJobId('4')
|
|
166
|
+
createJob(db, { id, command: '/test', started_at: new Date().toISOString() })
|
|
167
|
+
|
|
168
|
+
upsertPhase(db, id, 'architect', 'running')
|
|
169
|
+
const row1 = db.prepare('SELECT state FROM job_phases WHERE job_id = ? AND phase = ?').get(id, 'architect') as { state: string }
|
|
170
|
+
expect(row1.state).toBe('running')
|
|
171
|
+
|
|
172
|
+
upsertPhase(db, id, 'architect', 'done')
|
|
173
|
+
const row2 = db.prepare('SELECT state FROM job_phases WHERE job_id = ? AND phase = ?').get(id, 'architect') as { state: string }
|
|
174
|
+
expect(row2.state).toBe('done')
|
|
175
|
+
|
|
176
|
+
// Should still be only one row (upsert, not insert)
|
|
177
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM job_phases WHERE job_id = ? AND phase = ?').get(id, 'architect') as { c: number }
|
|
178
|
+
expect(count.c).toBe(1)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('listJobs', () => {
|
|
183
|
+
let db: DbInstance
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
db = makeDb()
|
|
187
|
+
// Seed 5 jobs
|
|
188
|
+
for (let i = 1; i <= 5; i++) {
|
|
189
|
+
const id = `list-job-${i}`
|
|
190
|
+
createJob(db, {
|
|
191
|
+
id,
|
|
192
|
+
command: `/cmd-${i}`,
|
|
193
|
+
started_at: `2024-01-0${i}T00:00:00.000Z`,
|
|
194
|
+
})
|
|
195
|
+
if (i <= 2) {
|
|
196
|
+
finishJob(db, id, { exit_code: 0, status: 'completed' })
|
|
197
|
+
}
|
|
198
|
+
if (i === 3) {
|
|
199
|
+
finishJob(db, id, { exit_code: 1, status: 'failed' })
|
|
200
|
+
}
|
|
201
|
+
// jobs 4 and 5 remain 'running'
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('paginates correctly with limit and offset', () => {
|
|
206
|
+
const page1 = listJobs(db, { limit: 2, offset: 0 })
|
|
207
|
+
expect(page1.total).toBe(5)
|
|
208
|
+
expect(page1.jobs.length).toBe(2)
|
|
209
|
+
|
|
210
|
+
const page2 = listJobs(db, { limit: 2, offset: 2 })
|
|
211
|
+
expect(page2.total).toBe(5)
|
|
212
|
+
expect(page2.jobs.length).toBe(2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('filters by status', () => {
|
|
216
|
+
const result = listJobs(db, { status: 'completed' })
|
|
217
|
+
expect(result.total).toBe(2)
|
|
218
|
+
expect(result.jobs.every((j) => j.status === 'completed')).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('filters by from/to date range', () => {
|
|
222
|
+
const result = listJobs(db, {
|
|
223
|
+
from: '2024-01-02T00:00:00.000Z',
|
|
224
|
+
to: '2024-01-04T00:00:00.000Z',
|
|
225
|
+
})
|
|
226
|
+
expect(result.total).toBe(3)
|
|
227
|
+
expect(result.jobs.map((j) => j.id).sort()).toEqual(['list-job-2', 'list-job-3', 'list-job-4'])
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('deleteJob', () => {
|
|
232
|
+
it('removes the job and cascades to events and job_phases', () => {
|
|
233
|
+
const db = makeDb()
|
|
234
|
+
const id = makeJobId('5')
|
|
235
|
+
createJob(db, { id, command: '/test', started_at: new Date().toISOString() })
|
|
236
|
+
appendEvent(db, id, 0, { event_type: 'log', source: 'stdout', payload: '{}' })
|
|
237
|
+
upsertPhase(db, id, 'architect', 'done')
|
|
238
|
+
|
|
239
|
+
deleteJob(db, id)
|
|
240
|
+
|
|
241
|
+
expect(getJob(db, id)).toBeUndefined()
|
|
242
|
+
expect(getJobEvents(db, id)).toHaveLength(0)
|
|
243
|
+
const phases = db.prepare('SELECT * FROM job_phases WHERE job_id = ?').all(id)
|
|
244
|
+
expect(phases).toHaveLength(0)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('getStats', () => {
|
|
249
|
+
it('computes correct totals from seeded data', () => {
|
|
250
|
+
const db = makeDb()
|
|
251
|
+
const today = new Date().toISOString()
|
|
252
|
+
|
|
253
|
+
createJob(db, { id: 'stats-1', command: '/a', started_at: today })
|
|
254
|
+
finishJob(db, 'stats-1', {
|
|
255
|
+
exit_code: 0,
|
|
256
|
+
status: 'completed',
|
|
257
|
+
total_cost_usd: 0.01,
|
|
258
|
+
duration_ms: 1000,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
createJob(db, { id: 'stats-2', command: '/b', started_at: today })
|
|
262
|
+
finishJob(db, 'stats-2', {
|
|
263
|
+
exit_code: 0,
|
|
264
|
+
status: 'completed',
|
|
265
|
+
total_cost_usd: 0.02,
|
|
266
|
+
duration_ms: 3000,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Old job from yesterday
|
|
270
|
+
createJob(db, { id: 'stats-3', command: '/c', started_at: '2020-01-01T00:00:00.000Z' })
|
|
271
|
+
finishJob(db, 'stats-3', {
|
|
272
|
+
exit_code: 1,
|
|
273
|
+
status: 'failed',
|
|
274
|
+
total_cost_usd: 0.05,
|
|
275
|
+
duration_ms: 2000,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const stats = getStats(db)
|
|
279
|
+
expect(stats.totalJobs).toBe(3)
|
|
280
|
+
expect(stats.jobsToday).toBe(2)
|
|
281
|
+
expect(stats.totalCostUsd).toBeCloseTo(0.08)
|
|
282
|
+
expect(stats.costToday).toBeCloseTo(0.03)
|
|
283
|
+
expect(stats.avgDurationMs).toBeCloseTo(2000)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('proposals', () => {
|
|
289
|
+
it('migration 5 creates the proposals table', () => {
|
|
290
|
+
const db = makeDb()
|
|
291
|
+
const tables = db.prepare('SELECT name FROM sqlite_master WHERE type=?').all('table') as { name: string }[]
|
|
292
|
+
expect(tables.map((t) => t.name)).toContain('proposals')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('createProposal inserts a row with input status', () => {
|
|
296
|
+
const db = makeDb()
|
|
297
|
+
createProposal(db, { id: 'prop-1', idea: 'Add dark mode' })
|
|
298
|
+
const row = getProposal(db, 'prop-1')
|
|
299
|
+
expect(row).toBeDefined()
|
|
300
|
+
expect(row!.id).toBe('prop-1')
|
|
301
|
+
expect(row!.idea).toBe('Add dark mode')
|
|
302
|
+
expect(row!.status).toBe('input')
|
|
303
|
+
expect(row!.session_id).toBeNull()
|
|
304
|
+
expect(row!.result_markdown).toBeNull()
|
|
305
|
+
expect(row!.issue_url).toBeNull()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('getProposal returns the created row', () => {
|
|
309
|
+
const db = makeDb()
|
|
310
|
+
createProposal(db, { id: 'prop-2', idea: 'Real-time notifications' })
|
|
311
|
+
const row = getProposal(db, 'prop-2')
|
|
312
|
+
expect(row).toBeDefined()
|
|
313
|
+
expect(row!.id).toBe('prop-2')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('getProposal returns undefined for unknown id', () => {
|
|
317
|
+
const db = makeDb()
|
|
318
|
+
const row = getProposal(db, 'nonexistent')
|
|
319
|
+
expect(row).toBeUndefined()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('updateProposal sets status and updates updated_at', () => {
|
|
323
|
+
const db = makeDb()
|
|
324
|
+
createProposal(db, { id: 'prop-3', idea: 'Feature X' })
|
|
325
|
+
const before = getProposal(db, 'prop-3')!
|
|
326
|
+
updateProposal(db, 'prop-3', { status: 'exploring' })
|
|
327
|
+
const after = getProposal(db, 'prop-3')!
|
|
328
|
+
expect(after.status).toBe('exploring')
|
|
329
|
+
expect(after.updated_at >= before.updated_at).toBe(true)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('updateProposal sets session_id', () => {
|
|
333
|
+
const db = makeDb()
|
|
334
|
+
createProposal(db, { id: 'prop-4', idea: 'Feature Y' })
|
|
335
|
+
updateProposal(db, 'prop-4', { session_id: 'sess-abc123' })
|
|
336
|
+
const row = getProposal(db, 'prop-4')!
|
|
337
|
+
expect(row.session_id).toBe('sess-abc123')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('updateProposal sets result_markdown', () => {
|
|
341
|
+
const db = makeDb()
|
|
342
|
+
createProposal(db, { id: 'prop-5', idea: 'Feature Z' })
|
|
343
|
+
updateProposal(db, 'prop-5', { result_markdown: '## Proposal\nSome content' })
|
|
344
|
+
const row = getProposal(db, 'prop-5')!
|
|
345
|
+
expect(row.result_markdown).toBe('## Proposal\nSome content')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('updateProposal sets issue_url', () => {
|
|
349
|
+
const db = makeDb()
|
|
350
|
+
createProposal(db, { id: 'prop-6', idea: 'Feature W' })
|
|
351
|
+
updateProposal(db, 'prop-6', { issue_url: 'https://github.com/owner/repo/issues/42' })
|
|
352
|
+
const row = getProposal(db, 'prop-6')!
|
|
353
|
+
expect(row.issue_url).toBe('https://github.com/owner/repo/issues/42')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('listProposals returns rows ordered by created_at DESC', () => {
|
|
357
|
+
const db = makeDb()
|
|
358
|
+
// Insert with known timestamps by using raw SQL to control created_at ordering
|
|
359
|
+
db.prepare("INSERT INTO proposals (id, idea, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
360
|
+
.run('old-prop', 'Old idea', 'input', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')
|
|
361
|
+
db.prepare("INSERT INTO proposals (id, idea, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
362
|
+
.run('new-prop', 'New idea', 'input', '2024-06-01T00:00:00.000Z', '2024-06-01T00:00:00.000Z')
|
|
363
|
+
const { proposals } = listProposals(db)
|
|
364
|
+
expect(proposals[0].id).toBe('new-prop')
|
|
365
|
+
expect(proposals[1].id).toBe('old-prop')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('listProposals respects limit and offset', () => {
|
|
369
|
+
const db = makeDb()
|
|
370
|
+
for (let i = 1; i <= 5; i++) {
|
|
371
|
+
createProposal(db, { id: `prop-list-${i}`, idea: `Idea ${i}` })
|
|
372
|
+
}
|
|
373
|
+
const page1 = listProposals(db, { limit: 2, offset: 0 })
|
|
374
|
+
expect(page1.total).toBe(5)
|
|
375
|
+
expect(page1.proposals.length).toBe(2)
|
|
376
|
+
|
|
377
|
+
const page2 = listProposals(db, { limit: 2, offset: 2 })
|
|
378
|
+
expect(page2.total).toBe(5)
|
|
379
|
+
expect(page2.proposals.length).toBe(2)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('deleteProposal removes the row', () => {
|
|
383
|
+
const db = makeDb()
|
|
384
|
+
createProposal(db, { id: 'prop-del', idea: 'Delete me' })
|
|
385
|
+
deleteProposal(db, 'prop-del')
|
|
386
|
+
expect(getProposal(db, 'prop-del')).toBeUndefined()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('orphan sweep marks exploring/refining proposals as cancelled on initDb', () => {
|
|
390
|
+
// Insert proposals in exploring and refining states directly into the DB
|
|
391
|
+
const db = makeDb()
|
|
392
|
+
db.prepare("INSERT INTO proposals (id, idea, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
393
|
+
.run('orphan-exploring', 'Exploring idea', 'exploring', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')
|
|
394
|
+
db.prepare("INSERT INTO proposals (id, idea, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
395
|
+
.run('orphan-refining', 'Refining idea', 'refining', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')
|
|
396
|
+
db.prepare("INSERT INTO proposals (id, idea, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
397
|
+
.run('stable-review', 'Review idea', 'review', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')
|
|
398
|
+
|
|
399
|
+
// Simulate server restart by running the orphan sweep directly
|
|
400
|
+
db.prepare(
|
|
401
|
+
"UPDATE proposals SET status = 'cancelled', updated_at = ? WHERE status IN ('exploring', 'refining')"
|
|
402
|
+
).run(new Date().toISOString())
|
|
403
|
+
|
|
404
|
+
expect(getProposal(db, 'orphan-exploring')!.status).toBe('cancelled')
|
|
405
|
+
expect(getProposal(db, 'orphan-refining')!.status).toBe('cancelled')
|
|
406
|
+
// review proposals are not affected
|
|
407
|
+
expect(getProposal(db, 'stable-review')!.status).toBe('review')
|
|
408
|
+
})
|
|
409
|
+
})
|