@forgehive/task 0.1.7 → 0.1.9
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.
- package/dist/index.d.ts +19 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +92 -6
- package/dist/index.js.map +1 -1
- package/dist/test/safe-replay-complex-boundary.test.d.ts +2 -0
- package/dist/test/safe-replay-complex-boundary.test.d.ts.map +1 -0
- package/dist/test/safe-replay-complex-boundary.test.js +314 -0
- package/dist/test/safe-replay-complex-boundary.test.js.map +1 -0
- package/dist/test/safe-replay.test.d.ts +2 -0
- package/dist/test/safe-replay.test.d.ts.map +1 -0
- package/dist/test/safe-replay.test.js +194 -0
- package/dist/test/safe-replay.test.js.map +1 -0
- package/dist/test/task-with-boundaries.test.js.map +1 -1
- package/dist/utils/boundary.d.ts +24 -5
- package/dist/utils/boundary.d.ts.map +1 -1
- package/dist/utils/boundary.js +16 -10
- package/dist/utils/boundary.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +168 -22
- package/src/test/safe-replay-complex-boundary.test.ts +385 -0
- package/src/test/safe-replay.test.ts +231 -0
- package/src/test/task-with-boundaries.test.ts +2 -2
- package/src/utils/boundary.ts +44 -17
package/src/index.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Schema, type SchemaType, type InferSchema, type SchemaDescription } from '@forgehive/schema'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createBoundary,
|
|
4
|
+
type Mode,
|
|
5
|
+
type Boundaries,
|
|
6
|
+
type WrappedBoundaries,
|
|
7
|
+
type WrappedBoundaryFunction,
|
|
8
|
+
type BoundaryRecord,
|
|
9
|
+
type BoundaryTapeData
|
|
10
|
+
} from './utils/boundary'
|
|
3
11
|
|
|
4
12
|
export interface Task {
|
|
5
13
|
id: string;
|
|
@@ -11,7 +19,17 @@ export interface Task {
|
|
|
11
19
|
export type BaseFunction = (...args: any[]) => any
|
|
12
20
|
|
|
13
21
|
// Re-export the boundary types for external use
|
|
14
|
-
export type {
|
|
22
|
+
export type {
|
|
23
|
+
BoundaryFunction,
|
|
24
|
+
WrappedBoundaryFunction,
|
|
25
|
+
Boundaries,
|
|
26
|
+
WrappedBoundaries,
|
|
27
|
+
Mode,
|
|
28
|
+
BoundarySuccessRecord,
|
|
29
|
+
BoundaryErrorRecord,
|
|
30
|
+
BoundaryRecord,
|
|
31
|
+
BoundaryTapeData
|
|
32
|
+
} from './utils/boundary'
|
|
15
33
|
|
|
16
34
|
// Re-export Schema for external use
|
|
17
35
|
export { Schema }
|
|
@@ -20,7 +38,14 @@ export interface TaskConfig<B extends Boundaries = Boundaries> {
|
|
|
20
38
|
schema?: Schema<Record<string, SchemaType>>
|
|
21
39
|
mode?: Mode
|
|
22
40
|
boundaries?: B
|
|
23
|
-
boundariesData?:
|
|
41
|
+
boundariesData?: BoundaryTapeData
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Interface for safeReplay configuration
|
|
45
|
+
export interface ReplayConfig<B extends Boundaries = Boundaries> {
|
|
46
|
+
boundaries: {
|
|
47
|
+
[K in keyof B]?: Mode
|
|
48
|
+
}
|
|
24
49
|
}
|
|
25
50
|
|
|
26
51
|
// ToDo: Add a type for the boundaries data
|
|
@@ -39,11 +64,7 @@ export interface TaskRecord<InputType = unknown, OutputType = unknown> {
|
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
// Make BoundaryLog generic
|
|
42
|
-
export type BoundaryLog<I extends unknown[] = unknown[], O = unknown> =
|
|
43
|
-
input: I
|
|
44
|
-
output: O | null
|
|
45
|
-
error: string | null
|
|
46
|
-
}
|
|
67
|
+
export type BoundaryLog<I extends unknown[] = unknown[], O = unknown> = BoundaryRecord<I, O>;
|
|
47
68
|
|
|
48
69
|
// Mapped type for boundaries
|
|
49
70
|
export type BoundaryLogsFor<B extends Boundaries> = {
|
|
@@ -89,7 +110,7 @@ export interface TaskInstanceType<Func extends BaseFunction = BaseFunction, B ex
|
|
|
89
110
|
// Boundary methods
|
|
90
111
|
asBoundary: () => (args: Parameters<Func>[0]) => Promise<ReturnType<Func>>
|
|
91
112
|
getBoundaries: () => WrappedBoundaries<B>
|
|
92
|
-
setBoundariesData: (boundariesData:
|
|
113
|
+
setBoundariesData: (boundariesData: BoundaryTapeData) => void
|
|
93
114
|
getBondariesData: () => Record<string, unknown>
|
|
94
115
|
|
|
95
116
|
// Mocking methods for testing
|
|
@@ -99,8 +120,17 @@ export interface TaskInstanceType<Func extends BaseFunction = BaseFunction, B ex
|
|
|
99
120
|
|
|
100
121
|
run: (argv?: Parameters<Func>[0]) => Promise<ReturnType<Func>>
|
|
101
122
|
safeRun: (argv?: Parameters<Func>[0]) => Promise<[ReturnType<Func> | null, Error | null, ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>]>
|
|
123
|
+
|
|
124
|
+
// Method for replaying task execution
|
|
125
|
+
safeReplay: (
|
|
126
|
+
executionLog: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>,
|
|
127
|
+
config: ReplayConfig<B>
|
|
128
|
+
) => Promise<[ReturnType<Func> | null, Error | null, ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>]>
|
|
102
129
|
}
|
|
103
130
|
|
|
131
|
+
// Define a type for the accumulated boundary data
|
|
132
|
+
type BoundaryData = Array<{input: unknown[], output?: unknown}>
|
|
133
|
+
|
|
104
134
|
// Helper type to infer schema type
|
|
105
135
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
136
|
export type InferSchemaType<S> = S extends Schema<any> ? InferSchema<S> : Record<string, unknown>;
|
|
@@ -110,9 +140,6 @@ export type TaskFunction<S, B extends Boundaries> =
|
|
|
110
140
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
111
141
|
(argv: InferSchemaType<S>, boundaries: WrappedBoundaries<B>) => Promise<any>;
|
|
112
142
|
|
|
113
|
-
// Define a type for the accumulated boundary data
|
|
114
|
-
type BoundaryData = Array<{input: unknown[], output?: unknown}>
|
|
115
|
-
|
|
116
143
|
export const Task = class Task<
|
|
117
144
|
B extends Boundaries = Boundaries,
|
|
118
145
|
Func extends BaseFunction = BaseFunction
|
|
@@ -125,7 +152,7 @@ export const Task = class Task<
|
|
|
125
152
|
_description?: string
|
|
126
153
|
|
|
127
154
|
_boundariesDefinition: B
|
|
128
|
-
_boundariesData:
|
|
155
|
+
_boundariesData: BoundaryTapeData | null
|
|
129
156
|
_accumulatedBoundariesData: Record<string, BoundaryData> = {}
|
|
130
157
|
|
|
131
158
|
// For storing mocks
|
|
@@ -250,7 +277,7 @@ export const Task = class Task<
|
|
|
250
277
|
})
|
|
251
278
|
}
|
|
252
279
|
|
|
253
|
-
setBoundariesData (boundariesData:
|
|
280
|
+
setBoundariesData (boundariesData: BoundaryTapeData): void {
|
|
254
281
|
this._boundariesData = boundariesData
|
|
255
282
|
|
|
256
283
|
// Update accumulated data as well
|
|
@@ -297,31 +324,36 @@ export const Task = class Task<
|
|
|
297
324
|
_createBounderies ({
|
|
298
325
|
definition,
|
|
299
326
|
baseData,
|
|
300
|
-
mode = 'proxy'
|
|
327
|
+
mode = 'proxy',
|
|
328
|
+
boundaryModes = {}
|
|
301
329
|
}: {
|
|
302
330
|
definition: B;
|
|
303
|
-
baseData:
|
|
331
|
+
baseData: BoundaryTapeData | null;
|
|
304
332
|
mode?: Mode;
|
|
333
|
+
boundaryModes?: Record<string, Mode | undefined>;
|
|
305
334
|
}): WrappedBoundaries<B> {
|
|
306
335
|
const boundariesFns: Record<string, WrappedBoundaryFunction> = {}
|
|
307
336
|
|
|
308
337
|
for (const name in definition) {
|
|
338
|
+
// Get the configured mode for this boundary or use default
|
|
339
|
+
const boundaryMode = boundaryModes[name] || mode
|
|
340
|
+
|
|
309
341
|
// Check if we have a mock for this boundary
|
|
310
342
|
if (this._boundaryMocks[name]) {
|
|
311
343
|
boundariesFns[name] = this._boundaryMocks[name]
|
|
312
344
|
continue
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
//
|
|
347
|
+
// Create the boundary
|
|
316
348
|
const boundary = createBoundary(definition[name])
|
|
317
349
|
|
|
318
350
|
if (baseData !== null && typeof baseData[name] !== 'undefined') {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
322
|
-
boundary.setTape(tape as any)
|
|
351
|
+
const boundaryData = baseData[name] as Array<BoundaryRecord<Parameters<B[Extract<keyof B, string>]>, Awaited<ReturnType<B[Extract<keyof B, string>]>>>>
|
|
352
|
+
boundary.setTape(boundaryData)
|
|
323
353
|
}
|
|
324
|
-
|
|
354
|
+
|
|
355
|
+
// Set the mode after setting the tape
|
|
356
|
+
boundary.setMode(boundaryMode)
|
|
325
357
|
|
|
326
358
|
boundariesFns[name] = boundary
|
|
327
359
|
}
|
|
@@ -436,6 +468,120 @@ export const Task = class Task<
|
|
|
436
468
|
return [output, error, logItem]
|
|
437
469
|
}
|
|
438
470
|
|
|
471
|
+
async safeReplay(
|
|
472
|
+
executionLog: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>,
|
|
473
|
+
config: ReplayConfig<B> = {
|
|
474
|
+
boundaries: {}
|
|
475
|
+
}
|
|
476
|
+
): Promise<[
|
|
477
|
+
ReturnType<Func> | null,
|
|
478
|
+
Error | null,
|
|
479
|
+
ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B>
|
|
480
|
+
]> {
|
|
481
|
+
// Extract the input from the execution log
|
|
482
|
+
const argv = executionLog.input
|
|
483
|
+
|
|
484
|
+
// Initialize log item for this replay
|
|
485
|
+
const logItem: ExecutionRecord<Parameters<Func>[0], ReturnType<Func>, B> = {
|
|
486
|
+
input: argv,
|
|
487
|
+
boundaries: {} as BoundaryLogsFor<B>
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Create boundaries for this replay execution with custom modes based on config
|
|
491
|
+
const boundariesConfig: BoundaryTapeData = {}
|
|
492
|
+
|
|
493
|
+
// Setup boundary data for replay mode boundaries
|
|
494
|
+
for (const name in this._boundariesDefinition) {
|
|
495
|
+
// Check if this boundary is configured for replay mode
|
|
496
|
+
const mode = config.boundaries[name] || 'proxy'
|
|
497
|
+
|
|
498
|
+
if (mode === 'replay' && executionLog.boundaries[name]) {
|
|
499
|
+
// Add boundary data from the execution log for replay mode
|
|
500
|
+
boundariesConfig[name] = executionLog.boundaries[name]
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Create fresh boundaries for this execution
|
|
505
|
+
const executionBoundaries = this._createBounderies({
|
|
506
|
+
definition: this._boundariesDefinition,
|
|
507
|
+
baseData: boundariesConfig,
|
|
508
|
+
mode: 'proxy',
|
|
509
|
+
boundaryModes: config.boundaries
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Start run for each boundary
|
|
513
|
+
for (const name in executionBoundaries) {
|
|
514
|
+
const boundary = executionBoundaries[name]
|
|
515
|
+
boundary.startRun()
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Handle schema validation - reusing the input from the execution log
|
|
519
|
+
if (this._schema) {
|
|
520
|
+
const validation = this._schema.safeParse(argv)
|
|
521
|
+
if (!validation.success) {
|
|
522
|
+
const errorDetails = validation.error?.errors.map(err =>
|
|
523
|
+
`${err.path.join('.')}: ${err.message}`
|
|
524
|
+
).join(', ')
|
|
525
|
+
|
|
526
|
+
const errorMessage = errorDetails
|
|
527
|
+
? `Invalid input on: ${errorDetails}`
|
|
528
|
+
: 'Invalid input'
|
|
529
|
+
|
|
530
|
+
logItem.error = errorMessage
|
|
531
|
+
logItem.output = executionLog.output // Keep the original output
|
|
532
|
+
|
|
533
|
+
// Copy the boundary data from the execution log
|
|
534
|
+
logItem.boundaries = executionLog.boundaries
|
|
535
|
+
|
|
536
|
+
this.emit(logItem)
|
|
537
|
+
return [null, new Error(errorMessage), logItem]
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let output: ReturnType<Func> | null = null
|
|
542
|
+
let error: Error | null = null
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
// Execute the task function with replay boundaries
|
|
546
|
+
output = await this._fn(
|
|
547
|
+
argv,
|
|
548
|
+
executionBoundaries as unknown as Parameters<Func>[1]
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
logItem.output = output
|
|
552
|
+
} catch (caughtError) {
|
|
553
|
+
const errorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError)
|
|
554
|
+
logItem.error = errorMessage
|
|
555
|
+
error = new Error(errorMessage)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Process boundary data after execution
|
|
559
|
+
const boundariesRunLog: BoundaryLogsFor<B> = {} as BoundaryLogsFor<B>
|
|
560
|
+
|
|
561
|
+
for (const name in executionBoundaries) {
|
|
562
|
+
const boundary = executionBoundaries[name]
|
|
563
|
+
const runData = boundary.getRunData()
|
|
564
|
+
|
|
565
|
+
// For boundaries in replay mode, use the original log data instead
|
|
566
|
+
const mode = config.boundaries[name] || 'proxy'
|
|
567
|
+
if (mode === 'replay' && executionLog.boundaries[name]) {
|
|
568
|
+
boundariesRunLog[name as keyof B] = executionLog.boundaries[name as keyof B]
|
|
569
|
+
} else {
|
|
570
|
+
// For other modes, use the actual run data
|
|
571
|
+
boundariesRunLog[name as keyof B] = runData as unknown as BoundaryLogsFor<B>[typeof name]
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Set boundaries in log item before emitting
|
|
576
|
+
logItem.boundaries = boundariesRunLog
|
|
577
|
+
|
|
578
|
+
// Emit the log item
|
|
579
|
+
this.emit(logItem)
|
|
580
|
+
|
|
581
|
+
// Return the output, error, and log item
|
|
582
|
+
return [output, error, logItem]
|
|
583
|
+
}
|
|
584
|
+
|
|
439
585
|
async run (argv?: Parameters<Func>[0]): Promise<ReturnType<Func>> {
|
|
440
586
|
const [result, error] = await this.safeRun(argv)
|
|
441
587
|
|
|
@@ -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
|
+
})
|