| 1 | import { mkdtemp, rm } from 'node:fs/promises' |
| 2 | import os from 'node:os' |
| 3 | import path from 'node:path' |
| 4 | import { createClient } from '@libsql/client' |
| 5 | import { afterEach, describe, expect, it, vi } from 'vitest' |
| 6 | |
| 7 | vi.mock('electron', () => ({ |
| 8 | app: { |
| 9 | getPath: vi.fn(() => path.join(os.tmpdir(), 'ohmyppt-test-user-data')) |
| 10 | } |
| 11 | })) |
| 12 | |
| 13 | vi.mock('@electron-toolkit/utils', () => ({ |
| 14 | is: { dev: true } |
| 15 | })) |
| 16 | |
| 17 | import { PPTDatabase } from '../../../src/main/db/database' |
| 18 | |
| 19 | describe('session jobs', () => { |
| 20 | const roots: string[] = [] |
| 21 | |
| 22 | afterEach(async () => { |
| 23 | for (const root of roots.splice(0)) { |
| 24 | await rm(root, { recursive: true, force: true }) |
| 25 | } |
| 26 | }) |
| 27 | |
| 28 | it('creates the run and unified page-edit job atomically', async () => { |
| 29 | const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-session-jobs-')) |
| 30 | roots.push(root) |
| 31 | const db = new PPTDatabase(path.join(root, 'test.db')) |
| 32 | await db.init() |
| 33 | |
| 34 | try { |
| 35 | const sessionId = await db.createSession({ |
| 36 | id: 'session-job-1', |
| 37 | title: 'Job test', |
| 38 | slideSizeId: 'wide-16-9', |
| 39 | slideWidth: 1600, |
| 40 | slideHeight: 900, |
| 41 | provider: 'test', |
| 42 | model: 'test-model' |
| 43 | }) |
| 44 | await db.createGenerationRunWithSessionJob({ |
| 45 | run: { |
| 46 | id: 'run-page-edit-1', |
| 47 | sessionId, |
| 48 | mode: 'edit', |
| 49 | totalPages: 1, |
| 50 | modelConfigId: 'model-1', |
| 51 | metadata: { jobType: 'page-edit' } |
| 52 | }, |
| 53 | job: { |
| 54 | id: 'run-page-edit-1', |
| 55 | sessionId, |
| 56 | kind: 'page-edit', |
| 57 | status: 'active', |
| 58 | previousSessionStatus: 'completed', |
| 59 | targetPageId: 'page-1', |
| 60 | targetPageNumber: 1, |
| 61 | selector: '#title', |
| 62 | totalPages: 1 |
| 63 | } |
| 64 | }) |
| 65 | |
| 66 | await expect(db.getGenerationRun('run-page-edit-1')).resolves.toMatchObject({ |
| 67 | session_id: sessionId, |
| 68 | mode: 'edit', |
| 69 | status: 'running' |
| 70 | }) |
| 71 | await expect(db.getLatestSessionJob(sessionId, ['page-edit'])).resolves.toMatchObject({ |
| 72 | id: 'run-page-edit-1', |
| 73 | kind: 'page-edit', |
| 74 | previous_session_status: 'completed', |
| 75 | target_page_id: 'page-1', |
| 76 | target_page_number: 1, |
| 77 | selector: '#title', |
| 78 | status: 'active' |
| 79 | }) |
| 80 | await expect(db.listActiveSessionJobs(['page-edit'])).resolves.toHaveLength(1) |
| 81 | |
| 82 | await db.updateSessionJobStatus('run-page-edit-1', 'finished') |
| 83 | await expect(db.listActiveSessionJobs(['page-edit'])).resolves.toEqual([]) |
| 84 | } finally { |
| 85 | await db.close() |
| 86 | } |
| 87 | }) |
| 88 | |
| 89 | it('migrates a legacy generation job into session_jobs during startup', async () => { |
| 90 | const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-session-jobs-migration-')) |
| 91 | roots.push(root) |
| 92 | const dbPath = path.join(root, 'test.db') |
| 93 | const client = createClient({ url: `file:${dbPath}` }) |
| 94 | await client.executeMultiple(` |
| 95 | CREATE TABLE sessions ( |
| 96 | id TEXT PRIMARY KEY, |
| 97 | title TEXT NOT NULL, |
| 98 | topic TEXT, |
| 99 | style_id TEXT, |
| 100 | page_count INTEGER, |
| 101 | slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9', |
| 102 | slide_width INTEGER NOT NULL DEFAULT 1600, |
| 103 | slide_height INTEGER NOT NULL DEFAULT 900, |
| 104 | reference_document_path TEXT, |
| 105 | status TEXT NOT NULL DEFAULT 'active', |
| 106 | provider TEXT NOT NULL, |
| 107 | model TEXT NOT NULL, |
| 108 | created_at INTEGER NOT NULL, |
| 109 | updated_at INTEGER NOT NULL, |
| 110 | metadata TEXT, |
| 111 | design_contract TEXT, |
| 112 | current_operation_id TEXT, |
| 113 | current_commit TEXT |
| 114 | ); |
| 115 | CREATE TABLE generation_runs ( |
| 116 | id TEXT PRIMARY KEY, |
| 117 | session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, |
| 118 | mode TEXT NOT NULL DEFAULT 'generate', |
| 119 | status TEXT NOT NULL DEFAULT 'running', |
| 120 | total_pages INTEGER NOT NULL DEFAULT 0, |
| 121 | error TEXT, |
| 122 | metadata TEXT, |
| 123 | created_at INTEGER NOT NULL, |
| 124 | updated_at INTEGER NOT NULL |
| 125 | ); |
| 126 | CREATE TABLE generation_jobs ( |
| 127 | id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE, |
| 128 | session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, |
| 129 | kind TEXT NOT NULL, |
| 130 | status TEXT NOT NULL, |
| 131 | created_at INTEGER NOT NULL, |
| 132 | updated_at INTEGER NOT NULL |
| 133 | ); |
| 134 | CREATE TABLE page_edit_jobs ( |
| 135 | id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE, |
| 136 | session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, |
| 137 | target_page_id TEXT NOT NULL, |
| 138 | target_page_number INTEGER, |
| 139 | selector TEXT, |
| 140 | previous_session_status TEXT NOT NULL, |
| 141 | status TEXT NOT NULL, |
| 142 | abort_reason TEXT, |
| 143 | created_at INTEGER NOT NULL, |
| 144 | activated_at INTEGER, |
| 145 | updated_at INTEGER NOT NULL, |
| 146 | finished_at INTEGER |
| 147 | ); |
| 148 | CREATE TABLE deck_edit_jobs ( |
| 149 | id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE, |
| 150 | session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, |
| 151 | previous_session_status TEXT NOT NULL, |
| 152 | total_pages INTEGER NOT NULL, |
| 153 | status TEXT NOT NULL, |
| 154 | abort_reason TEXT, |
| 155 | created_at INTEGER NOT NULL, |
| 156 | activated_at INTEGER, |
| 157 | updated_at INTEGER NOT NULL, |
| 158 | finished_at INTEGER |
| 159 | ); |
| 160 | INSERT INTO sessions ( |
| 161 | id, title, status, provider, model, created_at, updated_at |
| 162 | ) VALUES ('legacy-session', 'Legacy', 'active', 'test', 'test-model', 1, 1); |
| 163 | INSERT INTO generation_runs ( |
| 164 | id, session_id, mode, status, total_pages, metadata, created_at, updated_at |
| 165 | ) VALUES ( |
| 166 | 'legacy-run', 'legacy-session', 'generate', 'running', 3, |
| 167 | '{"previousSessionStatus":"completed"}', 1, 1 |
| 168 | ); |
| 169 | INSERT INTO generation_jobs (id, session_id, kind, status, created_at, updated_at) |
| 170 | VALUES ('legacy-run', 'legacy-session', 'standard', 'active', 1, 1); |
| 171 | INSERT INTO generation_runs ( |
| 172 | id, session_id, mode, status, total_pages, created_at, updated_at |
| 173 | ) VALUES ('legacy-page-run', 'legacy-session', 'edit', 'running', 1, 1, 1); |
| 174 | INSERT INTO page_edit_jobs ( |
| 175 | id, session_id, target_page_id, target_page_number, selector, previous_session_status, |
| 176 | status, created_at, updated_at |
| 177 | ) VALUES ( |
| 178 | 'legacy-page-run', 'legacy-session', 'page-2', 2, '#title', 'failed', 'active', 2, 2 |
| 179 | ); |
| 180 | INSERT INTO generation_jobs (id, session_id, kind, status, created_at, updated_at) |
| 181 | VALUES ('legacy-page-run', 'legacy-session', 'edit', 'active', 2, 2); |
| 182 | INSERT INTO generation_runs ( |
| 183 | id, session_id, mode, status, total_pages, created_at, updated_at |
| 184 | ) VALUES ('legacy-deck-run', 'legacy-session', 'edit', 'running', 4, 1, 1); |
| 185 | INSERT INTO deck_edit_jobs ( |
| 186 | id, session_id, previous_session_status, total_pages, status, created_at, updated_at |
| 187 | ) VALUES ('legacy-deck-run', 'legacy-session', 'completed', 4, 'active', 3, 3); |
| 188 | INSERT INTO generation_jobs (id, session_id, kind, status, created_at, updated_at) |
| 189 | VALUES ('legacy-deck-run', 'legacy-session', 'standard', 'active', 3, 3); |
| 190 | INSERT INTO page_edit_jobs ( |
| 191 | id, session_id, target_page_id, target_page_number, selector, previous_session_status, |
| 192 | status, created_at, updated_at |
| 193 | ) VALUES ( |
| 194 | 'legacy-deck-run', 'legacy-session', 'page-4', 4, '#deck-title', 'failed', 'active', 3, 3 |
| 195 | ); |
| 196 | `) |
| 197 | client.close() |
| 198 | |
| 199 | const db = new PPTDatabase(dbPath) |
| 200 | await db.init() |
| 201 | try { |
| 202 | await expect(db.getLatestSessionJob('legacy-session')).resolves.toMatchObject({ |
| 203 | id: 'legacy-deck-run', |
| 204 | kind: 'deck-edit', |
| 205 | previous_session_status: 'completed', |
| 206 | status: 'active', |
| 207 | total_pages: 4, |
| 208 | target_page_id: null |
| 209 | }) |
| 210 | await expect(db.getSessionJob('legacy-run')).resolves.toMatchObject({ |
| 211 | kind: 'standard', |
| 212 | previous_session_status: 'completed', |
| 213 | total_pages: 3 |
| 214 | }) |
| 215 | await expect(db.getSessionJob('legacy-page-run')).resolves.toMatchObject({ |
| 216 | kind: 'page-edit', |
| 217 | previous_session_status: 'failed', |
| 218 | target_page_id: 'page-2', |
| 219 | target_page_number: 2, |
| 220 | selector: '#title' |
| 221 | }) |
| 222 | } finally { |
| 223 | await db.close() |
| 224 | } |
| 225 | }) |
| 226 | |
| 227 | it('keeps legacy job tables intact when a transactional migration fails', async () => { |
| 228 | const root = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-session-jobs-rollback-')) |
| 229 | roots.push(root) |
| 230 | const dbPath = path.join(root, 'test.db') |
| 231 | const client = createClient({ url: `file:${dbPath}` }) |
| 232 | await client.executeMultiple(` |
| 233 | CREATE TABLE sessions ( |
| 234 | id TEXT PRIMARY KEY, |
| 235 | title TEXT NOT NULL, |
| 236 | topic TEXT, |
| 237 | style_id TEXT, |
| 238 | page_count INTEGER, |
| 239 | slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9', |
| 240 | slide_width INTEGER NOT NULL DEFAULT 1600, |
| 241 | slide_height INTEGER NOT NULL DEFAULT 900, |
| 242 | reference_document_path TEXT, |
| 243 | status TEXT NOT NULL DEFAULT 'active', |
| 244 | provider TEXT NOT NULL, |
| 245 | model TEXT NOT NULL, |
| 246 | created_at INTEGER NOT NULL, |
| 247 | updated_at INTEGER NOT NULL, |
| 248 | metadata TEXT, |
| 249 | design_contract TEXT, |
| 250 | current_operation_id TEXT, |
| 251 | current_commit TEXT |
| 252 | ); |
| 253 | CREATE TABLE generation_runs ( |
| 254 | id TEXT PRIMARY KEY, |
| 255 | session_id TEXT NOT NULL, |
| 256 | mode TEXT NOT NULL DEFAULT 'generate', |
| 257 | status TEXT NOT NULL DEFAULT 'running', |
| 258 | total_pages INTEGER NOT NULL DEFAULT 0, |
| 259 | error TEXT, |
| 260 | metadata TEXT, |
| 261 | created_at INTEGER NOT NULL, |
| 262 | updated_at INTEGER NOT NULL |
| 263 | ); |
| 264 | CREATE TABLE generation_jobs ( |
| 265 | id TEXT PRIMARY KEY, |
| 266 | session_id TEXT NOT NULL, |
| 267 | kind TEXT NOT NULL, |
| 268 | status TEXT NOT NULL, |
| 269 | created_at INTEGER NOT NULL, |
| 270 | updated_at INTEGER NOT NULL |
| 271 | ); |
| 272 | CREATE TABLE session_jobs ( |
| 273 | id TEXT PRIMARY KEY, |
| 274 | session_id TEXT NOT NULL, |
| 275 | kind TEXT NOT NULL CHECK(kind = 'blocked'), |
| 276 | previous_session_status TEXT NOT NULL, |
| 277 | target_page_id TEXT, |
| 278 | target_page_number INTEGER, |
| 279 | selector TEXT, |
| 280 | total_pages INTEGER, |
| 281 | status TEXT NOT NULL, |
| 282 | abort_reason TEXT, |
| 283 | created_at INTEGER NOT NULL, |
| 284 | activated_at INTEGER, |
| 285 | updated_at INTEGER NOT NULL, |
| 286 | finished_at INTEGER |
| 287 | ); |
| 288 | INSERT INTO sessions (id, title, provider, model, created_at, updated_at) |
| 289 | VALUES ('rollback-session', 'Rollback', 'test', 'test-model', 1, 1); |
| 290 | INSERT INTO generation_runs (id, session_id, mode, status, total_pages, created_at, updated_at) |
| 291 | VALUES ('rollback-run', 'rollback-session', 'generate', 'running', 1, 1, 1); |
| 292 | INSERT INTO generation_jobs (id, session_id, kind, status, created_at, updated_at) |
| 293 | VALUES ('rollback-run', 'rollback-session', 'standard', 'active', 1, 1); |
| 294 | `) |
| 295 | client.close() |
| 296 | |
| 297 | const db = new PPTDatabase(dbPath) |
| 298 | try { |
| 299 | await expect(db.init()).rejects.toThrow() |
| 300 | } finally { |
| 301 | await db.close() |
| 302 | } |
| 303 | |
| 304 | const verificationClient = createClient({ url: `file:${dbPath}` }) |
| 305 | try { |
| 306 | const legacyJobs = await verificationClient.execute('SELECT id FROM generation_jobs') |
| 307 | const migratedJobs = await verificationClient.execute('SELECT id FROM session_jobs') |
| 308 | expect(legacyJobs.rows).toHaveLength(1) |
| 309 | expect(migratedJobs.rows).toHaveLength(0) |
| 310 | } finally { |
| 311 | verificationClient.close() |
| 312 | } |
| 313 | }) |
| 314 | }) |
| 315 |