-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.test.ts
250 lines (222 loc) · 7.32 KB
/
index.test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import { describe, test, expect } from 'bun:test'
import { createTransaction, createBracket, createScope } from '../src'
/**
* A tiny helper to simulate asynchronous operations:
* - If "shouldReject" is true, it rejects with "value"
* - Otherwise, resolves after "ms" with "value"
*/
function delay<T>(ms: number, value: T, shouldReject = false): Promise<T> {
return new Promise<T>((resolve, reject) =>
setTimeout(() => (shouldReject ? reject(value) : resolve(value)), ms)
)
}
/**
* A minimal "mock" function that stores calls for assertion:
* const mockFn = createMock()
* mockFn("hello", 123)
* mockFn.calls -> [ ["hello", 123] ]
*/
function createMock() {
const calls: any[][] = []
const mockFn = (...args: any[]) => {
calls.push(args)
}
mockFn.calls = calls
return mockFn
}
describe('Transaction Builder (sync & async)', () => {
test('single-step with synchronous acquire and release', async () => {
// Acquire returns a plain object (no promise),
// Release also returns void (no promise).
const releaseMock = createMock()
const builder = createTransaction()
const pipeline = builder.add(
'res1',
(prev) => {
expect(prev).toEqual({})
// synchronous acquire
return { foo: 'bar', isSync: true }
},
(resource, exit) => {
// synchronous release
releaseMock(resource, exit.isError, exit.error)
}
)
const run = pipeline.build()
const results = await run() // still awaits, but everything inside is sync
expect(results).toEqual({ res1: { foo: 'bar', isSync: true } })
// Release called once with isError = false
expect(releaseMock.calls.length).toBe(1)
expect(releaseMock.calls[0]).toEqual([
{ foo: 'bar', isSync: true },
false,
undefined
])
})
test('single-step with asynchronous acquire and release', async () => {
// Acquire returns a promise
// Release also returns a promise
const releaseMock = createMock()
const builder = createTransaction()
const pipeline = builder.add(
'res1',
async (prev) => {
expect(prev).toEqual({})
// async acquire
const value = await delay(5, { foo: 'bar', isAsync: true })
return value
},
async (resource, exit) => {
// async release
await delay(5, null)
releaseMock(resource, exit.isError, exit.error)
}
)
const run = pipeline.build()
const results = await run()
expect(results).toEqual({ res1: { foo: 'bar', isAsync: true } })
// Release called once with isError = false
expect(releaseMock.calls.length).toBe(1)
expect(releaseMock.calls[0]).toEqual([
{ foo: 'bar', isAsync: true },
false,
undefined
])
})
test('multi-step with mixed sync/async', async () => {
const releaseDbMock = createMock()
const releaseFileMock = createMock()
const builder = createBracket() // same as createTransaction
const pipeline = builder
.add(
'db',
(prev) => {
// sync acquire
expect(prev).toEqual({})
return { client: 'fakeDbClient', sync: true }
},
async (db, exit) => {
// async release
await delay(5, null)
releaseDbMock(db, exit.isError, exit.error)
}
)
.add(
'file',
async (prev) => {
// async acquire
expect(prev.db).toEqual({ client: 'fakeDbClient', sync: true })
return delay(5, { fileHandle: 'fakeFileHandle', async: true })
},
(file, exit) => {
// sync release
releaseFileMock(file, exit.isError, exit.error)
}
)
const run = pipeline.build()
const results = await run()
expect(results).toEqual({
db: { client: 'fakeDbClient', sync: true },
file: { fileHandle: 'fakeFileHandle', async: true }
})
// Releases in reverse order: file -> db
expect(releaseFileMock.calls.length).toBe(1)
expect(releaseFileMock.calls[0]).toEqual([
{ fileHandle: 'fakeFileHandle', async: true },
false,
undefined
])
expect(releaseDbMock.calls.length).toBe(1)
expect(releaseDbMock.calls[0]).toEqual([
{ client: 'fakeDbClient', sync: true },
false,
undefined
])
})
test('rollback if second (async) acquire fails, first release is sync', async () => {
// We'll intentionally fail the second resource's acquire,
// and confirm the first resource's release is called with isError=true
const releaseFirstMock = createMock()
const builder = createTransaction()
const pipeline = builder
.add(
'first',
() => {
// sync acquire
return { name: 'first', sync: true }
},
(resource, exit) => {
// sync release
releaseFirstMock(resource, exit.isError, exit.error)
}
)
.add('failing', async () => {
// async acquire that fails
await delay(5, null)
throw new Error('Acquisition of failing resource failed')
})
const run = pipeline.build()
let thrownErr: unknown
try {
await run()
} catch (err) {
thrownErr = err
}
// Because second acquisition fails, we expect an error
expect(thrownErr).toBeInstanceOf(Error)
const errorMsg = (thrownErr as Error).message
expect(errorMsg).toContain('Acquisition of failing resource failed')
// The first resource was rolled back with isError=true
expect(releaseFirstMock.calls.length).toBe(1)
expect(releaseFirstMock.calls[0][0]).toEqual({ name: 'first', sync: true })
expect(releaseFirstMock.calls[0][1]).toBe(true) // isError
expect(releaseFirstMock.calls[0][2]).toBeInstanceOf(Error) // the original error
})
test('all acquires succeed, but final release fails (sync or async), throws AggregateError', async () => {
const release1Mock = createMock()
const release2Failing = async () => {
// async release that fails
await delay(5, null)
throw new Error('release2 fails on success path')
}
const builder = createScope() // same as createTransaction
const pipeline = builder
.add(
'res1',
() => {
// sync acquire
return { name: 'res1' }
},
(res, exit) => {
// sync release
release1Mock(res, exit.isError, exit.error)
}
)
.add(
'res2',
async (prev) => {
// async acquire
await delay(5, null)
expect(prev.res1).toEqual({ name: 'res1' })
return { name: 'res2' }
},
release2Failing
)
const run = pipeline.build()
let thrownErr: unknown
// run() will throw due to the failing release
const results = await run().catch((err) => {
thrownErr = err
})
// So we never actually "resolve" results in a normal sense
// with the aggregator logic, we throw at the end instead
expect(results).toBeUndefined()
expect(thrownErr).toBeInstanceOf(AggregateError)
const aggErr = thrownErr as AggregateError
expect(aggErr.errors.length).toBe(1)
expect(aggErr.errors[0]?.message).toEqual('release2 fails on success path')
// The first release function was called successfully
expect(release1Mock.calls.length).toBe(1)
expect(release1Mock.calls[0]).toEqual([{ name: 'res1' }, false, undefined])
})
})