|
1 | | -import { describe, expect, it } from 'vitest' |
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing' |
| 5 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | + |
| 7 | +vi.mock('@sim/db', () => databaseMock) |
| 8 | +vi.mock('@sim/db/schema', () => ({})) |
| 9 | +vi.mock('@sim/logger', () => loggerMock) |
| 10 | +vi.mock('drizzle-orm', () => drizzleOrmMock) |
| 11 | +vi.mock('uuid', () => ({ v4: vi.fn(() => 'generated-uuid-1') })) |
| 12 | + |
2 | 13 | import { SnapshotService } from '@/lib/logs/execution/snapshot/service' |
3 | 14 | import type { WorkflowState } from '@/lib/logs/types' |
4 | 15 |
|
| 16 | +const mockState: WorkflowState = { |
| 17 | + blocks: { |
| 18 | + block1: { |
| 19 | + id: 'block1', |
| 20 | + name: 'Test Agent', |
| 21 | + type: 'agent', |
| 22 | + position: { x: 100, y: 200 }, |
| 23 | + subBlocks: {}, |
| 24 | + outputs: {}, |
| 25 | + enabled: true, |
| 26 | + horizontalHandles: true, |
| 27 | + advancedMode: false, |
| 28 | + height: 0, |
| 29 | + }, |
| 30 | + }, |
| 31 | + edges: [{ id: 'edge1', source: 'block1', target: 'block2' }], |
| 32 | + loops: {}, |
| 33 | + parallels: {}, |
| 34 | +} |
| 35 | + |
5 | 36 | describe('SnapshotService', () => { |
| 37 | + beforeEach(() => { |
| 38 | + vi.clearAllMocks() |
| 39 | + }) |
| 40 | + |
6 | 41 | describe('computeStateHash', () => { |
7 | 42 | it.concurrent('should generate consistent hashes for identical states', () => { |
8 | 43 | const service = new SnapshotService() |
@@ -62,7 +97,7 @@ describe('SnapshotService', () => { |
62 | 97 | blocks: { |
63 | 98 | block1: { |
64 | 99 | ...baseState.blocks.block1, |
65 | | - position: { x: 500, y: 600 }, // Different position |
| 100 | + position: { x: 500, y: 600 }, |
66 | 101 | }, |
67 | 102 | }, |
68 | 103 | } |
@@ -140,7 +175,7 @@ describe('SnapshotService', () => { |
140 | 175 | const state2: WorkflowState = { |
141 | 176 | blocks: {}, |
142 | 177 | edges: [ |
143 | | - { id: 'edge2', source: 'b', target: 'c' }, // Different order |
| 178 | + { id: 'edge2', source: 'b', target: 'c' }, |
144 | 179 | { id: 'edge1', source: 'a', target: 'b' }, |
145 | 180 | ], |
146 | 181 | loops: {}, |
@@ -219,7 +254,6 @@ describe('SnapshotService', () => { |
219 | 254 | const hash = service.computeStateHash(complexState) |
220 | 255 | expect(hash).toHaveLength(64) |
221 | 256 |
|
222 | | - // Should be consistent |
223 | 257 | const hash2 = service.computeStateHash(complexState) |
224 | 258 | expect(hash).toBe(hash2) |
225 | 259 | }) |
@@ -335,4 +369,166 @@ describe('SnapshotService', () => { |
335 | 369 | expect(hash1).toHaveLength(64) |
336 | 370 | }) |
337 | 371 | }) |
| 372 | + |
| 373 | + describe('createSnapshotWithDeduplication', () => { |
| 374 | + it('should use upsert to insert a new snapshot', async () => { |
| 375 | + const service = new SnapshotService() |
| 376 | + const workflowId = 'wf-123' |
| 377 | + |
| 378 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 379 | + { |
| 380 | + id: 'generated-uuid-1', |
| 381 | + workflowId, |
| 382 | + stateHash: 'abc123', |
| 383 | + stateData: mockState, |
| 384 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 385 | + }, |
| 386 | + ]) |
| 387 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 388 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 389 | + const mockInsert = vi.fn().mockReturnValue({ values: mockValues }) |
| 390 | + databaseMock.db.insert = mockInsert |
| 391 | + |
| 392 | + const result = await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 393 | + |
| 394 | + expect(mockInsert).toHaveBeenCalled() |
| 395 | + expect(mockValues).toHaveBeenCalledWith( |
| 396 | + expect.objectContaining({ |
| 397 | + id: 'generated-uuid-1', |
| 398 | + workflowId, |
| 399 | + stateData: mockState, |
| 400 | + }) |
| 401 | + ) |
| 402 | + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith( |
| 403 | + expect.objectContaining({ |
| 404 | + set: expect.any(Object), |
| 405 | + }) |
| 406 | + ) |
| 407 | + expect(result.snapshot.id).toBe('generated-uuid-1') |
| 408 | + expect(result.isNew).toBe(true) |
| 409 | + }) |
| 410 | + |
| 411 | + it('should detect reused snapshot when returned id differs from generated id', async () => { |
| 412 | + const service = new SnapshotService() |
| 413 | + const workflowId = 'wf-123' |
| 414 | + |
| 415 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 416 | + { |
| 417 | + id: 'existing-snapshot-id', |
| 418 | + workflowId, |
| 419 | + stateHash: 'abc123', |
| 420 | + stateData: mockState, |
| 421 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 422 | + }, |
| 423 | + ]) |
| 424 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 425 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 426 | + const mockInsert = vi.fn().mockReturnValue({ values: mockValues }) |
| 427 | + databaseMock.db.insert = mockInsert |
| 428 | + |
| 429 | + const result = await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 430 | + |
| 431 | + expect(result.snapshot.id).toBe('existing-snapshot-id') |
| 432 | + expect(result.isNew).toBe(false) |
| 433 | + }) |
| 434 | + |
| 435 | + it('should not throw on concurrent inserts with the same hash', async () => { |
| 436 | + const service = new SnapshotService() |
| 437 | + const workflowId = 'wf-123' |
| 438 | + |
| 439 | + const mockReturningNew = vi.fn().mockResolvedValue([ |
| 440 | + { |
| 441 | + id: 'generated-uuid-1', |
| 442 | + workflowId, |
| 443 | + stateHash: 'abc123', |
| 444 | + stateData: mockState, |
| 445 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 446 | + }, |
| 447 | + ]) |
| 448 | + const mockReturningExisting = vi.fn().mockResolvedValue([ |
| 449 | + { |
| 450 | + id: 'existing-snapshot-id', |
| 451 | + workflowId, |
| 452 | + stateHash: 'abc123', |
| 453 | + stateData: mockState, |
| 454 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 455 | + }, |
| 456 | + ]) |
| 457 | + |
| 458 | + let callCount = 0 |
| 459 | + databaseMock.db.insert = vi.fn().mockImplementation(() => ({ |
| 460 | + values: vi.fn().mockImplementation(() => ({ |
| 461 | + onConflictDoUpdate: vi.fn().mockImplementation(() => ({ |
| 462 | + returning: callCount++ === 0 ? mockReturningNew : mockReturningExisting, |
| 463 | + })), |
| 464 | + })), |
| 465 | + })) |
| 466 | + |
| 467 | + const [result1, result2] = await Promise.all([ |
| 468 | + service.createSnapshotWithDeduplication(workflowId, mockState), |
| 469 | + service.createSnapshotWithDeduplication(workflowId, mockState), |
| 470 | + ]) |
| 471 | + |
| 472 | + expect(result1.snapshot.id).toBe('generated-uuid-1') |
| 473 | + expect(result1.isNew).toBe(true) |
| 474 | + expect(result2.snapshot.id).toBe('existing-snapshot-id') |
| 475 | + expect(result2.isNew).toBe(false) |
| 476 | + }) |
| 477 | + |
| 478 | + it('should pass state_data in the ON CONFLICT SET clause', async () => { |
| 479 | + const service = new SnapshotService() |
| 480 | + const workflowId = 'wf-123' |
| 481 | + |
| 482 | + let capturedConflictConfig: Record<string, unknown> | undefined |
| 483 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 484 | + { |
| 485 | + id: 'generated-uuid-1', |
| 486 | + workflowId, |
| 487 | + stateHash: 'abc123', |
| 488 | + stateData: mockState, |
| 489 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 490 | + }, |
| 491 | + ]) |
| 492 | + |
| 493 | + databaseMock.db.insert = vi.fn().mockReturnValue({ |
| 494 | + values: vi.fn().mockReturnValue({ |
| 495 | + onConflictDoUpdate: vi.fn().mockImplementation((config: Record<string, unknown>) => { |
| 496 | + capturedConflictConfig = config |
| 497 | + return { returning: mockReturning } |
| 498 | + }), |
| 499 | + }), |
| 500 | + }) |
| 501 | + |
| 502 | + await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 503 | + |
| 504 | + expect(capturedConflictConfig).toBeDefined() |
| 505 | + expect(capturedConflictConfig!.target).toBeDefined() |
| 506 | + expect(capturedConflictConfig!.set).toBeDefined() |
| 507 | + expect(capturedConflictConfig!.set).toHaveProperty('stateData') |
| 508 | + }) |
| 509 | + |
| 510 | + it('should always call insert, never a separate select for deduplication', async () => { |
| 511 | + const service = new SnapshotService() |
| 512 | + const workflowId = 'wf-123' |
| 513 | + |
| 514 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 515 | + { |
| 516 | + id: 'generated-uuid-1', |
| 517 | + workflowId, |
| 518 | + stateHash: 'abc123', |
| 519 | + stateData: mockState, |
| 520 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 521 | + }, |
| 522 | + ]) |
| 523 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 524 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 525 | + databaseMock.db.insert = vi.fn().mockReturnValue({ values: mockValues }) |
| 526 | + databaseMock.db.select = vi.fn() |
| 527 | + |
| 528 | + await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 529 | + |
| 530 | + expect(databaseMock.db.insert).toHaveBeenCalledTimes(1) |
| 531 | + expect(databaseMock.db.select).not.toHaveBeenCalled() |
| 532 | + }) |
| 533 | + }) |
338 | 534 | }) |
0 commit comments