|
| 1 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest' |
| 2 | +import { TestWorkflowEnvironment } from '@temporalio/testing' |
| 3 | +import { Worker } from '@temporalio/worker' |
| 4 | +import path from 'node:path' |
| 5 | +import type { SyncActivities, RunResult } from '../temporal/types.js' |
| 6 | + |
| 7 | +// workflowsPath must point to compiled JS (Temporal bundles it for V8 sandbox) |
| 8 | +const workflowsPath = path.resolve(process.cwd(), 'dist/temporal/workflows.js') |
| 9 | + |
| 10 | +const noErrors: RunResult = { errors: [] } |
| 11 | + |
| 12 | +function stubActivities(overrides: Partial<SyncActivities> = {}): SyncActivities { |
| 13 | + return { |
| 14 | + setup: async () => {}, |
| 15 | + run: async () => noErrors, |
| 16 | + teardown: async () => {}, |
| 17 | + ...overrides, |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +let testEnv: TestWorkflowEnvironment |
| 22 | + |
| 23 | +beforeAll(async () => { |
| 24 | + testEnv = await TestWorkflowEnvironment.createLocal() |
| 25 | +}, 120_000) |
| 26 | + |
| 27 | +afterAll(async () => { |
| 28 | + await testEnv?.teardown() |
| 29 | +}) |
| 30 | + |
| 31 | +describe('syncWorkflow (unit — stubbed activities)', () => { |
| 32 | + it('runs setup then continuous reconciliation until delete', async () => { |
| 33 | + let setupCalled = false |
| 34 | + let runCallCount = 0 |
| 35 | + |
| 36 | + const worker = await Worker.create({ |
| 37 | + connection: testEnv.nativeConnection, |
| 38 | + taskQueue: 'test-queue-1', |
| 39 | + workflowsPath, |
| 40 | + activities: stubActivities({ |
| 41 | + setup: async () => { |
| 42 | + setupCalled = true |
| 43 | + }, |
| 44 | + run: async () => { |
| 45 | + runCallCount++ |
| 46 | + return noErrors |
| 47 | + }, |
| 48 | + }), |
| 49 | + }) |
| 50 | + |
| 51 | + await worker.runUntil(async () => { |
| 52 | + const handle = await testEnv.client.workflow.start('syncWorkflow', { |
| 53 | + args: ['sync_test_1'], |
| 54 | + workflowId: 'test-sync-1', |
| 55 | + taskQueue: 'test-queue-1', |
| 56 | + }) |
| 57 | + |
| 58 | + // Let it run several reconciliation pages |
| 59 | + await new Promise((r) => setTimeout(r, 2000)) |
| 60 | + |
| 61 | + const status = await handle.query('status') |
| 62 | + expect(status.iteration).toBeGreaterThan(0) |
| 63 | + |
| 64 | + await handle.signal('delete') |
| 65 | + await handle.result() |
| 66 | + |
| 67 | + expect(setupCalled).toBe(true) |
| 68 | + expect(runCallCount).toBeGreaterThan(1) |
| 69 | + }) |
| 70 | + }) |
| 71 | + |
| 72 | + it('processes stripe_event signals as optimistic updates', async () => { |
| 73 | + const runCalls: { syncId: string; input?: unknown[] }[] = [] |
| 74 | + |
| 75 | + const worker = await Worker.create({ |
| 76 | + connection: testEnv.nativeConnection, |
| 77 | + taskQueue: 'test-queue-2', |
| 78 | + workflowsPath, |
| 79 | + activities: stubActivities({ |
| 80 | + run: async (syncId: string, input?: unknown[]) => { |
| 81 | + runCalls.push({ syncId, input: input ?? undefined }) |
| 82 | + return noErrors |
| 83 | + }, |
| 84 | + }), |
| 85 | + }) |
| 86 | + |
| 87 | + await worker.runUntil(async () => { |
| 88 | + const handle = await testEnv.client.workflow.start('syncWorkflow', { |
| 89 | + args: ['sync_test_2'], |
| 90 | + workflowId: 'test-sync-2', |
| 91 | + taskQueue: 'test-queue-2', |
| 92 | + }) |
| 93 | + |
| 94 | + // Let reconciliation start |
| 95 | + await new Promise((r) => setTimeout(r, 1500)) |
| 96 | + |
| 97 | + // Send events |
| 98 | + await handle.signal('stripe_event', { |
| 99 | + id: 'evt_1', |
| 100 | + type: 'customer.created', |
| 101 | + }) |
| 102 | + await handle.signal('stripe_event', { |
| 103 | + id: 'evt_2', |
| 104 | + type: 'product.updated', |
| 105 | + }) |
| 106 | + |
| 107 | + await new Promise((r) => setTimeout(r, 2000)) |
| 108 | + await handle.signal('delete') |
| 109 | + await handle.result() |
| 110 | + |
| 111 | + // Find event-bearing run calls (input is defined) |
| 112 | + const eventCalls = runCalls.filter((c) => c.input) |
| 113 | + expect(eventCalls.length).toBeGreaterThanOrEqual(1) |
| 114 | + |
| 115 | + const allEvents = eventCalls.flatMap((c) => c.input!) |
| 116 | + expect(allEvents).toEqual( |
| 117 | + expect.arrayContaining([ |
| 118 | + expect.objectContaining({ id: 'evt_1' }), |
| 119 | + expect.objectContaining({ id: 'evt_2' }), |
| 120 | + ]) |
| 121 | + ) |
| 122 | + |
| 123 | + // All calls should use the same syncId |
| 124 | + for (const call of runCalls) { |
| 125 | + expect(call.syncId).toBe('sync_test_2') |
| 126 | + } |
| 127 | + }) |
| 128 | + }) |
| 129 | + |
| 130 | + it('pauses and resumes processing', async () => { |
| 131 | + const worker = await Worker.create({ |
| 132 | + connection: testEnv.nativeConnection, |
| 133 | + taskQueue: 'test-queue-3', |
| 134 | + workflowsPath, |
| 135 | + activities: stubActivities(), |
| 136 | + }) |
| 137 | + |
| 138 | + await worker.runUntil(async () => { |
| 139 | + const handle = await testEnv.client.workflow.start('syncWorkflow', { |
| 140 | + args: ['sync_test_3'], |
| 141 | + workflowId: 'test-sync-3', |
| 142 | + taskQueue: 'test-queue-3', |
| 143 | + }) |
| 144 | + |
| 145 | + await new Promise((r) => setTimeout(r, 1000)) |
| 146 | + await handle.signal('pause') |
| 147 | + await new Promise((r) => setTimeout(r, 500)) |
| 148 | + |
| 149 | + const status = await handle.query('status') |
| 150 | + expect(status.paused).toBe(true) |
| 151 | + |
| 152 | + await handle.signal('resume') |
| 153 | + await new Promise((r) => setTimeout(r, 500)) |
| 154 | + await handle.signal('delete') |
| 155 | + await handle.result() |
| 156 | + }) |
| 157 | + }) |
| 158 | + |
| 159 | + it('triggers teardown on delete', async () => { |
| 160 | + let teardownCalled = false |
| 161 | + let teardownSyncId: string | undefined |
| 162 | + |
| 163 | + const worker = await Worker.create({ |
| 164 | + connection: testEnv.nativeConnection, |
| 165 | + taskQueue: 'test-queue-4', |
| 166 | + workflowsPath, |
| 167 | + activities: stubActivities({ |
| 168 | + run: async () => { |
| 169 | + // Slow run so delete arrives mid-reconciliation |
| 170 | + await new Promise((r) => setTimeout(r, 500)) |
| 171 | + return noErrors |
| 172 | + }, |
| 173 | + teardown: async (syncId: string) => { |
| 174 | + teardownCalled = true |
| 175 | + teardownSyncId = syncId |
| 176 | + }, |
| 177 | + }), |
| 178 | + }) |
| 179 | + |
| 180 | + await worker.runUntil(async () => { |
| 181 | + const handle = await testEnv.client.workflow.start('syncWorkflow', { |
| 182 | + args: ['sync_test_4'], |
| 183 | + workflowId: 'test-sync-4', |
| 184 | + taskQueue: 'test-queue-4', |
| 185 | + }) |
| 186 | + |
| 187 | + await new Promise((r) => setTimeout(r, 300)) |
| 188 | + await handle.signal('delete') |
| 189 | + await handle.result() |
| 190 | + |
| 191 | + expect(teardownCalled).toBe(true) |
| 192 | + expect(teardownSyncId).toBe('sync_test_4') |
| 193 | + }) |
| 194 | + }) |
| 195 | + |
| 196 | + it('skips setup when phase is running (continueAsNew case)', async () => { |
| 197 | + let setupCalled = false |
| 198 | + |
| 199 | + const worker = await Worker.create({ |
| 200 | + connection: testEnv.nativeConnection, |
| 201 | + taskQueue: 'test-queue-5', |
| 202 | + workflowsPath, |
| 203 | + activities: stubActivities({ |
| 204 | + setup: async () => { |
| 205 | + setupCalled = true |
| 206 | + }, |
| 207 | + }), |
| 208 | + }) |
| 209 | + |
| 210 | + await worker.runUntil(async () => { |
| 211 | + const handle = await testEnv.client.workflow.start('syncWorkflow', { |
| 212 | + args: ['sync_test_5', { phase: 'running' }], |
| 213 | + workflowId: 'test-sync-5', |
| 214 | + taskQueue: 'test-queue-5', |
| 215 | + }) |
| 216 | + |
| 217 | + await new Promise((r) => setTimeout(r, 1000)) |
| 218 | + await handle.signal('delete') |
| 219 | + await handle.result() |
| 220 | + |
| 221 | + expect(setupCalled).toBe(false) |
| 222 | + }) |
| 223 | + }) |
| 224 | +}) |
0 commit comments