@bsv/sdk 1.8.0 → 1.8.1
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/cjs/package.json +1 -1
- package/dist/cjs/src/kvstore/GlobalKVStore.js +420 -0
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/cjs/src/kvstore/LocalKVStore.js +6 -6
- package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js +74 -0
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/cjs/src/kvstore/types.js +11 -0
- package/dist/cjs/src/kvstore/types.js.map +1 -0
- package/dist/cjs/src/overlay-tools/Historian.js +153 -0
- package/dist/cjs/src/overlay-tools/Historian.js.map +1 -0
- package/dist/cjs/src/script/templates/PushDrop.js +2 -2
- package/dist/cjs/src/script/templates/PushDrop.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/kvstore/GlobalKVStore.js +416 -0
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/esm/src/kvstore/LocalKVStore.js +6 -6
- package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/kvStoreInterpreter.js +47 -0
- package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/esm/src/kvstore/types.js +8 -0
- package/dist/esm/src/kvstore/types.js.map +1 -0
- package/dist/esm/src/overlay-tools/Historian.js +155 -0
- package/dist/esm/src/overlay-tools/Historian.js.map +1 -0
- package/dist/esm/src/script/templates/PushDrop.js +2 -2
- package/dist/esm/src/script/templates/PushDrop.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/kvstore/GlobalKVStore.d.ts +129 -0
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +22 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -0
- package/dist/types/src/kvstore/types.d.ts +106 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/Historian.d.ts +92 -0
- package/dist/types/src/overlay-tools/Historian.d.ts.map +1 -0
- package/dist/types/src/script/templates/PushDrop.d.ts +6 -5
- package/dist/types/src/script/templates/PushDrop.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +1 -1
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/script.md +7 -19
- package/docs/reference/transaction.md +53 -2
- package/package.json +1 -1
- package/src/kvstore/GlobalKVStore.ts +478 -0
- package/src/kvstore/LocalKVStore.ts +7 -7
- package/src/kvstore/__tests/GlobalKVStore.test.ts +965 -0
- package/src/kvstore/__tests/LocalKVStore.test.ts +72 -0
- package/src/kvstore/kvStoreInterpreter.ts +49 -0
- package/src/kvstore/types.ts +114 -0
- package/src/overlay-tools/Historian.ts +195 -0
- package/src/overlay-tools/__tests/Historian.test.ts +690 -0
- package/src/script/templates/PushDrop.ts +6 -5
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/** eslint-env jest */
|
|
2
|
+
import GlobalKVStore from '../GlobalKVStore.js'
|
|
3
|
+
import { WalletInterface, CreateActionResult, SignActionResult } from '../../wallet/Wallet.interfaces.js'
|
|
4
|
+
import Transaction from '../../transaction/Transaction.js'
|
|
5
|
+
import { Historian } from '../../overlay-tools/Historian.js'
|
|
6
|
+
import { kvStoreInterpreter } from '../kvStoreInterpreter.js'
|
|
7
|
+
import { PushDrop } from '../../script/index.js'
|
|
8
|
+
import * as Utils from '../../primitives/utils.js'
|
|
9
|
+
import { TopicBroadcaster, LookupResolver } from '../../overlay-tools/index.js'
|
|
10
|
+
import { KVStoreConfig } from '../types.js'
|
|
11
|
+
import { Beef } from '../../transaction/Beef.js'
|
|
12
|
+
import { ProtoWallet } from '../../wallet/ProtoWallet.js'
|
|
13
|
+
|
|
14
|
+
// --- Module mocks ------------------------------------------------------------
|
|
15
|
+
jest.mock('../../transaction/Transaction.js')
|
|
16
|
+
jest.mock('../../transaction/Beef.js')
|
|
17
|
+
jest.mock('../../overlay-tools/Historian.js')
|
|
18
|
+
jest.mock('../kvStoreInterpreter.js')
|
|
19
|
+
jest.mock('../../script/index.js')
|
|
20
|
+
jest.mock('../../primitives/utils.js')
|
|
21
|
+
jest.mock('../../overlay-tools/index.js')
|
|
22
|
+
jest.mock('../../wallet/ProtoWallet.js')
|
|
23
|
+
jest.mock('../../wallet/WalletClient.js')
|
|
24
|
+
|
|
25
|
+
// --- Typed shortcuts to mocked classes --------------------------------------
|
|
26
|
+
const MockTransaction = Transaction as jest.MockedClass<typeof Transaction>
|
|
27
|
+
const MockBeef = Beef as jest.MockedClass<typeof Beef>
|
|
28
|
+
const MockHistorian = Historian as jest.MockedClass<typeof Historian>
|
|
29
|
+
const MockPushDrop = PushDrop as jest.MockedClass<typeof PushDrop>
|
|
30
|
+
const MockUtils = Utils as jest.Mocked<typeof Utils>
|
|
31
|
+
const MockTopicBroadcaster = TopicBroadcaster as jest.MockedClass<typeof TopicBroadcaster>
|
|
32
|
+
const MockLookupResolver = LookupResolver as jest.MockedClass<typeof LookupResolver>
|
|
33
|
+
const MockProtoWallet = ProtoWallet as jest.MockedClass<typeof ProtoWallet>
|
|
34
|
+
|
|
35
|
+
// --- Test constants ----------------------------------------------------------
|
|
36
|
+
const TEST_TXID =
|
|
37
|
+
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
|
|
38
|
+
const TEST_CONTROLLER =
|
|
39
|
+
'02e3f2c4a5b6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3'
|
|
40
|
+
const TEST_KEY = 'testKey'
|
|
41
|
+
const TEST_VALUE = 'testValue'
|
|
42
|
+
|
|
43
|
+
// --- Helpers ----------------------------------------------------------------
|
|
44
|
+
type MTx = jest.Mocked<InstanceType<typeof Transaction>>
|
|
45
|
+
type MBeef = jest.Mocked<InstanceType<typeof Beef>> & { findOutput: jest.Mock }
|
|
46
|
+
type MHistorian = jest.Mocked<InstanceType<typeof Historian>>
|
|
47
|
+
type MResolver = jest.Mocked<InstanceType<typeof LookupResolver>>
|
|
48
|
+
type MBroadcaster = jest.Mocked<InstanceType<typeof TopicBroadcaster>>
|
|
49
|
+
type MProtoWallet = jest.Mocked<InstanceType<typeof ProtoWallet>>
|
|
50
|
+
|
|
51
|
+
function makeMockTx(): MTx {
|
|
52
|
+
return {
|
|
53
|
+
id: jest.fn().mockReturnValue(TEST_TXID),
|
|
54
|
+
// Only the properties used by GlobalKVStore are needed
|
|
55
|
+
outputs: [
|
|
56
|
+
{
|
|
57
|
+
lockingScript: {
|
|
58
|
+
toHex: jest.fn().mockReturnValue('mock_script'),
|
|
59
|
+
toArray: jest.fn().mockReturnValue([1, 2, 3]),
|
|
60
|
+
},
|
|
61
|
+
satoshis: 1,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
inputs: [],
|
|
65
|
+
} as any
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function primeTransactionMocks(tx: MTx) {
|
|
69
|
+
; (MockTransaction.fromAtomicBEEF as jest.Mock).mockReturnValue(tx)
|
|
70
|
+
; (MockTransaction.fromBEEF as jest.Mock).mockReturnValue(tx)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function primeBeefMocks(beef: MBeef, tx: MTx) {
|
|
74
|
+
beef.toBinary.mockReturnValue(Array.from(new Uint8Array([1, 2, 3])))
|
|
75
|
+
beef.findTxid.mockReturnValue({ tx } as any)
|
|
76
|
+
beef.findOutput = jest.fn().mockReturnValue(tx.outputs[0] as any)
|
|
77
|
+
MockBeef.mockImplementation(() => beef)
|
|
78
|
+
; (MockBeef as any).fromBinary = jest.fn().mockReturnValue(beef)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function primePushDropDecodeToValidValue() {
|
|
82
|
+
; (MockPushDrop as any).decode = jest.fn().mockReturnValue({
|
|
83
|
+
fields: [
|
|
84
|
+
Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
|
|
85
|
+
Array.from(Buffer.from(TEST_KEY)), // key
|
|
86
|
+
Array.from(Buffer.from(TEST_VALUE)), // value
|
|
87
|
+
Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
|
|
88
|
+
Array.from(Buffer.from('signature')), // signature
|
|
89
|
+
],
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function primeUtilsDefaults() {
|
|
94
|
+
MockUtils.toUTF8.mockImplementation((arr: any) => {
|
|
95
|
+
if (typeof arr === 'string') return arr
|
|
96
|
+
if (!Array.isArray(arr)) return TEST_VALUE
|
|
97
|
+
|
|
98
|
+
// Check for protocolID field (JSON for [1,"kvstore"])
|
|
99
|
+
if (arr.join(',') === '91,49,44,34,107,118,115,116,111,114,101,34,93') {
|
|
100
|
+
return '[1,"kvstore"]'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check for key field (TEST_KEY as bytes)
|
|
104
|
+
const testKeyBytes = Array.from(Buffer.from(TEST_KEY))
|
|
105
|
+
if (arr.join(',') === testKeyBytes.join(',')) {
|
|
106
|
+
return TEST_KEY
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default to TEST_VALUE for value field
|
|
110
|
+
return TEST_VALUE
|
|
111
|
+
})
|
|
112
|
+
MockUtils.toHex.mockImplementation((arr: any) => {
|
|
113
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
114
|
+
return TEST_CONTROLLER
|
|
115
|
+
}
|
|
116
|
+
return 'mock_hex'
|
|
117
|
+
})
|
|
118
|
+
MockUtils.toBase64.mockReturnValue('dGVzdEtleQ==') // base64 of 'testKey'
|
|
119
|
+
MockUtils.toArray.mockReturnValue([1, 2, 3, 4])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function primeWalletMocks() {
|
|
123
|
+
return {
|
|
124
|
+
getPublicKey: jest.fn().mockResolvedValue({ publicKey: TEST_CONTROLLER }),
|
|
125
|
+
createAction: jest.fn().mockResolvedValue({
|
|
126
|
+
tx: Array.from(new Uint8Array([1, 2, 3])),
|
|
127
|
+
txid: TEST_TXID,
|
|
128
|
+
signableTransaction: {
|
|
129
|
+
tx: Array.from(new Uint8Array([1, 2, 3])),
|
|
130
|
+
reference: 'ref123',
|
|
131
|
+
},
|
|
132
|
+
} as CreateActionResult),
|
|
133
|
+
signAction: jest.fn().mockResolvedValue({
|
|
134
|
+
tx: Array.from(new Uint8Array([1, 2, 3])),
|
|
135
|
+
txid: TEST_TXID,
|
|
136
|
+
} as SignActionResult),
|
|
137
|
+
} as unknown as jest.Mocked<WalletInterface>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function primeResolverEmpty(resolver: MResolver) {
|
|
141
|
+
resolver.query.mockResolvedValue({ type: 'output-list', outputs: [] } as any)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function primeResolverWithOneOutput(resolver: MResolver) {
|
|
145
|
+
const mockOutput = {
|
|
146
|
+
beef: Array.from(new Uint8Array([1, 2, 3])),
|
|
147
|
+
outputIndex: 0,
|
|
148
|
+
context: Array.from(new Uint8Array([4, 5, 6])),
|
|
149
|
+
}
|
|
150
|
+
resolver.query.mockResolvedValue({
|
|
151
|
+
type: 'output-list',
|
|
152
|
+
outputs: [mockOutput],
|
|
153
|
+
} as any)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function primeResolverWithMultipleOutputs(resolver: MResolver, count: number = 3) {
|
|
157
|
+
const mockOutputs = Array.from({ length: count }, (_, i) => ({
|
|
158
|
+
beef: Array.from(new Uint8Array([1, 2, 3, i])),
|
|
159
|
+
outputIndex: i,
|
|
160
|
+
context: Array.from(new Uint8Array([4, 5, 6, i])),
|
|
161
|
+
}))
|
|
162
|
+
resolver.query.mockResolvedValue({
|
|
163
|
+
type: 'output-list',
|
|
164
|
+
outputs: mockOutputs,
|
|
165
|
+
} as any)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Test suite --------------------------------------------------------------
|
|
169
|
+
describe('GlobalKVStore', () => {
|
|
170
|
+
let kvStore: GlobalKVStore
|
|
171
|
+
let mockWallet: jest.Mocked<WalletInterface>
|
|
172
|
+
let mockHistorian: MHistorian
|
|
173
|
+
let mockResolver: MResolver
|
|
174
|
+
let mockBroadcaster: MBroadcaster
|
|
175
|
+
let mockBeef: MBeef
|
|
176
|
+
let mockProtoWallet: MProtoWallet
|
|
177
|
+
let tx: MTx
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
jest.clearAllMocks()
|
|
181
|
+
|
|
182
|
+
// Wallet
|
|
183
|
+
mockWallet = primeWalletMocks()
|
|
184
|
+
|
|
185
|
+
// Tx/BEEF
|
|
186
|
+
tx = makeMockTx()
|
|
187
|
+
primeTransactionMocks(tx)
|
|
188
|
+
mockBeef = {
|
|
189
|
+
toBinary: jest.fn(),
|
|
190
|
+
findTxid: jest.fn(),
|
|
191
|
+
findOutput: jest.fn(),
|
|
192
|
+
} as any
|
|
193
|
+
primeBeefMocks(mockBeef, tx)
|
|
194
|
+
|
|
195
|
+
// Historian
|
|
196
|
+
mockHistorian = {
|
|
197
|
+
buildHistory: jest.fn().mockResolvedValue([TEST_VALUE]),
|
|
198
|
+
} as any
|
|
199
|
+
MockHistorian.mockImplementation(() => mockHistorian)
|
|
200
|
+
|
|
201
|
+
// PushDrop lock/unlock plumbing
|
|
202
|
+
const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
|
|
203
|
+
const mockPushDrop = {
|
|
204
|
+
lock: jest.fn().mockResolvedValue(mockLockingScript),
|
|
205
|
+
unlock: jest.fn().mockReturnValue({
|
|
206
|
+
sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' }),
|
|
207
|
+
}),
|
|
208
|
+
}
|
|
209
|
+
MockPushDrop.mockImplementation(() => mockPushDrop as any)
|
|
210
|
+
primePushDropDecodeToValidValue()
|
|
211
|
+
|
|
212
|
+
// Utils
|
|
213
|
+
primeUtilsDefaults()
|
|
214
|
+
|
|
215
|
+
// Resolver / Broadcaster
|
|
216
|
+
mockResolver = {
|
|
217
|
+
query: jest.fn(),
|
|
218
|
+
} as any
|
|
219
|
+
MockLookupResolver.mockImplementation(() => mockResolver)
|
|
220
|
+
mockBroadcaster = {
|
|
221
|
+
broadcast: jest.fn().mockResolvedValue({ success: true }),
|
|
222
|
+
} as any
|
|
223
|
+
MockTopicBroadcaster.mockImplementation(() => mockBroadcaster)
|
|
224
|
+
|
|
225
|
+
// Proto wallet
|
|
226
|
+
mockProtoWallet = {
|
|
227
|
+
createHmac: jest.fn().mockResolvedValue({ hmac: new Uint8Array(32) }),
|
|
228
|
+
verifySignature: jest.fn().mockResolvedValue({ valid: true }),
|
|
229
|
+
} as any
|
|
230
|
+
MockProtoWallet.mockImplementation(() => mockProtoWallet)
|
|
231
|
+
|
|
232
|
+
// SUT
|
|
233
|
+
kvStore = new GlobalKVStore({ wallet: mockWallet })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
// --------------------------------------------------------------------------
|
|
237
|
+
describe('Constructor', () => {
|
|
238
|
+
it('creates with default config', () => {
|
|
239
|
+
const store = new GlobalKVStore()
|
|
240
|
+
expect(store).toBeInstanceOf(GlobalKVStore)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('creates with custom config', () => {
|
|
244
|
+
const config: KVStoreConfig = {
|
|
245
|
+
wallet: mockWallet,
|
|
246
|
+
protocolID: [2, 'custom'],
|
|
247
|
+
tokenAmount: 500,
|
|
248
|
+
networkPreset: 'testnet',
|
|
249
|
+
}
|
|
250
|
+
const store = new GlobalKVStore(config)
|
|
251
|
+
expect(store).toBeInstanceOf(GlobalKVStore)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('initializes Historian with kvStoreInterpreter', () => {
|
|
255
|
+
expect(MockHistorian).toHaveBeenCalledWith(kvStoreInterpreter)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// --------------------------------------------------------------------------
|
|
260
|
+
describe('get', () => {
|
|
261
|
+
describe('happy paths', () => {
|
|
262
|
+
it('returns empty array when key not found', async () => {
|
|
263
|
+
primeResolverEmpty(mockResolver)
|
|
264
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
265
|
+
expect(Array.isArray(result)).toBe(true)
|
|
266
|
+
expect(result).toHaveLength(0)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
it('returns KVStoreEntry when a valid token exists', async () => {
|
|
271
|
+
primeResolverWithOneOutput(mockResolver)
|
|
272
|
+
|
|
273
|
+
const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER })
|
|
274
|
+
|
|
275
|
+
expect(result).toEqual({
|
|
276
|
+
key: TEST_KEY,
|
|
277
|
+
value: TEST_VALUE,
|
|
278
|
+
controller: expect.any(String),
|
|
279
|
+
protocolID: [1, 'kvstore']
|
|
280
|
+
})
|
|
281
|
+
expect(mockResolver.query).toHaveBeenCalledWith({
|
|
282
|
+
service: 'ls_kvstore',
|
|
283
|
+
query: expect.objectContaining({
|
|
284
|
+
key: TEST_KEY,
|
|
285
|
+
controller: TEST_CONTROLLER
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('returns entry with history when history=true', async () => {
|
|
291
|
+
primeResolverWithOneOutput(mockResolver)
|
|
292
|
+
mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
|
|
293
|
+
|
|
294
|
+
const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER }, { history: true })
|
|
295
|
+
|
|
296
|
+
expect(result).toEqual({
|
|
297
|
+
key: TEST_KEY,
|
|
298
|
+
value: TEST_VALUE,
|
|
299
|
+
controller: expect.any(String),
|
|
300
|
+
protocolID: [1, 'kvstore'],
|
|
301
|
+
history: ['oldValue', TEST_VALUE]
|
|
302
|
+
})
|
|
303
|
+
expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
|
|
304
|
+
expect.any(Object),
|
|
305
|
+
expect.objectContaining({ key: TEST_KEY })
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('supports querying by protocolID', async () => {
|
|
310
|
+
primeResolverWithOneOutput(mockResolver)
|
|
311
|
+
|
|
312
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
|
|
313
|
+
|
|
314
|
+
expect(Array.isArray(result)).toBe(true)
|
|
315
|
+
expect(mockResolver.query).toHaveBeenCalledWith({
|
|
316
|
+
service: 'ls_kvstore',
|
|
317
|
+
query: expect.objectContaining({
|
|
318
|
+
protocolID: [1, 'kvstore']
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('includes token data when includeToken=true for key queries', async () => {
|
|
324
|
+
primeResolverWithOneOutput(mockResolver)
|
|
325
|
+
|
|
326
|
+
const result = await kvStore.get({ key: TEST_KEY }, { includeToken: true })
|
|
327
|
+
|
|
328
|
+
expect(Array.isArray(result)).toBe(true)
|
|
329
|
+
expect(result).toHaveLength(1)
|
|
330
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
331
|
+
expect(result[0].token).toBeDefined()
|
|
332
|
+
expect(result[0].token).toEqual({
|
|
333
|
+
txid: TEST_TXID,
|
|
334
|
+
outputIndex: 0,
|
|
335
|
+
satoshis: 1,
|
|
336
|
+
beef: expect.any(Object)
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('includes token data when includeToken=true for protocolID queries', async () => {
|
|
342
|
+
primeResolverWithOneOutput(mockResolver)
|
|
343
|
+
|
|
344
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { includeToken: true })
|
|
345
|
+
|
|
346
|
+
expect(Array.isArray(result)).toBe(true)
|
|
347
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
348
|
+
expect(result[0].token).toBeDefined()
|
|
349
|
+
expect(result[0].token).toEqual({
|
|
350
|
+
txid: TEST_TXID,
|
|
351
|
+
outputIndex: 0,
|
|
352
|
+
satoshis: 1,
|
|
353
|
+
beef: expect.any(Object)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('excludes token data when includeToken=false (default)', async () => {
|
|
359
|
+
primeResolverWithOneOutput(mockResolver)
|
|
360
|
+
|
|
361
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
362
|
+
|
|
363
|
+
expect(Array.isArray(result)).toBe(true)
|
|
364
|
+
expect(result).toHaveLength(1)
|
|
365
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
366
|
+
expect(result[0].token).toBeUndefined()
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('supports protocolID queries with history', async () => {
|
|
371
|
+
primeResolverWithOneOutput(mockResolver)
|
|
372
|
+
mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
|
|
373
|
+
|
|
374
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
|
|
375
|
+
|
|
376
|
+
expect(Array.isArray(result)).toBe(true)
|
|
377
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
378
|
+
expect(result[0].history).toEqual(['oldValue', TEST_VALUE])
|
|
379
|
+
}
|
|
380
|
+
expect(mockHistorian.buildHistory).toHaveBeenCalled()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('excludes history for protocolID queries when history=false', async () => {
|
|
384
|
+
primeResolverWithOneOutput(mockResolver)
|
|
385
|
+
|
|
386
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: false })
|
|
387
|
+
|
|
388
|
+
expect(Array.isArray(result)).toBe(true)
|
|
389
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
390
|
+
expect(result[0].history).toBeUndefined()
|
|
391
|
+
}
|
|
392
|
+
expect(mockHistorian.buildHistory).not.toHaveBeenCalled()
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('calls buildHistory for each valid token when multiple outputs exist', async () => {
|
|
396
|
+
// This test verifies the key behavior: history building is called for each processed token
|
|
397
|
+
primeResolverWithMultipleOutputs(mockResolver, 3)
|
|
398
|
+
|
|
399
|
+
// Don't worry about making unique tokens - just verify the calls
|
|
400
|
+
mockHistorian.buildHistory.mockResolvedValue(['sample_history'])
|
|
401
|
+
|
|
402
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
|
|
403
|
+
|
|
404
|
+
expect(Array.isArray(result)).toBe(true)
|
|
405
|
+
|
|
406
|
+
// The key assertion: buildHistory should be called once per valid token processed
|
|
407
|
+
// Even if some tokens are duplicates due to mocking, we're testing the iteration logic
|
|
408
|
+
expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
|
|
409
|
+
expect.any(Object),
|
|
410
|
+
expect.objectContaining({ key: expect.any(String) })
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
// Since we have 3 outputs but they may resolve to the same token due to mocking,
|
|
414
|
+
// we just verify that buildHistory was called at least once
|
|
415
|
+
expect(mockHistorian.buildHistory).toHaveBeenCalled()
|
|
416
|
+
|
|
417
|
+
// Verify each returned entry has history
|
|
418
|
+
if (Array.isArray(result)) {
|
|
419
|
+
result.forEach(entry => {
|
|
420
|
+
expect(entry.history).toEqual(['sample_history'])
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('handles history building failures gracefully', async () => {
|
|
426
|
+
primeResolverWithOneOutput(mockResolver)
|
|
427
|
+
|
|
428
|
+
// Mock history building to fail
|
|
429
|
+
mockHistorian.buildHistory.mockRejectedValue(new Error('History build failed'))
|
|
430
|
+
|
|
431
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
|
|
432
|
+
|
|
433
|
+
expect(Array.isArray(result)).toBe(true)
|
|
434
|
+
|
|
435
|
+
// Implementation should continue processing even if history fails
|
|
436
|
+
// The entry should be skipped due to the continue in the catch block
|
|
437
|
+
if (Array.isArray(result)) {
|
|
438
|
+
expect(result.length).toBe(0)
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('combines includeToken and history options correctly', async () => {
|
|
443
|
+
primeResolverWithOneOutput(mockResolver)
|
|
444
|
+
mockHistorian.buildHistory.mockResolvedValue(['combined_test_history'])
|
|
445
|
+
|
|
446
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, {
|
|
447
|
+
history: true,
|
|
448
|
+
includeToken: true
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
expect(Array.isArray(result)).toBe(true)
|
|
452
|
+
|
|
453
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
454
|
+
const entry = result[0]
|
|
455
|
+
// Entry should have both history and token data
|
|
456
|
+
expect(entry.history).toEqual(['combined_test_history'])
|
|
457
|
+
expect(entry.token).toBeDefined()
|
|
458
|
+
expect(entry.token?.txid).toBe(TEST_TXID)
|
|
459
|
+
expect(entry.token?.outputIndex).toBe(0)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Verify buildHistory was called
|
|
463
|
+
expect(mockHistorian.buildHistory).toHaveBeenCalled()
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('sad paths', () => {
|
|
468
|
+
it('rejects when no query parameters provided', async () => {
|
|
469
|
+
await expect(kvStore.get({})).rejects.toThrow('Must specify either key, controller, or protocolID')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('propagates overlay errors', async () => {
|
|
473
|
+
mockResolver.query.mockRejectedValue(new Error('Network error'))
|
|
474
|
+
|
|
475
|
+
await expect(kvStore.get({ key: TEST_KEY })).rejects.toThrow('Network error')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('skips malformed candidates and returns empty array (invalid PushDrop format)', async () => {
|
|
479
|
+
primeResolverWithOneOutput(mockResolver)
|
|
480
|
+
|
|
481
|
+
const originalDecode = (MockPushDrop as any).decode
|
|
482
|
+
; (MockPushDrop as any).decode = jest.fn(() => {
|
|
483
|
+
throw new Error('Invalid PushDrop format')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
488
|
+
expect(Array.isArray(result)).toBe(true)
|
|
489
|
+
expect(result).toHaveLength(0)
|
|
490
|
+
} finally {
|
|
491
|
+
; (MockPushDrop as any).decode = originalDecode
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
describe('Query Parameter Combinations', () => {
|
|
497
|
+
describe('Single parameter queries (return arrays)', () => {
|
|
498
|
+
it('key only - returns array of entries matching key across all controllers', async () => {
|
|
499
|
+
primeResolverWithOneOutput(mockResolver)
|
|
500
|
+
|
|
501
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
502
|
+
|
|
503
|
+
expect(Array.isArray(result)).toBe(true)
|
|
504
|
+
expect(mockResolver.query).toHaveBeenCalledWith({
|
|
505
|
+
service: 'ls_kvstore',
|
|
506
|
+
query: { key: TEST_KEY }
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('controller only - returns array of entries by specific controller', async () => {
|
|
511
|
+
primeResolverWithOneOutput(mockResolver)
|
|
512
|
+
|
|
513
|
+
const result = await kvStore.get({ controller: TEST_CONTROLLER })
|
|
514
|
+
|
|
515
|
+
expect(Array.isArray(result)).toBe(true)
|
|
516
|
+
expect(mockResolver.query).toHaveBeenCalledWith({
|
|
517
|
+
service: 'ls_kvstore',
|
|
518
|
+
query: { controller: TEST_CONTROLLER }
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('protocolID only - returns array of entries under protocol', async () => {
|
|
523
|
+
primeResolverWithOneOutput(mockResolver)
|
|
524
|
+
|
|
525
|
+
const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
|
|
526
|
+
|
|
527
|
+
expect(Array.isArray(result)).toBe(true)
|
|
528
|
+
expect(mockResolver.query).toHaveBeenCalledWith({
|
|
529
|
+
service: 'ls_kvstore',
|
|
530
|
+
query: { protocolID: [1, 'kvstore'] }
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
describe('Combined parameter queries', () => {
|
|
536
|
+
it('key + controller - returns single result (unique combination)', async () => {
|
|
537
|
+
primeResolverWithOneOutput(mockResolver)
|
|
538
|
+
|
|
539
|
+
const result = await kvStore.get({
|
|
540
|
+
key: TEST_KEY,
|
|
541
|
+
controller: TEST_CONTROLLER
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
// Should return single entry, not array
|
|
545
|
+
expect(result).not.toBeNull()
|
|
546
|
+
expect(Array.isArray(result)).toBe(false)
|
|
547
|
+
if (result && !Array.isArray(result)) {
|
|
548
|
+
expect(result.key).toBe(TEST_KEY)
|
|
549
|
+
expect(result.controller).toBe(TEST_CONTROLLER)
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('key + protocolID - returns array (multiple results possible)', async () => {
|
|
554
|
+
primeResolverWithOneOutput(mockResolver)
|
|
555
|
+
|
|
556
|
+
const result = await kvStore.get({
|
|
557
|
+
key: TEST_KEY,
|
|
558
|
+
protocolID: [1, 'kvstore']
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
expect(Array.isArray(result)).toBe(true)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('controller + protocolID - returns array (multiple results possible)', async () => {
|
|
565
|
+
primeResolverWithOneOutput(mockResolver)
|
|
566
|
+
|
|
567
|
+
const result = await kvStore.get({
|
|
568
|
+
controller: TEST_CONTROLLER,
|
|
569
|
+
protocolID: [1, 'kvstore']
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
expect(Array.isArray(result)).toBe(true)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('key + controller + protocolID - returns single result (most specific)', async () => {
|
|
576
|
+
primeResolverWithOneOutput(mockResolver)
|
|
577
|
+
|
|
578
|
+
const result = await kvStore.get({
|
|
579
|
+
key: TEST_KEY,
|
|
580
|
+
controller: TEST_CONTROLLER,
|
|
581
|
+
protocolID: [1, 'kvstore']
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// key + controller combination should return single result
|
|
585
|
+
expect(result).not.toBeNull()
|
|
586
|
+
expect(Array.isArray(result)).toBe(false)
|
|
587
|
+
})
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
describe('Return type consistency', () => {
|
|
591
|
+
it('key+controller always returns single result or undefined', async () => {
|
|
592
|
+
primeResolverEmpty(mockResolver)
|
|
593
|
+
|
|
594
|
+
const result = await kvStore.get({
|
|
595
|
+
key: TEST_KEY,
|
|
596
|
+
controller: TEST_CONTROLLER
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
expect(result).toBeUndefined()
|
|
600
|
+
expect(Array.isArray(result)).toBe(false)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('all other combinations always return arrays', async () => {
|
|
604
|
+
primeResolverEmpty(mockResolver)
|
|
605
|
+
|
|
606
|
+
const testCases = [
|
|
607
|
+
{ key: TEST_KEY },
|
|
608
|
+
{ controller: TEST_CONTROLLER },
|
|
609
|
+
{ protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
|
|
610
|
+
{ key: TEST_KEY, protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
|
|
611
|
+
{ controller: TEST_CONTROLLER, protocolID: [1, 'kvstore'] as [1, 'kvstore'] }
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
for (const query of testCases) {
|
|
615
|
+
const result = await kvStore.get(query)
|
|
616
|
+
expect(Array.isArray(result)).toBe(true)
|
|
617
|
+
expect((result as any[]).length).toBe(0)
|
|
618
|
+
}
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
// --------------------------------------------------------------------------
|
|
625
|
+
describe('set', () => {
|
|
626
|
+
describe('happy paths', () => {
|
|
627
|
+
it('creates a new token when key does not exist', async () => {
|
|
628
|
+
primeResolverEmpty(mockResolver)
|
|
629
|
+
const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
|
|
630
|
+
|
|
631
|
+
expect(mockWallet.createAction).toHaveBeenCalledWith(
|
|
632
|
+
expect.objectContaining({
|
|
633
|
+
description: `Create KVStore value for ${TEST_KEY}`,
|
|
634
|
+
outputs: expect.arrayContaining([
|
|
635
|
+
expect.objectContaining({
|
|
636
|
+
satoshis: 1,
|
|
637
|
+
outputDescription: 'KVStore token',
|
|
638
|
+
}),
|
|
639
|
+
])
|
|
640
|
+
}),
|
|
641
|
+
undefined
|
|
642
|
+
)
|
|
643
|
+
expect(outpoint).toBe(`${TEST_TXID}.0`)
|
|
644
|
+
expect(mockBroadcaster.broadcast).toHaveBeenCalled()
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('updates existing token when one exists', async () => {
|
|
648
|
+
// Mock the queryOverlay to return an entry with a token
|
|
649
|
+
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
|
|
650
|
+
mockQueryOverlay.mockResolvedValue([{
|
|
651
|
+
key: TEST_KEY,
|
|
652
|
+
value: 'oldValue',
|
|
653
|
+
controller: TEST_CONTROLLER,
|
|
654
|
+
token: {
|
|
655
|
+
txid: TEST_TXID,
|
|
656
|
+
outputIndex: 0,
|
|
657
|
+
beef: mockBeef,
|
|
658
|
+
satoshis: 1
|
|
659
|
+
}
|
|
660
|
+
}])
|
|
661
|
+
|
|
662
|
+
const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
|
|
663
|
+
|
|
664
|
+
expect(mockWallet.createAction).toHaveBeenCalledWith(
|
|
665
|
+
expect.objectContaining({
|
|
666
|
+
description: `Update KVStore value for ${TEST_KEY}`,
|
|
667
|
+
inputs: expect.arrayContaining([
|
|
668
|
+
expect.objectContaining({
|
|
669
|
+
inputDescription: 'Previous KVStore token'
|
|
670
|
+
})
|
|
671
|
+
])
|
|
672
|
+
}),
|
|
673
|
+
undefined
|
|
674
|
+
)
|
|
675
|
+
expect(mockWallet.signAction).toHaveBeenCalled()
|
|
676
|
+
expect(outpoint).toBe(`${TEST_TXID}.0`)
|
|
677
|
+
|
|
678
|
+
mockQueryOverlay.mockRestore()
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('is safe under concurrent operations (key locking)', async () => {
|
|
682
|
+
primeResolverEmpty(mockResolver)
|
|
683
|
+
|
|
684
|
+
const promise1 = kvStore.set(TEST_KEY, 'value1')
|
|
685
|
+
const promise2 = kvStore.set(TEST_KEY, 'value2')
|
|
686
|
+
|
|
687
|
+
await Promise.all([promise1, promise2])
|
|
688
|
+
|
|
689
|
+
// Both operations should have completed successfully
|
|
690
|
+
expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
|
|
691
|
+
})
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
describe('sad paths', () => {
|
|
695
|
+
it('rejects invalid key', async () => {
|
|
696
|
+
await expect(kvStore.set('', TEST_VALUE)).rejects.toThrow('Key must be a non-empty string.')
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('rejects invalid value', async () => {
|
|
700
|
+
await expect(kvStore.set(TEST_KEY, null as any)).rejects.toThrow('Value must be a string.')
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('propagates wallet createAction failures', async () => {
|
|
704
|
+
primeResolverEmpty(mockResolver)
|
|
705
|
+
mockWallet.createAction.mockRejectedValue(new Error('Wallet error'))
|
|
706
|
+
|
|
707
|
+
await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('Wallet error')
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it('surface broadcast errors in set', async () => {
|
|
711
|
+
primeResolverEmpty(mockResolver)
|
|
712
|
+
mockBroadcaster.broadcast.mockRejectedValue(new Error('overlay down'))
|
|
713
|
+
await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('overlay down')
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
// --------------------------------------------------------------------------
|
|
719
|
+
describe('remove', () => {
|
|
720
|
+
describe('happy paths', () => {
|
|
721
|
+
it('removes an existing token', async () => {
|
|
722
|
+
// Mock the queryOverlay to return an entry with a token
|
|
723
|
+
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
|
|
724
|
+
mockQueryOverlay.mockResolvedValue([{
|
|
725
|
+
key: TEST_KEY,
|
|
726
|
+
value: TEST_VALUE,
|
|
727
|
+
controller: TEST_CONTROLLER,
|
|
728
|
+
token: {
|
|
729
|
+
txid: TEST_TXID,
|
|
730
|
+
outputIndex: 0,
|
|
731
|
+
beef: mockBeef,
|
|
732
|
+
satoshis: 1
|
|
733
|
+
}
|
|
734
|
+
}])
|
|
735
|
+
|
|
736
|
+
const txid = await kvStore.remove(TEST_KEY)
|
|
737
|
+
|
|
738
|
+
expect(mockWallet.createAction).toHaveBeenCalledWith(
|
|
739
|
+
expect.objectContaining({
|
|
740
|
+
description: `Remove KVStore value for ${TEST_KEY}`,
|
|
741
|
+
inputs: expect.arrayContaining([
|
|
742
|
+
expect.objectContaining({
|
|
743
|
+
inputDescription: 'KVStore token to remove'
|
|
744
|
+
})
|
|
745
|
+
])
|
|
746
|
+
}),
|
|
747
|
+
undefined
|
|
748
|
+
)
|
|
749
|
+
expect(txid).toBe(TEST_TXID)
|
|
750
|
+
|
|
751
|
+
mockQueryOverlay.mockRestore()
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it('supports custom outputs on removal', async () => {
|
|
755
|
+
// Mock the queryOverlay to return an entry with a token
|
|
756
|
+
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
|
|
757
|
+
mockQueryOverlay.mockResolvedValue([{
|
|
758
|
+
key: TEST_KEY,
|
|
759
|
+
value: TEST_VALUE,
|
|
760
|
+
controller: TEST_CONTROLLER,
|
|
761
|
+
token: {
|
|
762
|
+
txid: TEST_TXID,
|
|
763
|
+
outputIndex: 0,
|
|
764
|
+
beef: mockBeef,
|
|
765
|
+
satoshis: 1
|
|
766
|
+
}
|
|
767
|
+
}])
|
|
768
|
+
|
|
769
|
+
const customOutputs = [
|
|
770
|
+
{
|
|
771
|
+
satoshis: 500,
|
|
772
|
+
lockingScript: 'customTransferScript',
|
|
773
|
+
outputDescription: 'Custom token transfer output',
|
|
774
|
+
},
|
|
775
|
+
]
|
|
776
|
+
|
|
777
|
+
const txid = await kvStore.remove(TEST_KEY, customOutputs)
|
|
778
|
+
|
|
779
|
+
expect(mockWallet.createAction).toHaveBeenCalledWith(
|
|
780
|
+
expect.objectContaining({
|
|
781
|
+
outputs: customOutputs,
|
|
782
|
+
}),
|
|
783
|
+
undefined
|
|
784
|
+
)
|
|
785
|
+
expect(txid).toBe(TEST_TXID)
|
|
786
|
+
|
|
787
|
+
mockQueryOverlay.mockRestore()
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
describe('sad paths', () => {
|
|
792
|
+
it('rejects invalid key', async () => {
|
|
793
|
+
await expect(kvStore.remove('')).rejects.toThrow('Key must be a non-empty string.')
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('throws when key does not exist', async () => {
|
|
797
|
+
primeResolverEmpty(mockResolver)
|
|
798
|
+
|
|
799
|
+
await expect(kvStore.remove(TEST_KEY)).rejects.toThrow(
|
|
800
|
+
'The item did not exist, no item was deleted.'
|
|
801
|
+
)
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
it('propagates wallet signAction failures', async () => {
|
|
805
|
+
// Mock the queryOverlay to return an entry with a token
|
|
806
|
+
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
|
|
807
|
+
mockQueryOverlay.mockResolvedValue([{
|
|
808
|
+
key: TEST_KEY,
|
|
809
|
+
value: TEST_VALUE,
|
|
810
|
+
controller: TEST_CONTROLLER,
|
|
811
|
+
token: {
|
|
812
|
+
txid: TEST_TXID,
|
|
813
|
+
outputIndex: 0,
|
|
814
|
+
beef: mockBeef,
|
|
815
|
+
satoshis: 1
|
|
816
|
+
}
|
|
817
|
+
}])
|
|
818
|
+
|
|
819
|
+
; (mockWallet.signAction as jest.Mock).mockRejectedValue(new Error('Sign failed'))
|
|
820
|
+
|
|
821
|
+
await expect(kvStore.remove(TEST_KEY)).rejects.toThrow('Sign failed')
|
|
822
|
+
|
|
823
|
+
mockQueryOverlay.mockRestore()
|
|
824
|
+
})
|
|
825
|
+
})
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
// --------------------------------------------------------------------------
|
|
829
|
+
describe('getWithHistory', () => {
|
|
830
|
+
it('delegates to get(key, undefined, controller, true) and returns value + history', async () => {
|
|
831
|
+
primeResolverWithOneOutput(mockResolver)
|
|
832
|
+
mockHistorian.buildHistory.mockResolvedValue([TEST_VALUE])
|
|
833
|
+
|
|
834
|
+
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
|
|
835
|
+
|
|
836
|
+
expect(Array.isArray(result)).toBe(true)
|
|
837
|
+
expect(result).toHaveLength(1)
|
|
838
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
839
|
+
expect(result[0]).toEqual({
|
|
840
|
+
key: TEST_KEY,
|
|
841
|
+
value: TEST_VALUE,
|
|
842
|
+
controller: expect.any(String),
|
|
843
|
+
protocolID: [1, 'kvstore'],
|
|
844
|
+
history: [TEST_VALUE],
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('returns empty array when key not found', async () => {
|
|
850
|
+
primeResolverEmpty(mockResolver)
|
|
851
|
+
|
|
852
|
+
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
|
|
853
|
+
expect(Array.isArray(result)).toBe(true)
|
|
854
|
+
expect(result).toHaveLength(0)
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
// --------------------------------------------------------------------------
|
|
859
|
+
describe('Integration-ish behaviors', () => {
|
|
860
|
+
it('uses PushDrop for signature verification', async () => {
|
|
861
|
+
primeResolverWithOneOutput(mockResolver)
|
|
862
|
+
|
|
863
|
+
await kvStore.get({ key: TEST_KEY })
|
|
864
|
+
|
|
865
|
+
expect(MockProtoWallet).toHaveBeenCalledWith('anyone')
|
|
866
|
+
expect(mockProtoWallet.verifySignature).toHaveBeenCalledWith({
|
|
867
|
+
data: expect.any(Array),
|
|
868
|
+
signature: expect.any(Array),
|
|
869
|
+
counterparty: TEST_CONTROLLER,
|
|
870
|
+
protocolID: [1, 'kvstore'],
|
|
871
|
+
keyID: TEST_KEY
|
|
872
|
+
})
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it('caches identity key (single wallet.getPublicKey call across operations)', async () => {
|
|
876
|
+
primeResolverEmpty(mockResolver)
|
|
877
|
+
await kvStore.set('key1', 'value1')
|
|
878
|
+
await kvStore.set('key2', 'value2')
|
|
879
|
+
|
|
880
|
+
expect(mockWallet.getPublicKey).toHaveBeenCalledTimes(1)
|
|
881
|
+
expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('properly cleans up empty lock queues to prevent memory leaks', async () => {
|
|
885
|
+
primeResolverEmpty(mockResolver)
|
|
886
|
+
|
|
887
|
+
// Get reference to private keyLocks Map
|
|
888
|
+
const keyLocks = (kvStore as any).keyLocks as Map<string, Array<() => void>>
|
|
889
|
+
|
|
890
|
+
// Initially empty
|
|
891
|
+
expect(keyLocks.size).toBe(0)
|
|
892
|
+
|
|
893
|
+
// Perform operations on different keys
|
|
894
|
+
await kvStore.set('key1', 'value1')
|
|
895
|
+
await kvStore.set('key2', 'value2')
|
|
896
|
+
await kvStore.set('key3', 'value3')
|
|
897
|
+
|
|
898
|
+
// After operations complete, keyLocks should be empty (no memory leak)
|
|
899
|
+
expect(keyLocks.size).toBe(0)
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// --------------------------------------------------------------------------
|
|
904
|
+
describe('Error recovery & edge cases', () => {
|
|
905
|
+
it('returns empty array for empty overlay response', async () => {
|
|
906
|
+
primeResolverEmpty(mockResolver)
|
|
907
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
908
|
+
expect(Array.isArray(result)).toBe(true)
|
|
909
|
+
expect(result).toHaveLength(0)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('skips malformed transactions and returns empty array', async () => {
|
|
913
|
+
primeResolverWithOneOutput(mockResolver)
|
|
914
|
+
|
|
915
|
+
const originalFromBEEF = (MockTransaction as any).fromBEEF
|
|
916
|
+
; (MockTransaction as any).fromBEEF = jest.fn(() => {
|
|
917
|
+
throw new Error('Malformed transaction data')
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
const result = await kvStore.get({ key: TEST_KEY })
|
|
922
|
+
expect(Array.isArray(result)).toBe(true)
|
|
923
|
+
expect(result).toHaveLength(0)
|
|
924
|
+
} finally {
|
|
925
|
+
; (MockTransaction as any).fromBEEF = originalFromBEEF
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('handles edge cases where no valid tokens pass full validation', async () => {
|
|
930
|
+
// This test verifies that when tokens exist but fail validation (signature, etc),
|
|
931
|
+
// the method gracefully returns empty array rather than throwing
|
|
932
|
+
primeResolverWithOneOutput(mockResolver)
|
|
933
|
+
|
|
934
|
+
// Make signature verification fail (this could be a realistic failure mode)
|
|
935
|
+
const originalVerifySignature = mockProtoWallet.verifySignature
|
|
936
|
+
mockProtoWallet.verifySignature = jest.fn().mockRejectedValue(new Error('Signature verification failed'))
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
|
|
940
|
+
expect(Array.isArray(result)).toBe(true)
|
|
941
|
+
expect(result).toHaveLength(0)
|
|
942
|
+
} finally {
|
|
943
|
+
// Restore original mock
|
|
944
|
+
mockProtoWallet.verifySignature = originalVerifySignature
|
|
945
|
+
}
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('when no valid outputs (decode fails), get(..., history=true) still returns empty array', async () => {
|
|
949
|
+
primeResolverWithOneOutput(mockResolver)
|
|
950
|
+
|
|
951
|
+
const originalDecode = (MockPushDrop as any).decode
|
|
952
|
+
; (MockPushDrop as any).decode = jest.fn(() => {
|
|
953
|
+
throw new Error('Invalid token format')
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
try {
|
|
957
|
+
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
|
|
958
|
+
expect(Array.isArray(result)).toBe(true)
|
|
959
|
+
expect(result).toHaveLength(0)
|
|
960
|
+
} finally {
|
|
961
|
+
; (MockPushDrop as any).decode = originalDecode
|
|
962
|
+
}
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
})
|