@bsv/sdk
Version:
BSV Blockchain Software Development Kit
1,105 lines (918 loc) • 39 kB
text/typescript
/** eslint-env jest */
import GlobalKVStore from '../GlobalKVStore.js'
import { WalletInterface, CreateActionResult, SignActionResult } from '../../wallet/Wallet.interfaces.js'
import Transaction from '../../transaction/Transaction.js'
import { Historian } from '../../overlay-tools/Historian.js'
import { kvStoreInterpreter } from '../kvStoreInterpreter.js'
import { PushDrop } from '../../script/index.js'
import * as Utils from '../../primitives/utils.js'
import { TopicBroadcaster, LookupResolver } from '../../overlay-tools/index.js'
import { KVStoreConfig } from '../types.js'
import { Beef } from '../../transaction/Beef.js'
import { ProtoWallet } from '../../wallet/ProtoWallet.js'
// --- Module mocks ------------------------------------------------------------
jest.mock('../../transaction/Transaction.js')
jest.mock('../../transaction/Beef.js')
jest.mock('../../overlay-tools/Historian.js')
jest.mock('../kvStoreInterpreter.js')
jest.mock('../../script/index.js')
jest.mock('../../primitives/utils.js')
jest.mock('../../overlay-tools/index.js', () => {
const actual = jest.requireActual('../../overlay-tools/index.js')
return {
...actual,
// Keep withDoubleSpendRetry as the real implementation
withDoubleSpendRetry: actual.withDoubleSpendRetry,
// Mock the classes
TopicBroadcaster: jest.fn(),
LookupResolver: jest.fn()
}
})
jest.mock('../../wallet/ProtoWallet.js')
jest.mock('../../wallet/WalletClient.js')
// --- Typed shortcuts to mocked classes --------------------------------------
const MockTransaction = Transaction as jest.MockedClass<typeof Transaction>
const MockBeef = Beef as jest.MockedClass<typeof Beef>
const MockHistorian = Historian as jest.MockedClass<typeof Historian>
const MockPushDrop = PushDrop as jest.MockedClass<typeof PushDrop>
const MockUtils = Utils as jest.Mocked<typeof Utils>
const MockTopicBroadcaster = TopicBroadcaster as jest.MockedClass<typeof TopicBroadcaster>
const MockLookupResolver = LookupResolver as jest.MockedClass<typeof LookupResolver>
const MockProtoWallet = ProtoWallet as jest.MockedClass<typeof ProtoWallet>
// --- Test constants ----------------------------------------------------------
const TEST_TXID =
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
const TEST_CONTROLLER =
'02e3f2c4a5b6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3'
const TEST_KEY = 'testKey'
const TEST_VALUE = 'testValue'
// --- Helpers ----------------------------------------------------------------
type MTx = jest.Mocked<InstanceType<typeof Transaction>>
type MBeef = jest.Mocked<InstanceType<typeof Beef>> & { findOutput: jest.Mock }
type MHistorian = jest.Mocked<InstanceType<typeof Historian>>
type MResolver = jest.Mocked<InstanceType<typeof LookupResolver>>
type MBroadcaster = jest.Mocked<InstanceType<typeof TopicBroadcaster>>
type MProtoWallet = jest.Mocked<InstanceType<typeof ProtoWallet>>
function makeMockTx(): MTx {
return {
id: jest.fn().mockReturnValue(TEST_TXID),
// Only the properties used by GlobalKVStore are needed
outputs: [
{
lockingScript: {
toHex: jest.fn().mockReturnValue('mock_script'),
toArray: jest.fn().mockReturnValue([1, 2, 3]),
},
satoshis: 1,
},
],
inputs: [],
} as any
}
function primeTransactionMocks(tx: MTx) {
; (MockTransaction.fromAtomicBEEF as jest.Mock).mockReturnValue(tx)
; (MockTransaction.fromBEEF as jest.Mock).mockReturnValue(tx)
}
function primeBeefMocks(beef: MBeef, tx: MTx) {
beef.toBinary.mockReturnValue(Array.from(new Uint8Array([1, 2, 3])))
beef.findTxid.mockReturnValue({ tx } as any)
beef.findOutput = jest.fn().mockReturnValue(tx.outputs[0] as any)
MockBeef.mockImplementation(() => beef)
; (MockBeef as any).fromBinary = jest.fn().mockReturnValue(beef)
}
function primePushDropDecodeToValidValue() {
; (MockPushDrop as any).decode = jest.fn().mockReturnValue({
fields: [
Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
Array.from(Buffer.from(TEST_KEY)), // key
Array.from(Buffer.from(TEST_VALUE)), // value
Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
Array.from(Buffer.from('signature')), // signature
],
})
}
function primeUtilsDefaults() {
MockUtils.toUTF8.mockImplementation((arr: any) => {
if (typeof arr === 'string') return arr
if (!Array.isArray(arr)) return TEST_VALUE
// Check for protocolID field (JSON for [1,"kvstore"])
if (arr.join(',') === '91,49,44,34,107,118,115,116,111,114,101,34,93') {
return '[1,"kvstore"]'
}
// Check for key field (TEST_KEY as bytes)
const testKeyBytes = Array.from(Buffer.from(TEST_KEY))
if (arr.join(',') === testKeyBytes.join(',')) {
return TEST_KEY
}
// Default to TEST_VALUE for value field
return TEST_VALUE
})
MockUtils.toHex.mockImplementation((arr: any) => {
if (Array.isArray(arr) && arr.length > 0) {
return TEST_CONTROLLER
}
return 'mock_hex'
})
MockUtils.toBase64.mockReturnValue('dGVzdEtleQ==') // base64 of 'testKey'
MockUtils.toArray.mockReturnValue([1, 2, 3, 4])
}
function primeWalletMocks() {
return {
getPublicKey: jest.fn().mockResolvedValue({ publicKey: TEST_CONTROLLER }),
createAction: jest.fn().mockResolvedValue({
tx: Array.from(new Uint8Array([1, 2, 3])),
txid: TEST_TXID,
signableTransaction: {
tx: Array.from(new Uint8Array([1, 2, 3])),
reference: 'ref123',
},
} as CreateActionResult),
signAction: jest.fn().mockResolvedValue({
tx: Array.from(new Uint8Array([1, 2, 3])),
txid: TEST_TXID,
} as SignActionResult),
} as unknown as jest.Mocked<WalletInterface>
}
function primeResolverEmpty(resolver: MResolver) {
resolver.query.mockResolvedValue({ type: 'output-list', outputs: [] } as any)
}
function primeResolverWithOneOutput(resolver: MResolver) {
const mockOutput = {
beef: Array.from(new Uint8Array([1, 2, 3])),
outputIndex: 0,
context: Array.from(new Uint8Array([4, 5, 6])),
}
resolver.query.mockResolvedValue({
type: 'output-list',
outputs: [mockOutput],
} as any)
}
function primeResolverWithMultipleOutputs(resolver: MResolver, count: number = 3) {
const mockOutputs = Array.from({ length: count }, (_, i) => ({
beef: Array.from(new Uint8Array([1, 2, 3, i])),
outputIndex: i,
context: Array.from(new Uint8Array([4, 5, 6, i])),
}))
resolver.query.mockResolvedValue({
type: 'output-list',
outputs: mockOutputs,
} as any)
}
// --- Test suite --------------------------------------------------------------
describe('GlobalKVStore', () => {
let kvStore: GlobalKVStore
let mockWallet: jest.Mocked<WalletInterface>
let mockHistorian: MHistorian
let mockResolver: MResolver
let mockBroadcaster: MBroadcaster
let mockBeef: MBeef
let mockProtoWallet: MProtoWallet
let tx: MTx
beforeEach(() => {
jest.clearAllMocks()
// Wallet
mockWallet = primeWalletMocks()
// Tx/BEEF
tx = makeMockTx()
primeTransactionMocks(tx)
mockBeef = {
toBinary: jest.fn(),
findTxid: jest.fn(),
findOutput: jest.fn(),
} as any
primeBeefMocks(mockBeef, tx)
// Historian
mockHistorian = {
buildHistory: jest.fn().mockResolvedValue([TEST_VALUE]),
} as any
MockHistorian.mockImplementation(() => mockHistorian)
// PushDrop lock/unlock plumbing
const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
const mockPushDrop = {
lock: jest.fn().mockResolvedValue(mockLockingScript),
unlock: jest.fn().mockReturnValue({
sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' }),
}),
}
MockPushDrop.mockImplementation(() => mockPushDrop as any)
primePushDropDecodeToValidValue()
// Utils
primeUtilsDefaults()
// Resolver / Broadcaster
mockResolver = {
query: jest.fn(),
} as any
MockLookupResolver.mockImplementation(() => mockResolver)
mockBroadcaster = {
broadcast: jest.fn().mockResolvedValue({ success: true }),
} as any
MockTopicBroadcaster.mockImplementation(() => mockBroadcaster)
// Proto wallet
mockProtoWallet = {
createHmac: jest.fn().mockResolvedValue({ hmac: new Uint8Array(32) }),
verifySignature: jest.fn().mockResolvedValue({ valid: true }),
} as any
MockProtoWallet.mockImplementation(() => mockProtoWallet)
// SUT
kvStore = new GlobalKVStore({ wallet: mockWallet })
})
// --------------------------------------------------------------------------
describe('Constructor', () => {
it('creates with default config', () => {
const store = new GlobalKVStore()
expect(store).toBeInstanceOf(GlobalKVStore)
})
it('creates with custom config', () => {
const config: KVStoreConfig = {
wallet: mockWallet,
protocolID: [2, 'custom'],
tokenAmount: 500,
networkPreset: 'testnet',
}
const store = new GlobalKVStore(config)
expect(store).toBeInstanceOf(GlobalKVStore)
})
it('initializes Historian with kvStoreInterpreter', () => {
expect(MockHistorian).toHaveBeenCalledWith(kvStoreInterpreter)
})
})
// --------------------------------------------------------------------------
describe('get', () => {
describe('happy paths', () => {
it('returns empty array when key not found', async () => {
primeResolverEmpty(mockResolver)
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
it('returns KVStoreEntry when a valid token exists', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER })
expect(result).toEqual({
key: TEST_KEY,
value: TEST_VALUE,
controller: expect.any(String),
protocolID: [1, 'kvstore']
})
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({
key: TEST_KEY,
controller: TEST_CONTROLLER
})
})
})
it('returns entry with tags when token includes tags field', async () => {
primeResolverWithOneOutput(mockResolver)
const originalDecode = (MockPushDrop as any).decode
;(MockPushDrop as any).decode = jest.fn().mockReturnValue({
fields: [
Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
Array.from(Buffer.from(TEST_KEY)), // key
Array.from(Buffer.from(TEST_VALUE)), // value
Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
// tags field as JSON string so Utils.toUTF8 returns it directly
'["alpha","beta"]',
Array.from(Buffer.from('signature')) // signature
]
})
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].tags).toEqual(['alpha', 'beta'])
}
;(MockPushDrop as any).decode = originalDecode
})
it('omits tags when token is in old-format (no tags field)', async () => {
primeResolverWithOneOutput(mockResolver)
// primePushDropDecodeToValidValue() already sets old-format (no tags)
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].tags).toBeUndefined()
}
})
it('returns entry with history when history=true', async () => {
primeResolverWithOneOutput(mockResolver)
mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER }, { history: true })
expect(result).toEqual({
key: TEST_KEY,
value: TEST_VALUE,
controller: expect.any(String),
protocolID: [1, 'kvstore'],
history: ['oldValue', TEST_VALUE]
})
expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ key: TEST_KEY })
)
})
it('supports querying by protocolID', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({
protocolID: [1, 'kvstore']
})
})
})
it('forwards tags-only queries to the resolver', async () => {
primeResolverEmpty(mockResolver)
const tags = ['group:music', 'env:prod']
const result = await kvStore.get({ tags })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({ tags })
})
})
it('forwards tagQueryMode "all" to the resolver (default)', async () => {
primeResolverEmpty(mockResolver)
const tags = ['music', 'rock']
const result = await kvStore.get({ tags, tagQueryMode: 'all' })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({
tags,
tagQueryMode: 'all'
})
})
})
it('forwards tagQueryMode "any" to the resolver', async () => {
primeResolverEmpty(mockResolver)
const tags = ['music', 'jazz']
const result = await kvStore.get({ tags, tagQueryMode: 'any' })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({
tags,
tagQueryMode: 'any'
})
})
})
it('defaults to tagQueryMode "all" when not specified', async () => {
primeResolverEmpty(mockResolver)
const tags = ['category:news']
const result = await kvStore.get({ tags })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: expect.objectContaining({ tags })
})
// Verify tagQueryMode is not explicitly set (will default to 'all' on server side)
const call = (mockResolver.query as jest.Mock).mock.calls[0][0]
expect(call.query.tagQueryMode).toBeUndefined()
})
it('includes token data when includeToken=true for key queries', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ key: TEST_KEY }, { includeToken: true })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].token).toBeDefined()
expect(result[0].token).toEqual({
txid: TEST_TXID,
outputIndex: 0,
satoshis: 1,
beef: expect.any(Object)
})
}
})
it('includes token data when includeToken=true for protocolID queries', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { includeToken: true })
expect(Array.isArray(result)).toBe(true)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].token).toBeDefined()
expect(result[0].token).toEqual({
txid: TEST_TXID,
outputIndex: 0,
satoshis: 1,
beef: expect.any(Object)
})
}
})
it('excludes token data when includeToken=false (default)', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].token).toBeUndefined()
}
})
it('supports protocolID queries with history', async () => {
primeResolverWithOneOutput(mockResolver)
mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
expect(Array.isArray(result)).toBe(true)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].history).toEqual(['oldValue', TEST_VALUE])
}
expect(mockHistorian.buildHistory).toHaveBeenCalled()
})
it('excludes history for protocolID queries when history=false', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: false })
expect(Array.isArray(result)).toBe(true)
if (Array.isArray(result) && result.length > 0) {
expect(result[0].history).toBeUndefined()
}
expect(mockHistorian.buildHistory).not.toHaveBeenCalled()
})
it('calls buildHistory for each valid token when multiple outputs exist', async () => {
// This test verifies the key behavior: history building is called for each processed token
primeResolverWithMultipleOutputs(mockResolver, 3)
// Don't worry about making unique tokens - just verify the calls
mockHistorian.buildHistory.mockResolvedValue(['sample_history'])
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
expect(Array.isArray(result)).toBe(true)
// The key assertion: buildHistory should be called once per valid token processed
// Even if some tokens are duplicates due to mocking, we're testing the iteration logic
expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ key: expect.any(String) })
)
// Since we have 3 outputs but they may resolve to the same token due to mocking,
// we just verify that buildHistory was called at least once
expect(mockHistorian.buildHistory).toHaveBeenCalled()
// Verify each returned entry has history
if (Array.isArray(result)) {
result.forEach(entry => {
expect(entry.history).toEqual(['sample_history'])
})
}
})
it('handles history building failures gracefully', async () => {
primeResolverWithOneOutput(mockResolver)
// Mock history building to fail
mockHistorian.buildHistory.mockRejectedValue(new Error('History build failed'))
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
expect(Array.isArray(result)).toBe(true)
// Implementation should continue processing even if history fails
// The entry should be skipped due to the continue in the catch block
if (Array.isArray(result)) {
expect(result.length).toBe(0)
}
})
it('combines includeToken and history options correctly', async () => {
primeResolverWithOneOutput(mockResolver)
mockHistorian.buildHistory.mockResolvedValue(['combined_test_history'])
const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, {
history: true,
includeToken: true
})
expect(Array.isArray(result)).toBe(true)
if (Array.isArray(result) && result.length > 0) {
const entry = result[0]
// Entry should have both history and token data
expect(entry.history).toEqual(['combined_test_history'])
expect(entry.token).toBeDefined()
expect(entry.token?.txid).toBe(TEST_TXID)
expect(entry.token?.outputIndex).toBe(0)
}
// Verify buildHistory was called
expect(mockHistorian.buildHistory).toHaveBeenCalled()
})
})
describe('sad paths', () => {
it('rejects when no query parameters provided', async () => {
await expect(kvStore.get({})).rejects.toThrow('Must specify either key, controller, or protocolID')
})
it('propagates overlay errors', async () => {
mockResolver.query.mockRejectedValue(new Error('Network error'))
await expect(kvStore.get({ key: TEST_KEY })).rejects.toThrow('Network error')
})
it('skips malformed candidates and returns empty array (invalid PushDrop format)', async () => {
primeResolverWithOneOutput(mockResolver)
const originalDecode = (MockPushDrop as any).decode
; (MockPushDrop as any).decode = jest.fn(() => {
throw new Error('Invalid PushDrop format')
})
try {
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
} finally {
; (MockPushDrop as any).decode = originalDecode
}
})
})
describe('Query Parameter Combinations', () => {
describe('Single parameter queries (return arrays)', () => {
it('key only - returns array of entries matching key across all controllers', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: { key: TEST_KEY }
})
})
it('controller only - returns array of entries by specific controller', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ controller: TEST_CONTROLLER })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: { controller: TEST_CONTROLLER }
})
})
it('protocolID only - returns array of entries under protocol', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
expect(Array.isArray(result)).toBe(true)
expect(mockResolver.query).toHaveBeenCalledWith({
service: 'ls_kvstore',
query: { protocolID: [1, 'kvstore'] }
})
})
})
describe('Combined parameter queries', () => {
it('key + controller - returns single result (unique combination)', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({
key: TEST_KEY,
controller: TEST_CONTROLLER
})
// Should return single entry, not array
expect(result).not.toBeNull()
expect(Array.isArray(result)).toBe(false)
if (result && !Array.isArray(result)) {
expect(result.key).toBe(TEST_KEY)
expect(result.controller).toBe(TEST_CONTROLLER)
}
})
it('key + protocolID - returns array (multiple results possible)', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({
key: TEST_KEY,
protocolID: [1, 'kvstore']
})
expect(Array.isArray(result)).toBe(true)
})
it('controller + protocolID - returns array (multiple results possible)', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({
controller: TEST_CONTROLLER,
protocolID: [1, 'kvstore']
})
expect(Array.isArray(result)).toBe(true)
})
it('key + controller + protocolID - returns single result (most specific)', async () => {
primeResolverWithOneOutput(mockResolver)
const result = await kvStore.get({
key: TEST_KEY,
controller: TEST_CONTROLLER,
protocolID: [1, 'kvstore']
})
// key + controller combination should return single result
expect(result).not.toBeNull()
expect(Array.isArray(result)).toBe(false)
})
})
describe('Return type consistency', () => {
it('key+controller always returns single result or undefined', async () => {
primeResolverEmpty(mockResolver)
const result = await kvStore.get({
key: TEST_KEY,
controller: TEST_CONTROLLER
})
expect(result).toBeUndefined()
expect(Array.isArray(result)).toBe(false)
})
it('all other combinations always return arrays', async () => {
primeResolverEmpty(mockResolver)
const testCases = [
{ key: TEST_KEY },
{ controller: TEST_CONTROLLER },
{ protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
{ key: TEST_KEY, protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
{ controller: TEST_CONTROLLER, protocolID: [1, 'kvstore'] as [1, 'kvstore'] }
]
for (const query of testCases) {
const result = await kvStore.get(query)
expect(Array.isArray(result)).toBe(true)
expect((result as any[]).length).toBe(0)
}
})
})
})
})
// --------------------------------------------------------------------------
describe('set', () => {
describe('happy paths', () => {
it('creates a new token when key does not exist', async () => {
primeResolverEmpty(mockResolver)
const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
expect(mockWallet.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: `Create KVStore value for ${TEST_KEY}`,
outputs: expect.arrayContaining([
expect.objectContaining({
satoshis: 1,
outputDescription: 'KVStore token',
}),
])
}),
undefined
)
expect(outpoint).toBe(`${TEST_TXID}.0`)
expect(mockBroadcaster.broadcast).toHaveBeenCalled()
})
it('includes tags field in locking script when options.tags provided', async () => {
primeResolverEmpty(mockResolver)
// Override PushDrop to capture the instance used within set()
const originalImpl = (MockPushDrop as any).mockImplementation
const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
const localPushDrop = {
lock: jest.fn().mockResolvedValue(mockLockingScript),
unlock: jest.fn().mockReturnValue({
sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' })
})
}
;(MockPushDrop as any).mockImplementation(() => localPushDrop as any)
const providedTags = ['primary', 'news']
await kvStore.set(TEST_KEY, TEST_VALUE, { tags: providedTags })
// Validate PushDrop.lock was called with 5 fields (protocolID, key, value, controller, tags)
expect(localPushDrop.lock).toHaveBeenCalled()
const lockArgs = (localPushDrop.lock as jest.Mock).mock.calls[0]
const fields = lockArgs[0]
expect(Array.isArray(fields)).toBe(true)
expect(fields.length).toBe(5)
// Restore original implementation
;(MockPushDrop as any).mockImplementation = originalImpl
})
it('updates existing token when one exists', async () => {
// Mock the queryOverlay to return an entry with a token
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
mockQueryOverlay.mockResolvedValue([{
key: TEST_KEY,
value: 'oldValue',
controller: TEST_CONTROLLER,
token: {
txid: TEST_TXID,
outputIndex: 0,
beef: mockBeef,
satoshis: 1
}
}])
const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
expect(mockWallet.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: `Update KVStore value for ${TEST_KEY}`,
inputs: expect.arrayContaining([
expect.objectContaining({
inputDescription: 'Previous KVStore token'
})
])
}),
undefined
)
expect(mockWallet.signAction).toHaveBeenCalled()
expect(outpoint).toBe(`${TEST_TXID}.0`)
mockQueryOverlay.mockRestore()
})
it('is safe under concurrent operations (key locking)', async () => {
primeResolverEmpty(mockResolver)
const promise1 = kvStore.set(TEST_KEY, 'value1')
const promise2 = kvStore.set(TEST_KEY, 'value2')
await Promise.all([promise1, promise2])
// Both operations should have completed successfully
expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
})
})
describe('sad paths', () => {
it('rejects invalid key', async () => {
await expect(kvStore.set('', TEST_VALUE)).rejects.toThrow('Key must be a non-empty string.')
})
it('rejects invalid value', async () => {
await expect(kvStore.set(TEST_KEY, null as any)).rejects.toThrow('Value must be a string.')
})
it('propagates wallet createAction failures', async () => {
primeResolverEmpty(mockResolver)
mockWallet.createAction.mockRejectedValue(new Error('Wallet error'))
await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('Wallet error')
})
it('surface broadcast errors in set', async () => {
primeResolverEmpty(mockResolver)
mockBroadcaster.broadcast.mockRejectedValue(new Error('overlay down'))
await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('overlay down')
})
})
})
// --------------------------------------------------------------------------
describe('remove', () => {
describe('happy paths', () => {
it('removes an existing token', async () => {
// Mock the queryOverlay to return an entry with a token
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
mockQueryOverlay.mockResolvedValue([{
key: TEST_KEY,
value: TEST_VALUE,
controller: TEST_CONTROLLER,
token: {
txid: TEST_TXID,
outputIndex: 0,
beef: mockBeef,
satoshis: 1
}
}])
const txid = await kvStore.remove(TEST_KEY)
expect(mockWallet.createAction).toHaveBeenCalledWith(
expect.objectContaining({
description: `Remove KVStore value for ${TEST_KEY}`,
inputs: expect.arrayContaining([
expect.objectContaining({
inputDescription: 'KVStore token to remove'
})
])
}),
undefined
)
expect(txid).toBe(TEST_TXID)
mockQueryOverlay.mockRestore()
})
it('supports custom outputs on removal', async () => {
// Mock the queryOverlay to return an entry with a token
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
mockQueryOverlay.mockResolvedValue([{
key: TEST_KEY,
value: TEST_VALUE,
controller: TEST_CONTROLLER,
token: {
txid: TEST_TXID,
outputIndex: 0,
beef: mockBeef,
satoshis: 1
}
}])
const customOutputs = [
{
satoshis: 500,
lockingScript: 'customTransferScript',
outputDescription: 'Custom token transfer output',
},
]
const txid = await kvStore.remove(TEST_KEY, customOutputs)
expect(mockWallet.createAction).toHaveBeenCalledWith(
expect.objectContaining({
outputs: customOutputs,
}),
undefined
)
expect(txid).toBe(TEST_TXID)
mockQueryOverlay.mockRestore()
})
})
describe('sad paths', () => {
it('rejects invalid key', async () => {
await expect(kvStore.remove('')).rejects.toThrow('Key must be a non-empty string.')
})
it('throws when key does not exist', async () => {
primeResolverEmpty(mockResolver)
await expect(kvStore.remove(TEST_KEY)).rejects.toThrow(
'The item did not exist, no item was deleted.'
)
})
it('propagates wallet signAction failures', async () => {
// Mock the queryOverlay to return an entry with a token
const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
mockQueryOverlay.mockResolvedValue([{
key: TEST_KEY,
value: TEST_VALUE,
controller: TEST_CONTROLLER,
token: {
txid: TEST_TXID,
outputIndex: 0,
beef: mockBeef,
satoshis: 1
}
}])
; (mockWallet.signAction as jest.Mock).mockRejectedValue(new Error('Sign failed'))
await expect(kvStore.remove(TEST_KEY)).rejects.toThrow('Sign failed')
mockQueryOverlay.mockRestore()
})
})
})
// --------------------------------------------------------------------------
describe('getWithHistory', () => {
it('delegates to get(key, undefined, controller, true) and returns value + history', async () => {
primeResolverWithOneOutput(mockResolver)
mockHistorian.buildHistory.mockResolvedValue([TEST_VALUE])
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(1)
if (Array.isArray(result) && result.length > 0) {
expect(result[0]).toEqual({
key: TEST_KEY,
value: TEST_VALUE,
controller: expect.any(String),
protocolID: [1, 'kvstore'],
history: [TEST_VALUE],
})
}
})
it('returns empty array when key not found', async () => {
primeResolverEmpty(mockResolver)
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
})
// --------------------------------------------------------------------------
describe('Integration-ish behaviors', () => {
it('uses PushDrop for signature verification', async () => {
primeResolverWithOneOutput(mockResolver)
await kvStore.get({ key: TEST_KEY })
expect(MockProtoWallet).toHaveBeenCalledWith('anyone')
expect(mockProtoWallet.verifySignature).toHaveBeenCalledWith({
data: expect.any(Array),
signature: expect.any(Array),
counterparty: TEST_CONTROLLER,
protocolID: [1, 'kvstore'],
keyID: TEST_KEY
})
})
it('caches identity key (single wallet.getPublicKey call across operations)', async () => {
primeResolverEmpty(mockResolver)
await kvStore.set('key1', 'value1')
await kvStore.set('key2', 'value2')
expect(mockWallet.getPublicKey).toHaveBeenCalledTimes(1)
expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
})
it('properly cleans up empty lock queues to prevent memory leaks', async () => {
primeResolverEmpty(mockResolver)
// Get reference to private keyLocks Map
const keyLocks = (kvStore as any).keyLocks as Map<string, Array<() => void>>
// Initially empty
expect(keyLocks.size).toBe(0)
// Perform operations on different keys
await kvStore.set('key1', 'value1')
await kvStore.set('key2', 'value2')
await kvStore.set('key3', 'value3')
// After operations complete, keyLocks should be empty (no memory leak)
expect(keyLocks.size).toBe(0)
})
})
// --------------------------------------------------------------------------
describe('Error recovery & edge cases', () => {
it('returns empty array for empty overlay response', async () => {
primeResolverEmpty(mockResolver)
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
it('skips malformed transactions and returns empty array', async () => {
primeResolverWithOneOutput(mockResolver)
const originalFromBEEF = (MockTransaction as any).fromBEEF
; (MockTransaction as any).fromBEEF = jest.fn(() => {
throw new Error('Malformed transaction data')
})
try {
const result = await kvStore.get({ key: TEST_KEY })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
} finally {
; (MockTransaction as any).fromBEEF = originalFromBEEF
}
})
it('handles edge cases where no valid tokens pass full validation', async () => {
// This test verifies that when tokens exist but fail validation (signature, etc),
// the method gracefully returns empty array rather than throwing
primeResolverWithOneOutput(mockResolver)
// Make signature verification fail (this could be a realistic failure mode)
const originalVerifySignature = mockProtoWallet.verifySignature
mockProtoWallet.verifySignature = jest.fn().mockRejectedValue(new Error('Signature verification failed'))
try {
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
} finally {
// Restore original mock
mockProtoWallet.verifySignature = originalVerifySignature
}
})
it('when no valid outputs (decode fails), get(..., history=true) still returns empty array', async () => {
primeResolverWithOneOutput(mockResolver)
const originalDecode = (MockPushDrop as any).decode
; (MockPushDrop as any).decode = jest.fn(() => {
throw new Error('Invalid token format')
})
try {
const result = await kvStore.get({ key: TEST_KEY }, { history: true })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
} finally {
; (MockPushDrop as any).decode = originalDecode
}
})
})
})