@forgehive/task 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ import { Schema } from '@forgehive/schema'
2
+ import { createTask, ExecutionRecord } from '../index'
3
+
4
+ describe('Complex boundary replay tests', () => {
5
+ // Define types for our portfolio data
6
+ type Stock = {
7
+ ticker: string;
8
+ quantity: number;
9
+ }
10
+
11
+ type Portfolio = {
12
+ id: string;
13
+ userId: string;
14
+ stocks: Stock[];
15
+ }
16
+
17
+ // Test data
18
+ let priceData: Record<string, number>
19
+ let portfolioData: Record<string, Portfolio>
20
+
21
+ // Boundaries for the portfolio task
22
+ let boundaries: {
23
+ fetchPrice: (ticker: string) => Promise<number>;
24
+ fetchPortfolio: (userId: string) => Promise<Portfolio>;
25
+ }
26
+
27
+ // The task - using eslint-disable to allow any type for this test
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ let calculatePortfolioValue: any
30
+
31
+ beforeEach(() => {
32
+ // Setup mock data
33
+ priceData = {
34
+ 'AAPL': 182.63,
35
+ 'MSFT': 421.90,
36
+ 'GOOGL': 171.04,
37
+ 'AMZN': 184.72
38
+ }
39
+
40
+ portfolioData = {
41
+ 'user1': {
42
+ id: 'portfolio1',
43
+ userId: 'user1',
44
+ stocks: [
45
+ { ticker: 'AAPL', quantity: 10 },
46
+ { ticker: 'MSFT', quantity: 5 },
47
+ { ticker: 'GOOGL', quantity: 8 }
48
+ ]
49
+ },
50
+ 'user2': {
51
+ id: 'portfolio2',
52
+ userId: 'user2',
53
+ stocks: [
54
+ { ticker: 'AMZN', quantity: 15 },
55
+ { ticker: 'MSFT', quantity: 3 }
56
+ ]
57
+ }
58
+ }
59
+
60
+ // Define boundaries with realistic functions
61
+ boundaries = {
62
+ fetchPrice: async (ticker: string): Promise<number> => {
63
+ // Check if we have a price for this ticker
64
+ if (!priceData[ticker]) {
65
+ throw new Error(`Price data not available for ticker: ${ticker}`)
66
+ }
67
+ return priceData[ticker]
68
+ },
69
+ fetchPortfolio: async (userId: string): Promise<Portfolio> => {
70
+ // Check if we have a portfolio for this user
71
+ if (!portfolioData[userId]) {
72
+ throw new Error(`Portfolio not found for user: ${userId}`)
73
+ }
74
+ return portfolioData[userId]
75
+ }
76
+ }
77
+
78
+ // Create a schema for the task
79
+ const schema = new Schema({
80
+ userId: Schema.string()
81
+ })
82
+
83
+ // Create the portfolio value calculation task
84
+ calculatePortfolioValue = createTask(
85
+ schema,
86
+ boundaries,
87
+ async ({ userId }, { fetchPortfolio, fetchPrice }) => {
88
+ // First fetch the portfolio for the user
89
+ const portfolio = await fetchPortfolio(userId)
90
+
91
+ // Then calculate the value of each stock and the total portfolio value
92
+ const stocksWithValue = await Promise.all(
93
+ portfolio.stocks.map(async (stock) => {
94
+ const price = await fetchPrice(stock.ticker)
95
+ const value = price * stock.quantity
96
+
97
+ return {
98
+ ticker: stock.ticker,
99
+ quantity: stock.quantity,
100
+ price,
101
+ value
102
+ }
103
+ })
104
+ )
105
+
106
+ // Calculate total value
107
+ const totalValue = stocksWithValue.reduce((sum, stock) => sum + stock.value, 0)
108
+
109
+ // Return portfolio value information
110
+ return {
111
+ id: portfolio.id,
112
+ userId: portfolio.userId,
113
+ totalValue,
114
+ stocks: stocksWithValue
115
+ }
116
+ }
117
+ )
118
+ })
119
+
120
+ it('Should calculate portfolio value using multiple boundaries', async () => {
121
+ // Run the task for user1
122
+ const result = await calculatePortfolioValue.run({ userId: 'user1' })
123
+
124
+ // Verify the structure of the result
125
+ expect(result).toHaveProperty('id', 'portfolio1')
126
+ expect(result).toHaveProperty('userId', 'user1')
127
+ expect(result).toHaveProperty('totalValue')
128
+ expect(result).toHaveProperty('stocks')
129
+
130
+ // Verify portfolio calculations
131
+ expect(result.stocks).toHaveLength(3)
132
+
133
+ // Calculate expected values
134
+ const expectedTotalValue =
135
+ (priceData['AAPL'] * 10) +
136
+ (priceData['MSFT'] * 5) +
137
+ (priceData['GOOGL'] * 8)
138
+
139
+ // Verify total value matches expected
140
+ expect(result.totalValue).toBeCloseTo(expectedTotalValue)
141
+
142
+ // Verify each stock's value
143
+ const applStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
144
+ expect(applStock).toBeDefined()
145
+ expect(applStock?.price).toBe(priceData['AAPL'])
146
+ expect(applStock?.value).toBeCloseTo(priceData['AAPL'] * 10)
147
+
148
+ const msftStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'MSFT')
149
+ expect(msftStock).toBeDefined()
150
+ expect(msftStock?.price).toBe(priceData['MSFT'])
151
+ expect(msftStock?.value).toBeCloseTo(priceData['MSFT'] * 5)
152
+
153
+ const googlStock = result.stocks.find((s: { ticker: string }) => s.ticker === 'GOOGL')
154
+ expect(googlStock).toBeDefined()
155
+ expect(googlStock?.price).toBe(priceData['GOOGL'])
156
+ expect(googlStock?.value).toBeCloseTo(priceData['GOOGL'] * 8)
157
+ })
158
+
159
+ it('Should replay portfolio calculation from an execution log', async () => {
160
+ // First create a portfolio value calculation execution log
161
+ const executionLog: ExecutionRecord = {
162
+ input: { userId: 'user1' },
163
+ output: {
164
+ id: 'portfolio1',
165
+ userId: 'user1',
166
+ totalValue: 4363.02,
167
+ stocks: [
168
+ { ticker: 'AAPL', quantity: 10, price: 190.50, value: 1905.00 },
169
+ { ticker: 'MSFT', quantity: 5, price: 405.75, value: 2028.75 },
170
+ { ticker: 'GOOGL', quantity: 8, price: 161.16, value: 429.27 }
171
+ ]
172
+ },
173
+ boundaries: {
174
+ fetchPortfolio: [
175
+ {
176
+ input: ['user1'],
177
+ output: {
178
+ id: 'portfolio1',
179
+ userId: 'user1',
180
+ stocks: [
181
+ { ticker: 'AAPL', quantity: 10 },
182
+ { ticker: 'MSFT', quantity: 5 },
183
+ { ticker: 'GOOGL', quantity: 8 }
184
+ ]
185
+ }
186
+ }
187
+ ],
188
+ fetchPrice: [
189
+ {
190
+ input: ['AAPL'],
191
+ output: 190.50
192
+ },
193
+ {
194
+ input: ['MSFT'],
195
+ output: 405.75
196
+ },
197
+ {
198
+ input: ['GOOGL'],
199
+ output: 161.16
200
+ }
201
+ ]
202
+ }
203
+ }
204
+
205
+ // Use replay mode for all boundaries
206
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
207
+ executionLog,
208
+ {
209
+ boundaries: {
210
+ fetchPortfolio: 'replay',
211
+ fetchPrice: 'replay'
212
+ }
213
+ }
214
+ )
215
+
216
+ // Verify there was no error
217
+ expect(replayError).toBeNull()
218
+
219
+ // Verify the replayed result
220
+ expect(replayResult).toHaveProperty('id', 'portfolio1')
221
+ expect(replayResult).toHaveProperty('userId', 'user1')
222
+ expect(replayResult).toHaveProperty('totalValue', 5223.03)
223
+ expect(replayResult).toHaveProperty('stocks')
224
+ expect(replayResult.stocks).toHaveLength(3)
225
+
226
+ // Check values match the execution log exactly
227
+ const applStock = replayResult.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
228
+ expect(applStock?.price).toBe(190.50)
229
+ expect(applStock?.value).toBe(1905.00)
230
+
231
+ // Verify that boundary calls were properly replayed
232
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
233
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
234
+ })
235
+
236
+ it('Should handle errors during replay', async () => {
237
+ // Create an execution log with an error in one of the price fetches
238
+ const executionLog: ExecutionRecord = {
239
+ input: { userId: 'user1' },
240
+ error: 'Price data not available for ticker: GOOGL',
241
+ boundaries: {
242
+ fetchPortfolio: [
243
+ {
244
+ input: ['user1'],
245
+ output: {
246
+ id: 'portfolio1',
247
+ userId: 'user1',
248
+ stocks: [
249
+ { ticker: 'AAPL', quantity: 10 },
250
+ { ticker: 'MSFT', quantity: 5 },
251
+ { ticker: 'GOOGL', quantity: 8 }
252
+ ]
253
+ }
254
+ }
255
+ ],
256
+ fetchPrice: [
257
+ {
258
+ input: ['AAPL'],
259
+ output: 190.50
260
+ },
261
+ {
262
+ input: ['MSFT'],
263
+ output: 405.75
264
+ },
265
+ {
266
+ input: ['GOOGL'],
267
+ error: 'Price data not available for ticker: GOOGL'
268
+ }
269
+ ]
270
+ }
271
+ }
272
+
273
+ // Use replay mode for all boundaries
274
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
275
+ executionLog,
276
+ {
277
+ boundaries: {
278
+ fetchPortfolio: 'replay',
279
+ fetchPrice: 'replay'
280
+ }
281
+ }
282
+ )
283
+
284
+ // Verify error was properly replayed
285
+ expect(replayResult).toBeNull()
286
+ expect(replayError).not.toBeNull()
287
+ expect(replayError?.message).toBe('Price data not available for ticker: GOOGL')
288
+
289
+ // Verify that boundary calls were properly replayed up to the error
290
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
291
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
292
+
293
+ // Check the error in the boundary
294
+ const googPriceCall = replayLog.boundaries.fetchPrice.find(
295
+ (call: { input?: unknown[] }) => call.input && call.input[0] === 'GOOGL'
296
+ )
297
+ expect(googPriceCall).toBeDefined()
298
+ expect(googPriceCall?.error).toBe('Price data not available for ticker: GOOGL')
299
+ })
300
+
301
+ it('Should support mixed replay with some boundaries in replay mode and others in proxy mode', async () => {
302
+ // Update prices for the test
303
+ priceData['AAPL'] = 195.00 // Different from replay data
304
+
305
+ // Create an execution log with historical data
306
+ const executionLog: ExecutionRecord = {
307
+ input: { userId: 'user1' },
308
+ output: {
309
+ id: 'portfolio1',
310
+ userId: 'user1',
311
+ totalValue: 4363.02,
312
+ stocks: [
313
+ { ticker: 'AAPL', quantity: 10, price: 190.50, value: 1905.00 },
314
+ { ticker: 'MSFT', quantity: 5, price: 405.75, value: 2028.75 },
315
+ { ticker: 'GOOGL', quantity: 8, price: 161.16, value: 429.27 }
316
+ ]
317
+ },
318
+ boundaries: {
319
+ fetchPortfolio: [
320
+ {
321
+ input: ['user1'],
322
+ output: {
323
+ id: 'portfolio1',
324
+ userId: 'user1',
325
+ stocks: [
326
+ { ticker: 'AAPL', quantity: 10 },
327
+ { ticker: 'MSFT', quantity: 5 },
328
+ { ticker: 'GOOGL', quantity: 8 }
329
+ ]
330
+ }
331
+ }
332
+ ],
333
+ fetchPrice: [
334
+ {
335
+ input: ['AAPL'],
336
+ output: 190.50
337
+ },
338
+ {
339
+ input: ['MSFT'],
340
+ output: 405.75
341
+ },
342
+ {
343
+ input: ['GOOGL'],
344
+ output: 161.16
345
+ }
346
+ ]
347
+ }
348
+ }
349
+
350
+ // Use replay mode for portfolio but proxy mode for prices
351
+ const [replayResult, replayError, replayLog] = await calculatePortfolioValue.safeReplay(
352
+ executionLog,
353
+ {
354
+ boundaries: {
355
+ fetchPortfolio: 'replay', // Use replay for portfolio
356
+ fetchPrice: 'proxy' // Use proxy (real execution) for prices
357
+ }
358
+ }
359
+ )
360
+
361
+ // Verify there was no error
362
+ expect(replayError).toBeNull()
363
+
364
+ // The result should reflect the updated AAPL price of 195.00
365
+ expect(replayResult).toHaveProperty('id', 'portfolio1')
366
+ expect(replayResult).toHaveProperty('userId', 'user1')
367
+
368
+ // Calculate expected value with current prices
369
+ const expectedValue =
370
+ (priceData['AAPL'] * 10) + // AAPL: 195.00 * 10
371
+ (priceData['MSFT'] * 5) + // MSFT: 421.90 * 5
372
+ (priceData['GOOGL'] * 8) // GOOGL: 171.04 * 8
373
+
374
+ expect(replayResult.totalValue).toBeCloseTo(expectedValue)
375
+
376
+ // Check AAPL price reflects the current price, not the replay data
377
+ const applStock = replayResult.stocks.find((s: { ticker: string }) => s.ticker === 'AAPL')
378
+ expect(applStock?.price).toBe(195.00)
379
+ expect(applStock?.value).toBeCloseTo(195.00 * 10)
380
+
381
+ // Verify the log shows the mixed sources correctly
382
+ expect(replayLog.boundaries.fetchPortfolio).toHaveLength(1)
383
+ expect(replayLog.boundaries.fetchPrice).toHaveLength(3)
384
+ })
385
+ })
@@ -0,0 +1,189 @@
1
+ import { Schema } from '@forgehive/schema'
2
+ import { createTask, ExecutionRecord } from '../index'
3
+
4
+ describe('safeReplay functionality tests', () => {
5
+ // Common variables
6
+ let prices: Record<string, number>
7
+ let boundaries: {
8
+ fetchData: (ticker: string) => Promise<number>
9
+ }
10
+
11
+ // ToDo: Add correct type for schema and getTickerPrice
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ let schema: Schema<Record<string, any>>
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ let getTickerPrice: any // Using any temporarily until we implement safeReplay
16
+
17
+ beforeEach(() => {
18
+ // Create a schema for the task
19
+ schema = new Schema({
20
+ ticker: Schema.string()
21
+ })
22
+
23
+ // Mock price data
24
+ prices = {
25
+ 'AAPL': 150.23
26
+ }
27
+
28
+ // Define the boundaries
29
+ boundaries = {
30
+ fetchData: async (ticker: string): Promise<number> => {
31
+ // check if the ticker is in the prices object
32
+ if (!prices[ticker as keyof typeof prices]) {
33
+ throw new Error(`Ticker ${ticker} not found in prices`)
34
+ }
35
+
36
+ return prices[ticker as keyof typeof prices]
37
+ }
38
+ }
39
+
40
+ // Create the task using createTask
41
+ getTickerPrice = createTask(
42
+ schema,
43
+ boundaries,
44
+ async ({ ticker }, { fetchData }) => {
45
+ const price = await fetchData(ticker)
46
+ return {
47
+ ticker,
48
+ price
49
+ }
50
+ }
51
+ )
52
+ })
53
+
54
+ it('Should replay a previous execution using the execution log and replay the fetchData boundary', async () => {
55
+ // Create a manual execution log
56
+ const executionLog: ExecutionRecord = {
57
+ input: { ticker: 'AAPL' },
58
+ output: {
59
+ ticker: 'AAPL',
60
+ price: 160.23
61
+ },
62
+ boundaries: {
63
+ fetchData: [
64
+ {
65
+ input: ['AAPL'],
66
+ output: 160.23
67
+ }
68
+ ]
69
+ }
70
+ }
71
+
72
+ // No safeReplay method yet, this will be implemented later
73
+ // This will be our test for that functionality
74
+ const [replayResult, replayError, replayLog] = await getTickerPrice.safeReplay(
75
+ executionLog
76
+ )
77
+
78
+ // Verify the replay execution
79
+ expect(replayError).toBeNull()
80
+ expect(replayResult).toMatchObject({
81
+ ticker: 'AAPL',
82
+ price: 150.23
83
+ })
84
+
85
+ expect(replayLog).toMatchObject({
86
+ input: { ticker: 'AAPL' },
87
+ output: {
88
+ ticker: 'AAPL',
89
+ price: 150.23
90
+ },
91
+ boundaries: {
92
+ fetchData: [
93
+ {
94
+ input: ['AAPL'],
95
+ output: 150.23,
96
+ }
97
+ ]
98
+ }
99
+ })
100
+ })
101
+
102
+ it('Should execute with mixed boundaries modes', async () => {
103
+ // Create a manual execution log for testing
104
+ const executionLog: ExecutionRecord = {
105
+ input: { ticker: 'AAPL' },
106
+ output: {
107
+ ticker: 'AAPL',
108
+ price: 160.23
109
+ },
110
+ boundaries: {
111
+ fetchData: [
112
+ {
113
+ input: ['AAPL'],
114
+ output: 160.23
115
+ }
116
+ ]
117
+ }
118
+ }
119
+
120
+ // Use mixed mode - replay for fetchData but execute logAccess
121
+ const [replayResult, replayError, replayLog] = await getTickerPrice.safeReplay(
122
+ executionLog,
123
+ {
124
+ boundaries: {
125
+ fetchData: 'replay',
126
+ }
127
+ }
128
+ )
129
+
130
+ // Verify the replay execution
131
+ expect(replayError).toBeNull()
132
+ expect(replayResult).toMatchObject({
133
+ ticker: 'AAPL',
134
+ price: 160.23
135
+ })
136
+
137
+ expect(replayLog).toMatchObject({
138
+ input: { ticker: 'AAPL' },
139
+ output: {
140
+ ticker: 'AAPL',
141
+ price: 160.23
142
+ },
143
+ boundaries: {
144
+ fetchData: [
145
+ {
146
+ input: ['AAPL'],
147
+ output: 160.23
148
+ }
149
+ ]
150
+ }
151
+ })
152
+ })
153
+
154
+ it('Should properly handle errors in boundary replay mode', async () => {
155
+ // Create a manual execution log with an error in the boundary
156
+ const executionLog: ExecutionRecord = {
157
+ input: { ticker: 'AAPL' },
158
+ output: null,
159
+ error: 'API error: Rate limit exceeded',
160
+ boundaries: {
161
+ fetchData: [
162
+ {
163
+ input: ['AAPL'],
164
+ error: 'API error: Rate limit exceeded'
165
+ }
166
+ ]
167
+ }
168
+ }
169
+
170
+ // Use replay mode for fetchData
171
+ const [replayResult, replayError, replayLog] = await getTickerPrice.safeReplay(
172
+ executionLog,
173
+ {
174
+ boundaries: {
175
+ fetchData: 'replay',
176
+ }
177
+ }
178
+ )
179
+
180
+ // Verify the replay execution - should have an error
181
+ expect(replayResult).toBeNull()
182
+ expect(replayError).not.toBeNull()
183
+ expect(replayError?.message).toBe('API error: Rate limit exceeded')
184
+
185
+ // The log should contain the error from the boundary
186
+ expect(replayLog.error).toBeDefined()
187
+ expect(replayLog.boundaries.fetchData[0].error).toBe('API error: Rate limit exceeded')
188
+ })
189
+ })