|
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 | +const { mockSchemaExports } = vi.hoisted(() => ({ |
| 8 | + mockSchemaExports: { |
| 9 | + workflowExecutionSnapshots: { |
| 10 | + id: 'id', |
| 11 | + workflowId: 'workflow_id', |
| 12 | + stateHash: 'state_hash', |
| 13 | + stateData: 'state_data', |
| 14 | + createdAt: 'created_at', |
| 15 | + }, |
| 16 | + workflowExecutionLogs: { |
| 17 | + id: 'id', |
| 18 | + stateSnapshotId: 'state_snapshot_id', |
| 19 | + }, |
| 20 | + }, |
| 21 | +})) |
| 22 | + |
| 23 | +vi.mock('@sim/db', () => databaseMock) |
| 24 | +vi.mock('@sim/db/schema', () => mockSchemaExports) |
| 25 | +vi.mock('@sim/logger', () => loggerMock) |
| 26 | +vi.mock('drizzle-orm', () => drizzleOrmMock) |
| 27 | +vi.mock('uuid', () => ({ v4: vi.fn(() => 'generated-uuid-1') })) |
| 28 | + |
2 | 29 | import { SnapshotService } from '@/lib/logs/execution/snapshot/service' |
3 | 30 | import type { WorkflowState } from '@/lib/logs/types' |
4 | 31 |
|
| 32 | +const mockState: WorkflowState = { |
| 33 | + blocks: { |
| 34 | + block1: { |
| 35 | + id: 'block1', |
| 36 | + name: 'Test Agent', |
| 37 | + type: 'agent', |
| 38 | + position: { x: 100, y: 200 }, |
| 39 | + subBlocks: {}, |
| 40 | + outputs: {}, |
| 41 | + enabled: true, |
| 42 | + horizontalHandles: true, |
| 43 | + advancedMode: false, |
| 44 | + height: 0, |
| 45 | + }, |
| 46 | + }, |
| 47 | + edges: [{ id: 'edge1', source: 'block1', target: 'block2' }], |
| 48 | + loops: {}, |
| 49 | + parallels: {}, |
| 50 | +} |
| 51 | + |
5 | 52 | describe('SnapshotService', () => { |
| 53 | + beforeEach(() => { |
| 54 | + vi.clearAllMocks() |
| 55 | + }) |
| 56 | + |
6 | 57 | describe('computeStateHash', () => { |
7 | 58 | it.concurrent('should generate consistent hashes for identical states', () => { |
8 | 59 | const service = new SnapshotService() |
@@ -62,7 +113,7 @@ describe('SnapshotService', () => { |
62 | 113 | blocks: { |
63 | 114 | block1: { |
64 | 115 | ...baseState.blocks.block1, |
65 | | - position: { x: 500, y: 600 }, // Different position |
| 116 | + position: { x: 500, y: 600 }, |
66 | 117 | }, |
67 | 118 | }, |
68 | 119 | } |
@@ -140,7 +191,7 @@ describe('SnapshotService', () => { |
140 | 191 | const state2: WorkflowState = { |
141 | 192 | blocks: {}, |
142 | 193 | edges: [ |
143 | | - { id: 'edge2', source: 'b', target: 'c' }, // Different order |
| 194 | + { id: 'edge2', source: 'b', target: 'c' }, |
144 | 195 | { id: 'edge1', source: 'a', target: 'b' }, |
145 | 196 | ], |
146 | 197 | loops: {}, |
@@ -219,7 +270,6 @@ describe('SnapshotService', () => { |
219 | 270 | const hash = service.computeStateHash(complexState) |
220 | 271 | expect(hash).toHaveLength(64) |
221 | 272 |
|
222 | | - // Should be consistent |
223 | 273 | const hash2 = service.computeStateHash(complexState) |
224 | 274 | expect(hash).toBe(hash2) |
225 | 275 | }) |
@@ -335,4 +385,166 @@ describe('SnapshotService', () => { |
335 | 385 | expect(hash1).toHaveLength(64) |
336 | 386 | }) |
337 | 387 | }) |
| 388 | + |
| 389 | + describe('createSnapshotWithDeduplication', () => { |
| 390 | + it('should use upsert to insert a new snapshot', async () => { |
| 391 | + const service = new SnapshotService() |
| 392 | + const workflowId = 'wf-123' |
| 393 | + |
| 394 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 395 | + { |
| 396 | + id: 'generated-uuid-1', |
| 397 | + workflowId, |
| 398 | + stateHash: 'abc123', |
| 399 | + stateData: mockState, |
| 400 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 401 | + }, |
| 402 | + ]) |
| 403 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 404 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 405 | + const mockInsert = vi.fn().mockReturnValue({ values: mockValues }) |
| 406 | + databaseMock.db.insert = mockInsert |
| 407 | + |
| 408 | + const result = await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 409 | + |
| 410 | + expect(mockInsert).toHaveBeenCalled() |
| 411 | + expect(mockValues).toHaveBeenCalledWith( |
| 412 | + expect.objectContaining({ |
| 413 | + id: 'generated-uuid-1', |
| 414 | + workflowId, |
| 415 | + stateData: mockState, |
| 416 | + }) |
| 417 | + ) |
| 418 | + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith( |
| 419 | + expect.objectContaining({ |
| 420 | + set: expect.any(Object), |
| 421 | + }) |
| 422 | + ) |
| 423 | + expect(result.snapshot.id).toBe('generated-uuid-1') |
| 424 | + expect(result.isNew).toBe(true) |
| 425 | + }) |
| 426 | + |
| 427 | + it('should detect reused snapshot when returned id differs from generated id', async () => { |
| 428 | + const service = new SnapshotService() |
| 429 | + const workflowId = 'wf-123' |
| 430 | + |
| 431 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 432 | + { |
| 433 | + id: 'existing-snapshot-id', |
| 434 | + workflowId, |
| 435 | + stateHash: 'abc123', |
| 436 | + stateData: mockState, |
| 437 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 438 | + }, |
| 439 | + ]) |
| 440 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 441 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 442 | + const mockInsert = vi.fn().mockReturnValue({ values: mockValues }) |
| 443 | + databaseMock.db.insert = mockInsert |
| 444 | + |
| 445 | + const result = await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 446 | + |
| 447 | + expect(result.snapshot.id).toBe('existing-snapshot-id') |
| 448 | + expect(result.isNew).toBe(false) |
| 449 | + }) |
| 450 | + |
| 451 | + it('should not throw on concurrent inserts with the same hash', async () => { |
| 452 | + const service = new SnapshotService() |
| 453 | + const workflowId = 'wf-123' |
| 454 | + |
| 455 | + const mockReturningNew = vi.fn().mockResolvedValue([ |
| 456 | + { |
| 457 | + id: 'generated-uuid-1', |
| 458 | + workflowId, |
| 459 | + stateHash: 'abc123', |
| 460 | + stateData: mockState, |
| 461 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 462 | + }, |
| 463 | + ]) |
| 464 | + const mockReturningExisting = vi.fn().mockResolvedValue([ |
| 465 | + { |
| 466 | + id: 'existing-snapshot-id', |
| 467 | + workflowId, |
| 468 | + stateHash: 'abc123', |
| 469 | + stateData: mockState, |
| 470 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 471 | + }, |
| 472 | + ]) |
| 473 | + |
| 474 | + let callCount = 0 |
| 475 | + databaseMock.db.insert = vi.fn().mockImplementation(() => ({ |
| 476 | + values: vi.fn().mockImplementation(() => ({ |
| 477 | + onConflictDoUpdate: vi.fn().mockImplementation(() => ({ |
| 478 | + returning: callCount++ === 0 ? mockReturningNew : mockReturningExisting, |
| 479 | + })), |
| 480 | + })), |
| 481 | + })) |
| 482 | + |
| 483 | + const [result1, result2] = await Promise.all([ |
| 484 | + service.createSnapshotWithDeduplication(workflowId, mockState), |
| 485 | + service.createSnapshotWithDeduplication(workflowId, mockState), |
| 486 | + ]) |
| 487 | + |
| 488 | + expect(result1.snapshot.id).toBe('generated-uuid-1') |
| 489 | + expect(result1.isNew).toBe(true) |
| 490 | + expect(result2.snapshot.id).toBe('existing-snapshot-id') |
| 491 | + expect(result2.isNew).toBe(false) |
| 492 | + }) |
| 493 | + |
| 494 | + it('should pass state_data in the ON CONFLICT SET clause', async () => { |
| 495 | + const service = new SnapshotService() |
| 496 | + const workflowId = 'wf-123' |
| 497 | + |
| 498 | + let capturedConflictConfig: Record<string, unknown> | undefined |
| 499 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 500 | + { |
| 501 | + id: 'generated-uuid-1', |
| 502 | + workflowId, |
| 503 | + stateHash: 'abc123', |
| 504 | + stateData: mockState, |
| 505 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 506 | + }, |
| 507 | + ]) |
| 508 | + |
| 509 | + databaseMock.db.insert = vi.fn().mockReturnValue({ |
| 510 | + values: vi.fn().mockReturnValue({ |
| 511 | + onConflictDoUpdate: vi.fn().mockImplementation((config: Record<string, unknown>) => { |
| 512 | + capturedConflictConfig = config |
| 513 | + return { returning: mockReturning } |
| 514 | + }), |
| 515 | + }), |
| 516 | + }) |
| 517 | + |
| 518 | + await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 519 | + |
| 520 | + expect(capturedConflictConfig).toBeDefined() |
| 521 | + expect(capturedConflictConfig!.target).toBeDefined() |
| 522 | + expect(capturedConflictConfig!.set).toBeDefined() |
| 523 | + expect(capturedConflictConfig!.set).toHaveProperty('stateData') |
| 524 | + }) |
| 525 | + |
| 526 | + it('should always call insert, never a separate select for deduplication', async () => { |
| 527 | + const service = new SnapshotService() |
| 528 | + const workflowId = 'wf-123' |
| 529 | + |
| 530 | + const mockReturning = vi.fn().mockResolvedValue([ |
| 531 | + { |
| 532 | + id: 'generated-uuid-1', |
| 533 | + workflowId, |
| 534 | + stateHash: 'abc123', |
| 535 | + stateData: mockState, |
| 536 | + createdAt: new Date('2026-02-19T00:00:00Z'), |
| 537 | + }, |
| 538 | + ]) |
| 539 | + const mockOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: mockReturning }) |
| 540 | + const mockValues = vi.fn().mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) |
| 541 | + databaseMock.db.insert = vi.fn().mockReturnValue({ values: mockValues }) |
| 542 | + databaseMock.db.select = vi.fn() |
| 543 | + |
| 544 | + await service.createSnapshotWithDeduplication(workflowId, mockState) |
| 545 | + |
| 546 | + expect(databaseMock.db.insert).toHaveBeenCalledTimes(1) |
| 547 | + expect(databaseMock.db.select).not.toHaveBeenCalled() |
| 548 | + }) |
| 549 | + }) |
338 | 550 | }) |
0 commit comments