| 1 | import fs from 'fs' |
| 2 | import os from 'os' |
| 3 | import path from 'path' |
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | |
| 6 | const mocks = vi.hoisted(() => ({ |
| 7 | loadEditableSessionPages: vi.fn(), |
| 8 | persistManagedPages: vi.fn(), |
| 9 | ensureHistoryBaselineSafe: vi.fn(), |
| 10 | recordHistoryOperationStrict: vi.fn(), |
| 11 | buildFontHeadTags: vi.fn(), |
| 12 | logInfo: vi.fn(), |
| 13 | logWarn: vi.fn(), |
| 14 | logError: vi.fn() |
| 15 | })) |
| 16 | |
| 17 | vi.mock('electron-log/main.js', () => ({ |
| 18 | default: { |
| 19 | info: mocks.logInfo, |
| 20 | warn: mocks.logWarn, |
| 21 | error: mocks.logError |
| 22 | } |
| 23 | })) |
| 24 | |
| 25 | vi.mock('../../../src/main/session/page-management-service', () => ({ |
| 26 | loadEditableSessionPages: mocks.loadEditableSessionPages, |
| 27 | persistManagedPages: mocks.persistManagedPages |
| 28 | })) |
| 29 | |
| 30 | vi.mock('../../../src/main/history/git-history-service', () => ({ |
| 31 | ensureHistoryBaselineSafe: mocks.ensureHistoryBaselineSafe, |
| 32 | recordHistoryOperationStrict: mocks.recordHistoryOperationStrict |
| 33 | })) |
| 34 | |
| 35 | vi.mock('../../../src/main/session/template-builder', () => ({ |
| 36 | SESSION_ASSET_FILE_NAMES: ['ppt-runtime.js', 'index-runtime.js'] |
| 37 | })) |
| 38 | |
| 39 | vi.mock('../../../src/main/presentation/html/html-utils', () => ({ |
| 40 | validatePersistedPageHtml: () => ({ valid: true, errors: [] }) |
| 41 | })) |
| 42 | |
| 43 | vi.mock('../../../src/main/presentation/fonts/font-registry', () => ({ |
| 44 | buildFontHeadTags: mocks.buildFontHeadTags |
| 45 | })) |
| 46 | |
| 47 | vi.mock('../../../src/main/templates/template-service', () => ({ |
| 48 | loadTemplateManifest: vi.fn(), |
| 49 | listTemplates: vi.fn() |
| 50 | })) |
| 51 | |
| 52 | vi.mock('../../../src/main/templates/template-paths', () => ({ |
| 53 | resolveTemplateRelativePath: vi.fn() |
| 54 | })) |
| 55 | |
| 56 | import { |
| 57 | listMergeSourceSessions, |
| 58 | mergeSessionPages |
| 59 | } from '../../../src/main/session/page-merge-service' |
| 60 | |
| 61 | const wideSlideSize = { |
| 62 | slideSizeId: 'wide-16-9', |
| 63 | slideWidth: 1600, |
| 64 | slideHeight: 900 |
| 65 | } |
| 66 | |
| 67 | describe('mergeSessionPages', () => { |
| 68 | let root: string |
| 69 | let sourceProjectDir: string |
| 70 | let targetProjectDir: string |
| 71 | let sourceSkeletons: Array<Record<string, unknown>> |
| 72 | let upsertedPages: Array<{ id: string; title: string; pageNumber: number; fileSlug: string }> |
| 73 | |
| 74 | const createContext = () => ({ |
| 75 | db: { |
| 76 | getSession: vi.fn(async (sessionId: string) => ({ |
| 77 | id: sessionId, |
| 78 | title: sessionId === 'source' ? 'Source deck' : 'Target deck', |
| 79 | status: 'completed', |
| 80 | metadata: '{}', |
| 81 | ...wideSlideSize |
| 82 | })), |
| 83 | listSessionsWithPageCounts: vi.fn(), |
| 84 | listSourcePageSkeletons: vi.fn(async () => sourceSkeletons), |
| 85 | upsertSessionPage: vi.fn(async (page) => { |
| 86 | upsertedPages.push(page) |
| 87 | }), |
| 88 | upsertSourcePageSkeleton: vi.fn(), |
| 89 | getProject: vi.fn(async () => ({ id: 'project', status: 'published' })), |
| 90 | updateProjectStatus: vi.fn(), |
| 91 | updateSessionStatus: vi.fn(), |
| 92 | hardDeleteSessionPages: vi.fn(), |
| 93 | deleteSourcePageSkeletons: vi.fn(), |
| 94 | replaceSessionPageOrder: vi.fn(), |
| 95 | updateSessionMetadata: vi.fn() |
| 96 | }, |
| 97 | sessionRunStates: new Map(), |
| 98 | getPageSourceUrl: (htmlPath?: string) => (htmlPath ? `file://${htmlPath}` : undefined) |
| 99 | }) |
| 100 | |
| 101 | beforeEach(async () => { |
| 102 | root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'page-merge-service-')) |
| 103 | sourceProjectDir = path.join(root, 'source') |
| 104 | targetProjectDir = path.join(root, 'target') |
| 105 | sourceSkeletons = [] |
| 106 | upsertedPages = [] |
| 107 | await fs.promises.mkdir(path.join(sourceProjectDir, 'images'), { recursive: true }) |
| 108 | await fs.promises.mkdir(path.join(sourceProjectDir, 'assets', 'custom-font'), { |
| 109 | recursive: true |
| 110 | }) |
| 111 | await fs.promises.mkdir( |
| 112 | path.join(sourceProjectDir, 'assets', 'fonts', 'google-fonts', 'Source-Font'), |
| 113 | { recursive: true } |
| 114 | ) |
| 115 | await fs.promises.mkdir( |
| 116 | path.join(targetProjectDir, 'assets', 'fonts', 'google-fonts', 'Target-Font'), |
| 117 | { recursive: true } |
| 118 | ) |
| 119 | await fs.promises.mkdir(targetProjectDir, { recursive: true }) |
| 120 | await fs.promises.writeFile(path.join(sourceProjectDir, 'images', 'shared.png'), 'image') |
| 121 | await fs.promises.writeFile( |
| 122 | path.join(sourceProjectDir, 'assets', 'custom-font', 'font.css'), |
| 123 | '@font-face{font-family:Demo;src:url("./demo.woff2")}' |
| 124 | ) |
| 125 | await fs.promises.writeFile( |
| 126 | path.join(sourceProjectDir, 'assets', 'custom-font', 'demo.woff2'), |
| 127 | 'font' |
| 128 | ) |
| 129 | await fs.promises.writeFile( |
| 130 | path.join(sourceProjectDir, 'assets', 'fonts', 'google-fonts', 'Source-Font', 'source.woff2'), |
| 131 | 'source-font' |
| 132 | ) |
| 133 | await fs.promises.writeFile( |
| 134 | path.join(targetProjectDir, 'assets', 'fonts', 'google-fonts', 'Target-Font', 'target.woff2'), |
| 135 | 'target-font' |
| 136 | ) |
| 137 | await fs.promises.writeFile( |
| 138 | path.join(sourceProjectDir, 'page-two.html'), |
| 139 | '<html><head><link rel="stylesheet" href="./assets/custom-font/font.css"><style data-ppt-fonts="google">@font-face{font-family:"Source Font";src:url("./assets/fonts/google-fonts/Source-Font/source.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Source Font";--ppt-body-font:"Source Font"}</style><style>.source-font{font-family:"Source Font"}</style></head><body data-page-id="page-two"><img src="./images/shared.png"><p class="source-font">Two</p></body></html>' |
| 140 | ) |
| 141 | await fs.promises.writeFile( |
| 142 | path.join(sourceProjectDir, 'page-one.html'), |
| 143 | '<html><body data-page-id="page-one"><p>One</p></body></html>' |
| 144 | ) |
| 145 | await fs.promises.writeFile( |
| 146 | path.join(targetProjectDir, 'existing.html'), |
| 147 | '<html><head><style data-ppt-fonts="google">@font-face{font-family:"Target Font";src:url("./assets/fonts/google-fonts/Target-Font/target.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Target Font";--ppt-body-font:"Target Font"}</style></head><body data-page-id="existing"><p>Existing</p></body></html>' |
| 148 | ) |
| 149 | await fs.promises.writeFile(path.join(targetProjectDir, 'index.html'), '<html>old index</html>') |
| 150 | |
| 151 | mocks.loadEditableSessionPages.mockImplementation(async (_ctx, sessionId: string) => { |
| 152 | if (sessionId === 'source') { |
| 153 | return { |
| 154 | session: wideSlideSize, |
| 155 | projectDir: sourceProjectDir, |
| 156 | indexPath: path.join(sourceProjectDir, 'index.html'), |
| 157 | deckTitle: 'Source', |
| 158 | pages: [ |
| 159 | { |
| 160 | id: 'source-page-1', |
| 161 | pageNumber: 1, |
| 162 | pageId: 'page-one', |
| 163 | title: 'One', |
| 164 | htmlPath: path.join(sourceProjectDir, 'page-one.html'), |
| 165 | status: 'completed' |
| 166 | }, |
| 167 | { |
| 168 | id: 'source-page-2', |
| 169 | pageNumber: 2, |
| 170 | pageId: 'page-two', |
| 171 | title: 'Two', |
| 172 | htmlPath: path.join(sourceProjectDir, 'page-two.html'), |
| 173 | status: 'completed' |
| 174 | } |
| 175 | ] |
| 176 | } |
| 177 | } |
| 178 | return { |
| 179 | session: wideSlideSize, |
| 180 | projectDir: targetProjectDir, |
| 181 | indexPath: path.join(targetProjectDir, 'index.html'), |
| 182 | deckTitle: 'Target', |
| 183 | pages: [ |
| 184 | { |
| 185 | id: 'target-page-1', |
| 186 | pageNumber: 1, |
| 187 | pageId: 'existing', |
| 188 | title: 'Existing', |
| 189 | htmlPath: path.join(targetProjectDir, 'existing.html'), |
| 190 | status: 'completed' |
| 191 | } |
| 192 | ] |
| 193 | } |
| 194 | }) |
| 195 | mocks.persistManagedPages.mockImplementation(async (_ctx, args) => args.pages) |
| 196 | }) |
| 197 | |
| 198 | afterEach(async () => { |
| 199 | vi.restoreAllMocks() |
| 200 | vi.clearAllMocks() |
| 201 | await fs.promises.rm(root, { recursive: true, force: true }) |
| 202 | }) |
| 203 | |
| 204 | it('sorts selected source pages, copies resources, and records one history operation', async () => { |
| 205 | const context = createContext() |
| 206 | |
| 207 | const result = await mergeSessionPages(context as never, { |
| 208 | targetSessionId: 'target', |
| 209 | sourceSessionId: 'source', |
| 210 | sourcePageIds: ['source-page-2', 'source-page-1'] |
| 211 | }) |
| 212 | |
| 213 | expect(upsertedPages.map((page) => page.title)).toEqual(['One', 'Two']) |
| 214 | expect(upsertedPages.map((page) => page.pageNumber)).toEqual([2, 3]) |
| 215 | expect(result.insertedPageIds).toHaveLength(2) |
| 216 | expect(result.generatedPages.map((page) => page.title)).toEqual(['Existing', 'One', 'Two']) |
| 217 | expect(mocks.persistManagedPages).toHaveBeenCalledTimes(1) |
| 218 | expect(mocks.recordHistoryOperationStrict).toHaveBeenCalledTimes(1) |
| 219 | expect(mocks.logInfo).toHaveBeenCalledWith( |
| 220 | '[page-merge]', |
| 221 | expect.objectContaining({ stage: 'request:start', targetSessionId: 'target' }) |
| 222 | ) |
| 223 | expect(mocks.logInfo).toHaveBeenCalledWith( |
| 224 | '[page-merge]', |
| 225 | expect.objectContaining({ |
| 226 | stage: 'request:completed', |
| 227 | insertedPageCount: 2, |
| 228 | targetSessionId: 'target' |
| 229 | }) |
| 230 | ) |
| 231 | |
| 232 | const secondMergedPage = upsertedPages.find((page) => page.title === 'Two') |
| 233 | expect(secondMergedPage).toBeDefined() |
| 234 | const copiedHtml = await fs.promises.readFile( |
| 235 | path.join(targetProjectDir, `${secondMergedPage?.fileSlug}.html`), |
| 236 | 'utf-8' |
| 237 | ) |
| 238 | expect(copiedHtml).toContain('./assets/merged-pages/') |
| 239 | expect(copiedHtml).toContain('font-family:"Target Font"') |
| 240 | expect(copiedHtml).toContain('./assets/fonts/google-fonts/Target-Font/target.woff2') |
| 241 | expect(copiedHtml).not.toContain('Source Font') |
| 242 | expect(copiedHtml).not.toContain('Source-Font/source.woff2') |
| 243 | const copiedAssets = await fs.promises.readdir( |
| 244 | path.join(targetProjectDir, 'assets', 'merged-pages'), |
| 245 | { recursive: true } |
| 246 | ) |
| 247 | expect(copiedAssets.some((entry) => String(entry).endsWith('shared.png'))).toBe(true) |
| 248 | expect(copiedAssets.some((entry) => String(entry).endsWith('demo.woff2'))).toBe(false) |
| 249 | expect(copiedAssets.some((entry) => String(entry).endsWith('source.woff2'))).toBe(false) |
| 250 | const copiedCssPath = copiedAssets.find((entry) => String(entry).endsWith('font.css')) |
| 251 | expect(copiedCssPath).toBeDefined() |
| 252 | expect( |
| 253 | await fs.promises.readFile( |
| 254 | path.join(targetProjectDir, 'assets', 'merged-pages', String(copiedCssPath)), |
| 255 | 'utf-8' |
| 256 | ) |
| 257 | ).not.toContain('@font-face') |
| 258 | }) |
| 259 | |
| 260 | it('uses one aggregated query for source sessions', async () => { |
| 261 | const context = createContext() |
| 262 | context.db.listSessionsWithPageCounts.mockResolvedValue([ |
| 263 | { |
| 264 | session: { |
| 265 | id: 'target', |
| 266 | title: 'Target', |
| 267 | status: 'completed', |
| 268 | updated_at: 3, |
| 269 | ...wideSlideSize |
| 270 | }, |
| 271 | pageCount: 1 |
| 272 | }, |
| 273 | { |
| 274 | session: { |
| 275 | id: 'source', |
| 276 | title: 'Source', |
| 277 | status: 'completed', |
| 278 | updated_at: 2, |
| 279 | ...wideSlideSize |
| 280 | }, |
| 281 | pageCount: 4 |
| 282 | } |
| 283 | ]) |
| 284 | |
| 285 | const result = await listMergeSourceSessions(context as never, 'target') |
| 286 | |
| 287 | expect(context.db.listSessionsWithPageCounts).toHaveBeenCalledTimes(1) |
| 288 | expect(result).toEqual([ |
| 289 | expect.objectContaining({ id: 'source', pageCount: 4, selectable: true }) |
| 290 | ]) |
| 291 | }) |
| 292 | |
| 293 | it('disables and rejects source sessions with a different canvas size', async () => { |
| 294 | const context = createContext() |
| 295 | context.db.getSession.mockImplementation(async (sessionId: string) => ({ |
| 296 | id: sessionId, |
| 297 | title: sessionId, |
| 298 | status: 'completed', |
| 299 | metadata: '{}', |
| 300 | slideSizeId: sessionId === 'source' ? 'vertical-9-16' : 'wide-16-9', |
| 301 | slideWidth: sessionId === 'source' ? 900 : 1600, |
| 302 | slideHeight: sessionId === 'source' ? 1600 : 900 |
| 303 | })) |
| 304 | context.db.listSessionsWithPageCounts.mockResolvedValue([ |
| 305 | { |
| 306 | session: await context.db.getSession('source'), |
| 307 | pageCount: 2 |
| 308 | } |
| 309 | ]) |
| 310 | |
| 311 | await expect(listMergeSourceSessions(context as never, 'target')).resolves.toEqual([ |
| 312 | expect.objectContaining({ |
| 313 | id: 'source', |
| 314 | selectable: false, |
| 315 | disabledReason: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH' |
| 316 | }) |
| 317 | ]) |
| 318 | await expect( |
| 319 | mergeSessionPages(context as never, { |
| 320 | targetSessionId: 'target', |
| 321 | sourceSessionId: 'source', |
| 322 | sourcePageIds: ['source-page-1'] |
| 323 | }) |
| 324 | ).rejects.toMatchObject({ code: 'PAGE_MERGE_SLIDE_SIZE_MISMATCH' }) |
| 325 | }) |
| 326 | |
| 327 | it('rejects a page when a required local resource is missing', async () => { |
| 328 | await fs.promises.writeFile( |
| 329 | path.join(sourceProjectDir, 'page-one.html'), |
| 330 | '<html><body data-page-id="page-one"><img src="./images/missing.png"></body></html>' |
| 331 | ) |
| 332 | const context = createContext() |
| 333 | |
| 334 | await expect( |
| 335 | mergeSessionPages(context as never, { |
| 336 | targetSessionId: 'target', |
| 337 | sourceSessionId: 'source', |
| 338 | sourcePageIds: ['source-page-1'] |
| 339 | }) |
| 340 | ).rejects.toMatchObject({ code: 'PAGE_MERGE_PAGE_COPY_FAILED' }) |
| 341 | |
| 342 | expect(upsertedPages).toEqual([]) |
| 343 | }) |
| 344 | |
| 345 | it('copies source documents and rewrites the skeleton path', async () => { |
| 346 | await fs.promises.mkdir(path.join(sourceProjectDir, 'docs'), { recursive: true }) |
| 347 | await fs.promises.writeFile(path.join(sourceProjectDir, 'docs', 'source.md'), '# source') |
| 348 | sourceSkeletons = [ |
| 349 | { |
| 350 | page_number: 2, |
| 351 | title: 'Two', |
| 352 | role: 'content', |
| 353 | source_document_path: '/docs/source.md', |
| 354 | source_document_name: 'source.md', |
| 355 | source_heading: 'Two heading', |
| 356 | heading_level: 1, |
| 357 | line_start: 1, |
| 358 | line_end: 2, |
| 359 | reason: null, |
| 360 | confidence: 'high' |
| 361 | } |
| 362 | ] |
| 363 | const context = createContext() |
| 364 | |
| 365 | await mergeSessionPages(context as never, { |
| 366 | targetSessionId: 'target', |
| 367 | sourceSessionId: 'source', |
| 368 | sourcePageIds: ['source-page-2'] |
| 369 | }) |
| 370 | |
| 371 | expect(context.db.upsertSourcePageSkeleton).toHaveBeenCalledWith( |
| 372 | expect.objectContaining({ |
| 373 | sourceDocumentPath: expect.stringMatching(/^\/docs\/merged-pages\/mg_/), |
| 374 | sourceHeading: 'Two heading' |
| 375 | }) |
| 376 | ) |
| 377 | const copiedDocs = await fs.promises.readdir( |
| 378 | path.join(targetProjectDir, 'docs', 'merged-pages'), |
| 379 | { recursive: true } |
| 380 | ) |
| 381 | expect(copiedDocs.some((entry) => String(entry).endsWith('source.md'))).toBe(true) |
| 382 | }) |
| 383 | |
| 384 | it('uses a legacy marker when the source document is missing', async () => { |
| 385 | sourceSkeletons = [ |
| 386 | { |
| 387 | page_number: 2, |
| 388 | title: 'Two', |
| 389 | role: 'content', |
| 390 | source_document_path: '/docs/missing.md', |
| 391 | source_document_name: 'missing.md', |
| 392 | source_heading: 'Two heading', |
| 393 | heading_level: 1, |
| 394 | line_start: 1, |
| 395 | line_end: 2, |
| 396 | reason: null, |
| 397 | confidence: 'medium' |
| 398 | } |
| 399 | ] |
| 400 | const context = createContext() |
| 401 | |
| 402 | await mergeSessionPages(context as never, { |
| 403 | targetSessionId: 'target', |
| 404 | sourceSessionId: 'source', |
| 405 | sourcePageIds: ['source-page-2'] |
| 406 | }) |
| 407 | |
| 408 | expect(context.db.upsertSourcePageSkeleton).toHaveBeenCalledWith( |
| 409 | expect.objectContaining({ |
| 410 | sourceDocumentPath: expect.stringMatching(/^merged-session:mg_/) |
| 411 | }) |
| 412 | ) |
| 413 | }) |
| 414 | |
| 415 | it('rolls back database rows, files, resources, and index when commit fails', async () => { |
| 416 | const context = createContext() |
| 417 | context.db.getSession.mockImplementation(async (sessionId: string) => ({ |
| 418 | id: sessionId, |
| 419 | title: sessionId === 'source' ? 'Source deck' : 'Target deck', |
| 420 | status: sessionId === 'target' ? 'failed' : 'completed', |
| 421 | metadata: '{}', |
| 422 | ...wideSlideSize |
| 423 | })) |
| 424 | mocks.persistManagedPages.mockImplementationOnce(async (_ctx, args) => { |
| 425 | await fs.promises.writeFile(args.indexPath, '<html>new index</html>') |
| 426 | return args.pages |
| 427 | }) |
| 428 | mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed')) |
| 429 | |
| 430 | await expect( |
| 431 | mergeSessionPages(context as never, { |
| 432 | targetSessionId: 'target', |
| 433 | sourceSessionId: 'source', |
| 434 | sourcePageIds: ['source-page-2'] |
| 435 | }) |
| 436 | ).rejects.toThrow('history failed') |
| 437 | |
| 438 | expect(context.db.hardDeleteSessionPages).toHaveBeenCalledWith( |
| 439 | 'target', |
| 440 | expect.arrayContaining([expect.any(String)]) |
| 441 | ) |
| 442 | expect(context.db.deleteSourcePageSkeletons).toHaveBeenCalled() |
| 443 | expect(context.db.replaceSessionPageOrder).toHaveBeenCalledWith('target', [ |
| 444 | { id: 'target-page-1', pageNumber: 1 } |
| 445 | ]) |
| 446 | expect(context.db.updateSessionStatus).toHaveBeenLastCalledWith('target', 'failed') |
| 447 | expect(context.db.updateProjectStatus).toHaveBeenLastCalledWith('project', 'published') |
| 448 | expect(await fs.promises.readFile(path.join(targetProjectDir, 'index.html'), 'utf-8')).toBe( |
| 449 | '<html>old index</html>' |
| 450 | ) |
| 451 | expect( |
| 452 | await fs.promises.readdir(path.join(targetProjectDir, 'assets', 'merged-pages'), { |
| 453 | recursive: true |
| 454 | }) |
| 455 | ).toEqual([]) |
| 456 | for (const page of upsertedPages) { |
| 457 | await expect( |
| 458 | fs.promises.access(path.join(targetProjectDir, `${page.fileSlug}.html`)) |
| 459 | ).rejects.toThrow() |
| 460 | } |
| 461 | }) |
| 462 | |
| 463 | it('removes newly staged target font files when merge commit fails', async () => { |
| 464 | await fs.promises.writeFile( |
| 465 | path.join(targetProjectDir, 'existing.html'), |
| 466 | '<html><body data-page-id="existing"><p>Existing</p></body></html>' |
| 467 | ) |
| 468 | mocks.buildFontHeadTags.mockImplementationOnce(async ({ projectDir }) => { |
| 469 | const fontPath = path.join( |
| 470 | projectDir, |
| 471 | 'assets', |
| 472 | 'fonts', |
| 473 | 'google-fonts', |
| 474 | 'Fallback-Font', |
| 475 | 'fallback.woff2' |
| 476 | ) |
| 477 | await fs.promises.mkdir(path.dirname(fontPath), { recursive: true }) |
| 478 | await fs.promises.writeFile(fontPath, 'fallback-font') |
| 479 | return '<style data-ppt-fonts="google">@font-face{font-family:"Fallback Font";src:url("./assets/fonts/google-fonts/Fallback-Font/fallback.woff2") format("woff2")}</style><style data-ppt-fonts="1">:root{--ppt-title-font:"Fallback Font";--ppt-body-font:"Fallback Font"}</style>' |
| 480 | }) |
| 481 | mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed')) |
| 482 | const context = createContext() |
| 483 | |
| 484 | await expect( |
| 485 | mergeSessionPages(context as never, { |
| 486 | targetSessionId: 'target', |
| 487 | sourceSessionId: 'source', |
| 488 | sourcePageIds: ['source-page-1'] |
| 489 | }) |
| 490 | ).rejects.toThrow('history failed') |
| 491 | |
| 492 | await expect( |
| 493 | fs.promises.access( |
| 494 | path.join( |
| 495 | targetProjectDir, |
| 496 | 'assets', |
| 497 | 'fonts', |
| 498 | 'google-fonts', |
| 499 | 'Fallback-Font', |
| 500 | 'fallback.woff2' |
| 501 | ) |
| 502 | ) |
| 503 | ).rejects.toThrow() |
| 504 | }) |
| 505 | |
| 506 | it('logs the rollback stage when a compensation step fails', async () => { |
| 507 | const context = createContext() |
| 508 | context.db.hardDeleteSessionPages.mockRejectedValueOnce(new Error('delete failed')) |
| 509 | mocks.recordHistoryOperationStrict.mockRejectedValueOnce(new Error('history failed')) |
| 510 | await expect( |
| 511 | mergeSessionPages(context as never, { |
| 512 | targetSessionId: 'target', |
| 513 | sourceSessionId: 'source', |
| 514 | sourcePageIds: ['source-page-1'] |
| 515 | }) |
| 516 | ).rejects.toThrow('history failed') |
| 517 | |
| 518 | expect(mocks.logError).toHaveBeenCalledWith( |
| 519 | '[page-merge]', |
| 520 | expect.objectContaining({ stage: 'request:failed', failedStage: 'record-history' }) |
| 521 | ) |
| 522 | expect(mocks.logWarn).toHaveBeenCalledWith( |
| 523 | '[page-merge]', |
| 524 | expect.objectContaining({ |
| 525 | stage: 'rollback:delete-session-pages:failed', |
| 526 | error: 'delete failed' |
| 527 | }) |
| 528 | ) |
| 529 | }) |
| 530 | }) |
| 531 |