Merge branch 'swarmforge-coder'

This commit is contained in:
Chris Troutner
2026-08-28 18:11:39 -07:00
55 changed files with 3802 additions and 6 deletions
+280
View File
@@ -41,6 +41,12 @@ const TopicFeedPage = require('../../src/services/topic-feed-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
const MemoPollCreate = require('../../src/services/memo-poll-create')
const PollCreatePage = require('../../src/services/poll-create-page')
const MemoPollOption = require('../../src/services/memo-poll-option')
const PollOptionPage = require('../../src/services/poll-option-page')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const PollVotePage = require('../../src/services/poll-vote-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
@@ -53,6 +59,9 @@ const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
const MEMO_TOPIC_MESSAGE_PREFIX = MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX
const MEMO_TOPIC_FOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX
const MEMO_TOPIC_UNFOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX
const MEMO_CREATE_POLL_PREFIX = MemoPollCreate.MEMO_CREATE_POLL_PREFIX
const MEMO_ADD_POLL_OPTION_PREFIX = MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX
const MEMO_POLL_VOTE_PREFIX = MemoPollVote.MEMO_POLL_VOTE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
@@ -98,6 +107,24 @@ function makeFeed () {
}
}
// A fake poll store recording polls, options, and votes.
function makePolls () {
const polls = []
const options = []
const votes = []
return {
polls,
options,
votes,
addPoll: (poll) => polls.push(poll),
addOption: (option) => options.push(option),
addVote: (vote) => votes.push(vote),
getPoll: (txid) => polls.find((p) => p.txid === txid) || null,
getOptions: (txid) => options.filter((o) => o.pollTxid === txid),
getVotes: (txid) => votes.filter((v) => v.pollTxid === txid)
}
}
// A fake profile store recording display names, bios, avatar URLs, and follow state.
function makeProfiles () {
const names = {}
@@ -241,6 +268,8 @@ function createWorld () {
const memoLike = new MemoLike({ wallet, feed })
const memoFollow = new MemoFollow({ wallet, profiles })
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
const polls = makePolls()
const memoPollCreate = new MemoPollCreate({ wallet, polls })
const memoDb = makeMemoDb()
const world = {
@@ -252,6 +281,8 @@ function createWorld () {
memoLike,
memoFollow,
memoTopicFollow,
polls,
memoPollCreate,
memoDb,
currentPath: null,
menuOpen: false,
@@ -313,6 +344,12 @@ function createWorld () {
navigate: (path) => { world.currentPath = path }
})
// The Poll Create Page controller wraps the memo poll create behavior.
world.pollCreatePage = new PollCreatePage({
memoPollCreate,
navigate: (path) => { world.currentPath = path }
})
return world
}
@@ -1700,9 +1737,252 @@ const handlers = [
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'poll with txid exists',
pattern: /^a poll with the txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
world.polls.addPoll({ txid, question: 'existing poll', optionCount: 2 })
world.currentPollTxid = txid
}
},
{
name: 'compose poll',
pattern: /^I compose a poll with the question "<([A-Za-z0-9_]+)>" and (.+) options$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollCreatePage.setInput(example[param])
world.pollCreatePage.setOptionCount(resolveParam(m[2], example))
}
},
{
name: 'submit poll',
pattern: /^I submit the poll$/,
async run (m, example, world) {
await world.pollCreatePage.submit()
}
},
{
name: 'broadcasts create-poll prefix for question',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo create-poll prefix for the question "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_CREATE_POLL_PREFIX) {
throw new Error(`Expected Memo create-poll prefix ${MEMO_CREATE_POLL_PREFIX}, got "${last.prefix}".`)
}
const { question } = decodeCreatePollPayload(last.msg)
if (question !== expected) {
throw new Error(`Broadcast question "${question}" did not match "${expected}".`)
}
}
},
{
name: 'broadcasts create-poll prefix carrying option count',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo create-poll prefix carrying (.+) options$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_CREATE_POLL_PREFIX) {
throw new Error(`Expected Memo create-poll prefix ${MEMO_CREATE_POLL_PREFIX}, got "${last.prefix}".`)
}
const { optionCount } = decodeCreatePollPayload(last.msg)
if (optionCount !== expected) {
throw new Error(`Expected option count ${expected}, got ${optionCount}.`)
}
}
},
{
name: 'poll composer shows validation/length error',
pattern: /^the poll composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_create_validation' : 'poll_create_length'
if (world.pollCreatePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollCreatePage.submitError}.`)
}
}
},
{
name: 'poll composer remaining byte count',
pattern: /^the poll composer shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${param}".`)
const actual = world.pollCreatePage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'open poll',
pattern: /^I open the poll with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
world.currentPollTxid = txid
const memoPollOption = new MemoPollOption({ wallet: world.wallet, pollTxid: txid, polls: world.polls })
world.pollOptionPage = new PollOptionPage({ memoPollOption })
const memoPollVote = new MemoPollVote({ wallet: world.wallet, pollTxid: txid, polls: world.polls })
world.pollVotePage = new PollVotePage({ memoPollVote })
}
},
{
name: 'compose option',
pattern: /^I compose an option with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollOptionPage.setInput(example[param])
}
},
{
name: 'submit option',
pattern: /^I submit the option$/,
async run (m, example, world) {
await world.pollOptionPage.submit()
}
},
{
name: 'broadcasts add-poll-option prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo add-poll-option prefix for the poll (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_ADD_POLL_OPTION_PREFIX) {
throw new Error(`Expected Memo add-poll-option prefix ${MEMO_ADD_POLL_OPTION_PREFIX}, got "${last.prefix}".`)
}
const { pollTxid } = decodePollTxidPayload(last.msg)
if (pollTxid !== txid) {
throw new Error(`Broadcast poll txid ${pollTxid} did not match ${txid}.`)
}
}
},
{
name: 'poll shows new option',
pattern: /^the poll shows the new option with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const txid = world.currentPollTxid
const found = world.polls.getOptions(txid).find((o) => o.option === expected && o.address === world.wallet.walletInfo.cashAddress)
if (!found) {
throw new Error(`Poll does not show the new option "${expected}".`)
}
}
},
{
name: 'add-option composer shows validation/length error',
pattern: /^the add-option composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_option_validation' : 'poll_option_length'
if (world.pollOptionPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollOptionPage.submitError}.`)
}
}
},
{
name: 'add-option composer remaining byte count',
pattern: /^the add-option composer shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${param}".`)
const actual = world.pollOptionPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'vote with comment',
pattern: /^I vote with the comment "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollVotePage.setInput(example[param])
}
},
{
name: 'submit vote',
pattern: /^I submit the vote$/,
async run (m, example, world) {
await world.pollVotePage.submit()
}
},
{
name: 'broadcasts poll-vote prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo poll-vote prefix for the poll (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_POLL_VOTE_PREFIX) {
throw new Error(`Expected Memo poll-vote prefix ${MEMO_POLL_VOTE_PREFIX}, got "${last.prefix}".`)
}
const { pollTxid } = decodePollTxidPayload(last.msg)
if (pollTxid !== txid) {
throw new Error(`Broadcast poll txid ${pollTxid} did not match ${txid}.`)
}
}
},
{
name: 'poll shows my vote',
pattern: /^the poll shows my vote with the comment "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const txid = world.currentPollTxid
const found = world.polls.getVotes(txid).find((v) => v.comment === expected && v.address === world.wallet.walletInfo.cashAddress)
if (!found) {
throw new Error(`Poll does not show my vote with comment "${expected}".`)
}
}
},
{
name: 'vote composer shows validation/length error',
pattern: /^the vote composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_vote_validation' : 'poll_vote_length'
if (world.pollVotePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`)
}
}
}
]
// Decode a raw create-poll payload into poll_type, option_count, and question.
function decodeCreatePollPayload (raw) {
const buf = Buffer.from(raw)
const pollType = buf[0]
const optionCount = buf[1]
const question = buf.slice(2).toString('utf8')
return { pollType, optionCount, question }
}
// Decode a raw add-poll-option or poll-vote payload into poll txid (hex).
function decodePollTxidPayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
return { pollTxid }
}
// Route a single step to its handler. Throws on unsupported step text. Throws on unsupported step text.
async function handleStep (step, example, world) {
for (const handler of handlers) {
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-29T00:37:49.079587425Z","feature_name":"Poll Create","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/poll-create.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Poll Create - 2 an empty question is rejected","scenario_hash":"5a79d6ea8c9094ac2604abc469208209595bed749d9ca791a61084a8095ae5cd","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:37:04.929072464Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Poll Create - 1, Poll Create - 2, Poll Create - 3, Poll Create - 4
#
# The poll composer broadcasts a Memo create-poll action (0x6d10). The
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-29T00:37:53.863891271Z","feature_name":"Poll Option","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/poll-option.feature","background_hash":"9d8ea293796e711d8de8ea019377a9ade22ab7cb963aeef565a8d1a5ee447119","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Poll Option - 2 an empty option is rejected","scenario_hash":"289ac126dc4858e8dcd0f9ffc1f848d535cfe6ac526681590cfec68b495159de","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:37:53.863891271Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Poll Option - 1, Poll Option - 2, Poll Option - 3, Poll Option - 4
#
# The add-option composer broadcasts a Memo add-poll-option action (0x6d13).
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-29T00:37:55.277305747Z","feature_name":"Poll Vote","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/poll-vote.feature","background_hash":"9d8ea293796e711d8de8ea019377a9ade22ab7cb963aeef565a8d1a5ee447119","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Poll Vote - 2 an empty comment is rejected","scenario_hash":"31d59bfcd52104756d65afbc7a76928752bdd662fc414a889e94a3b24d8be577","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:37:55.277305747Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Poll Vote - 1, Poll Vote - 2, Poll Vote - 3
#
# The vote composer broadcasts a Memo poll-vote action (0x6d14). The payload
+16 -1
View File
@@ -25,4 +25,19 @@ function hexToBytes (hex, byteLength = 32, label = 'Value') {
return bytes
}
module.exports = { hexToBytes }
// Build the raw OP_RETURN payload for a txid-referencing Memo action: the
// given 32-byte txid followed by a UTF-8 encoded value.
function buildTxidTextPayload (txid, text) {
const txidBytes = hexToBytes(txid, 32, 'Poll txid')
const textBytes = new TextEncoder().encode(text)
const raw = new Uint8Array(txidBytes.length + textBytes.length)
raw.set(txidBytes, 0)
raw.set(textBytes, txidBytes.length)
return raw
}
module.exports = { hexToBytes, buildTxidTextPayload }
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:43:42.484Z","module_hash":"7f3445b7df4d792113f2736232f69faee044f71f6b4a5985224615b36909607b","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":30,"end_line":37,"hash":"542a47a13d30c845b7ac9a04079d7a131ece3368a604840ffef75adf97b11f7d"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,106 @@
/*
Memo create-poll behavior: compose, validate, and broadcast a Memo create-poll
action (0x6d10).
A create-poll transaction carries the Memo create-poll protocol prefix
followed by a poll_type byte, an option_count byte, and the question text.
The question is limited to 209 bytes.
The wallet and an injected poll store are used so this module stays testable
and free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
Constants
MEMO_CREATE_POLL_PREFIX : hex prefix for the Memo create-poll action (0x6d10)
MAX_QUESTION_BYTES : maximum question byte length (209)
DEFAULT_POLL_TYPE : default poll type byte (1)
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_CREATE_POLL_PREFIX = '6d10'
const MAX_QUESTION_BYTES = 209
const DEFAULT_POLL_TYPE = 1
class MemoPollCreate extends MemoAction {
static config = {
prefix: MEMO_CREATE_POLL_PREFIX,
walletRequiredMsg: 'Memo poll create requires a wallet.',
lengthMessage: `Poll question is too long. Maximum is ${MAX_QUESTION_BYTES} bytes.`,
emptyMessage: 'Poll question must not be empty.',
lengthCode: 'poll_create_length',
validationCode: 'poll_create_validation'
}
constructor (deps = {}) {
super(deps)
this.pollType = deps.pollType ?? DEFAULT_POLL_TYPE
this.polls = deps.polls || null
}
// A poll question is over-length when its UTF-8 byte count exceeds the limit.
isTooLong (question) {
return byteLength(question) > MAX_QUESTION_BYTES
}
// Compose and broadcast a Memo create-poll action.
async create (question, optionCount) {
const check = this.validate(question)
this._throwIfInvalid(check)
const count = parseInt(optionCount, 10)
if (Number.isNaN(count) || count < 1) {
const err = new Error('Poll option count must be a positive number.')
err.code = 'poll_create_validation'
throw err
}
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
await this.wallet.getUtxos()
const raw = buildCreatePollPayload(question, this.pollType, count)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
this.reflect(txid, question, count)
return txid
}
// Record the new poll on the injected poll store when one is present.
reflect (txid, question, optionCount) {
if (this.polls && typeof this.polls.addPoll === 'function') {
this.polls.addPoll({
txid,
address: this.wallet.walletInfo.cashAddress,
question,
optionCount,
pollType: this.pollType
})
}
}
}
// Build the raw OP_RETURN message payload for a create-poll action.
// The protocol wire format is: <poll_type 1 byte><option_count 1 byte><question UTF-8 bytes>.
function buildCreatePollPayload (question, pollType, optionCount) {
const textBytes = new TextEncoder().encode(question)
const raw = new Uint8Array(2 + textBytes.length)
raw[0] = pollType & 0xff
raw[1] = optionCount & 0xff
raw.set(textBytes, 2)
return raw
}
MemoPollCreate.MEMO_CREATE_POLL_PREFIX = MEMO_CREATE_POLL_PREFIX
MemoPollCreate.MAX_QUESTION_BYTES = MAX_QUESTION_BYTES
MemoPollCreate.DEFAULT_POLL_TYPE = DEFAULT_POLL_TYPE
module.exports = MemoPollCreate
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:45:49.496Z","module_hash":"3455852bee199b40530bbbe787f85429a7a15bf0116989231ac06f9ca9eadf36","functions":[{"id":"func/MemoPollCreate.constructor","name":"MemoPollCreate.constructor","line":36,"end_line":40,"hash":"9404cf1f0df5cc8756e0ea24abab27ade844fa461f84818162faac807b838dd1"},{"id":"func/MemoPollCreate.isTooLong","name":"MemoPollCreate.isTooLong","line":43,"end_line":45,"hash":"ca9087a454f1fba64ac35738a037372ade36ba6d4c2270d2025adc00b860412c"},{"id":"func/MemoPollCreate.create","name":"MemoPollCreate.create","line":48,"end_line":71,"hash":"b5e769b07f3795a5db0d4202106f4b25b5a0b69e30e70491a7a9c2ac52085fce"},{"id":"func/MemoPollCreate.reflect","name":"MemoPollCreate.reflect","line":74,"end_line":84,"hash":"ec559b778b279c1efb46bdb2440a0c197dae242052d7c1ebf5331078258b719f"},{"id":"func/buildCreatePollPayload","name":"buildCreatePollPayload","line":89,"end_line":96,"hash":"f5abdaad00f9e3c857c65766a981d6971e1bc7c64e0200cbb4105ab798bd94da"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,49 @@
/*
Memo add-poll-option behavior: compose, validate, and broadcast a Memo
add-poll-option action (0x6d13).
An add-poll-option transaction carries the Memo add-poll-option protocol
prefix followed by the poll's 32-byte txid and the option text. The option
text is limited to 184 bytes.
It shares the txid-embedding broadcast flow with MemoTxidAction and builds
its wire payload from the shared txid+text helper.
Constants
MEMO_ADD_POLL_OPTION_PREFIX : hex prefix for the Memo add-poll-option action (0x6d13)
MAX_OPTION_BYTES : maximum option byte length (184)
*/
const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex')
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
const MAX_OPTION_BYTES = 184
class MemoPollOption extends MemoTxidAction {
static config = {
prefix: MEMO_ADD_POLL_OPTION_PREFIX,
walletRequiredMsg: 'Memo poll option requires a wallet.',
lengthMessage: `Poll option is too long. Maximum is ${MAX_OPTION_BYTES} bytes.`,
emptyMessage: 'Poll option must not be empty.',
lengthCode: 'poll_option_length',
validationCode: 'poll_option_validation',
reflectMethod: 'addOption',
valueField: 'option',
maxBytes: MAX_OPTION_BYTES
}
// Compose and broadcast a Memo add-poll-option action.
add (option) {
return this.broadcastTxid(option, buildTxidTextPayload)
}
}
MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX = MEMO_ADD_POLL_OPTION_PREFIX
MemoPollOption.MAX_OPTION_BYTES = MAX_OPTION_BYTES
module.exports = MemoPollOption
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:38:02.285Z","module_hash":"f8432d9ccb0073f5cce9f96415fdfb033876f498a7d80aadaca1ed96d89d4ce3","functions":[{"id":"func/MemoPollOption.add","name":"MemoPollOption.add","line":37,"end_line":39,"hash":"e0343ddce138d48e3fec2c5c0169e9d99f374835cc95d381383a254297d7f1dc"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,49 @@
/*
Memo poll-vote behavior: compose, validate, and broadcast a Memo poll-vote
action (0x6d14).
A poll-vote transaction carries the Memo poll-vote protocol prefix followed
by the poll's 32-byte txid and a comment. The comment is limited to 184
bytes.
It shares the txid-embedding broadcast flow with MemoTxidAction and builds
its wire payload from the shared txid+text helper.
Constants
MEMO_POLL_VOTE_PREFIX : hex prefix for the Memo poll-vote action (0x6d14)
MAX_COMMENT_BYTES : maximum comment byte length (184)
*/
const MemoTxidAction = require('./memo-txid-action')
const { buildTxidTextPayload } = require('./hex')
const MEMO_POLL_VOTE_PREFIX = '6d14'
const MAX_COMMENT_BYTES = 184
class MemoPollVote extends MemoTxidAction {
static config = {
prefix: MEMO_POLL_VOTE_PREFIX,
walletRequiredMsg: 'Memo poll vote requires a wallet.',
lengthMessage: `Poll vote comment is too long. Maximum is ${MAX_COMMENT_BYTES} bytes.`,
emptyMessage: 'Poll vote comment must not be empty.',
lengthCode: 'poll_vote_length',
validationCode: 'poll_vote_validation',
reflectMethod: 'addVote',
valueField: 'comment',
maxBytes: MAX_COMMENT_BYTES
}
// Compose and broadcast a Memo poll-vote action.
vote (comment) {
return this.broadcastTxid(comment, buildTxidTextPayload)
}
}
MemoPollVote.MEMO_POLL_VOTE_PREFIX = MEMO_POLL_VOTE_PREFIX
MemoPollVote.MAX_COMMENT_BYTES = MAX_COMMENT_BYTES
module.exports = MemoPollVote
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:38:23.086Z","module_hash":"c60989293dede6eeaf8edd168d00745e7299620b80874f87680337cc2f4d36fc","functions":[{"id":"func/MemoPollVote.vote","name":"MemoPollVote.vote","line":37,"end_line":39,"hash":"c6c194dfaf6c92f5cafcf86772bd295d038536ea20079931e4144eb5141c7101"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,76 @@
/*
Shared base for Memo actions that reference a parent poll by txid (e.g. an
add-poll-option or a poll-vote). Such actions broadcast an OP_RETURN payload
that embeds the parent poll's 32-byte txid followed by a short UTF-8 value.
Subclasses extend MemoAction's config with the poll-specific messages and
codes, and supply the wire payload via the injected buildPayload function.
The wallet and an injected poll store are used so this module stays testable
and free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
class MemoTxidAction extends MemoAction {
constructor (deps = {}) {
super(deps)
this.pollTxid = deps.pollTxid || ''
this.polls = deps.polls || null
}
// Compose and broadcast an action that embeds this.pollTxid plus the given
// value through the supplied buildPayload(pollTxid, value) function.
async broadcastTxid (value, buildPayload) {
const check = this.validate(value)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
if (!this.pollTxid) {
const err = new Error('Poll txid is required.')
err.code = this.validationCode
throw err
}
await this.wallet.getUtxos()
const raw = buildPayload(this.pollTxid, value)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
this.reflect(txid, value)
return txid
}
// Record the new child record on the injected poll store when one is
// present. Subclasses supply `reflectMethod` (the store method to call) and
// `valueField` (the record field holding the parsed value) in their config.
reflect (txid, value) {
const cfg = this.constructor.config
if (this.polls && typeof this.polls[cfg.reflectMethod] === 'function') {
this.polls[cfg.reflectMethod]({
txid,
pollTxid: this.pollTxid,
address: this.wallet.walletInfo.cashAddress,
[cfg.valueField]: value
})
}
}
// A poll child value is over-length when its UTF-8 byte count exceeds the
// subclass's `maxBytes` limit.
isTooLong (value) {
return byteLength(value) > this.constructor.config.maxBytes
}
}
module.exports = MemoTxidAction
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:37:21.781Z","module_hash":"b43e695aee6198c465b2cdbb0fbb405fccb0aadb573f56c3d3249de3920c1939","functions":[{"id":"func/MemoTxidAction.constructor","name":"MemoTxidAction.constructor","line":18,"end_line":22,"hash":"399be38855508b45b6a07e00361d4fb36186df3fdac5e2a68e004f7dcbdd353c"},{"id":"func/MemoTxidAction.broadcastTxid","name":"MemoTxidAction.broadcastTxid","line":26,"end_line":48,"hash":"395ea932dd691049556a66a3d4c674778818b8b53152c0ae7088d960524fc400"},{"id":"func/MemoTxidAction.reflect","name":"MemoTxidAction.reflect","line":53,"end_line":63,"hash":"7d61638162b7142298e021b02cbdeaca359f33503b3b84a308498f0479c80c5c"},{"id":"func/MemoTxidAction.isTooLong","name":"MemoTxidAction.isTooLong","line":67,"end_line":69,"hash":"7eeb0b9dcb35530de412b0aa0ade6b267e2fe22f5e49aa0f3ecf5b9124022a3b"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,59 @@
/*
Shared base for page controllers that submit a single-text Memo action
against a parent poll (e.g. add a poll option or cast a poll vote), with a
byte counter that counts down from the field's limit.
Such a page holds the current input, computes the remaining byte budget,
validates/broadcasts through an injected action handler, and surfaces the
result through the shared PageController flow (no navigation on success).
Subclasses supply a static config:
handlerKey - deps key holding the action handler
busyKey - instance key for the in-flight flag
actionMethod - handler method to invoke for the current input
requiresMsg - error message when no handler is injected
maxBytes - the field's byte limit
validationCodes - error codes for local validation failures
The handler and navigate concerns are injected so this module stays free of
UI/network concerns; environmentally unsuitable I/O lives behind those small
adapter boundaries.
*/
const PageController = require('./page-controller')
const { byteLength } = require('./utf8')
class PollActionPage extends PageController {
constructor (deps = {}) {
super(deps)
const cfg = this.constructor.config
this[cfg.handlerKey] = deps[cfg.handlerKey] || null
this[cfg.busyKey] = false
this.validationCodes = cfg.validationCodes
}
// Bytes remaining before the field's limit is reached.
remainingCount () {
return this.constructor.config.maxBytes - byteLength(this.input)
}
// Set the in-flight flag.
_setBusy (value) {
this[this.constructor.config.busyKey] = value
}
// Run the action handler for the current input.
async _perform (input) {
const cfg = this.constructor.config
if (!this[cfg.handlerKey]) {
throw new Error(cfg.requiresMsg)
}
return this[cfg.handlerKey][cfg.actionMethod](input)
}
}
module.exports = PollActionPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:46:20.654Z","module_hash":"424696b48899d06ade5386ec5be3588c6b1ea1a30fe8ce9ed43a3636ff446d68","functions":[{"id":"func/PollActionPage.constructor","name":"PollActionPage.constructor","line":27,"end_line":33,"hash":"4b6202ed9803158cf5b646f3e48635ad318c50adb503b13a7e6d8c01b1e95fea"},{"id":"func/PollActionPage.remainingCount","name":"PollActionPage.remainingCount","line":36,"end_line":38,"hash":"99ebb2601bf4ecfc6d9fcfe03a4ffcb3ec2dc7174a12ee668ce35e44083df96d"},{"id":"func/PollActionPage._setBusy","name":"PollActionPage._setBusy","line":41,"end_line":43,"hash":"b25ece6baf7159cdfbb0ddcd435617965f884d0541108f9e2d677a1e65cba970"},{"id":"func/PollActionPage._perform","name":"PollActionPage._perform","line":46,"end_line":52,"hash":"b16fe867f293e1aa356a38484a912d112e90157cb7d74966facf6b59861c1495"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,70 @@
/*
Poll Create Page behavior: compose and broadcast a Memo create-poll action,
with a byte counter that counts down from the question limit.
This is the testable controller behind the React poll composer. It wraps
the Memo poll-create behavior (src/services/memo-poll-create.js) and adds
page-level concerns: holding the current question and option count,
computing the remaining byte count, surfacing validation/length errors, and
navigating on success.
The memoPollCreate and navigate concerns are injected so this module stays
free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
*/
const PageController = require('./page-controller')
const MemoPollCreate = require('./memo-poll-create')
const RECENT_FEED_PATH = '/posts/recent'
class PollCreatePage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoPollCreate = deps.memoPollCreate || null
this.optionCount = 2
this.creating = false
this.successPath = RECENT_FEED_PATH
this.validationCodes = ['poll_create_validation', 'poll_create_length']
}
// Set the option count.
setOptionCount (count) {
this.optionCount = parseInt(count, 10)
return this
}
// Bytes remaining for the question.
remainingCount () {
if (!this.memoPollCreate) {
throw new Error('Poll create page requires a memo poll create handler.')
}
return MemoPollCreate.MAX_QUESTION_BYTES - this._questionBytes()
}
_questionBytes () {
return new TextEncoder().encode(this.input).length
}
// Set the in-flight flag.
_setBusy (value) {
this.creating = value
}
// Run the memo poll create action for the current input.
async _perform (input) {
if (!this.memoPollCreate) {
throw new Error('Poll create page requires a memo poll create handler.')
}
return this.memoPollCreate.create(input, this.optionCount)
}
}
PollCreatePage.MAX_QUESTION_BYTES = MemoPollCreate.MAX_QUESTION_BYTES
PollCreatePage.RECENT_FEED_PATH = RECENT_FEED_PATH
module.exports = PollCreatePage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:46:38.952Z","module_hash":"f4505b989b5fa6868f584fb729256221b7f4c8f68dbfdd21b9e1a60fc93dea49","functions":[{"id":"func/PollCreatePage.constructor","name":"PollCreatePage.constructor","line":22,"end_line":29,"hash":"7fea9e1104d00994f47b87163f114da456ef3a93d3bee4516ac9516f6f5e0dc5"},{"id":"func/PollCreatePage.setOptionCount","name":"PollCreatePage.setOptionCount","line":32,"end_line":35,"hash":"942e0826112c964545b8e198a935fec15f4664d4933e1aec0537ea3f9b03593d"},{"id":"func/PollCreatePage.remainingCount","name":"PollCreatePage.remainingCount","line":38,"end_line":43,"hash":"ce0f4da879dcc66b216f905fcc1a0141c4432f8b46ab29728de7d1c00fd22f0c"},{"id":"func/PollCreatePage._questionBytes","name":"PollCreatePage._questionBytes","line":45,"end_line":47,"hash":"37e13e5bdc13c74698809f523cd1ac5ad126efea55a747ac21ef1367e0b2834e"},{"id":"func/PollCreatePage._setBusy","name":"PollCreatePage._setBusy","line":50,"end_line":52,"hash":"bccf9e30325028d65bddeb286090bc025b5a051a77286d7b8ea1ea21a827b498"},{"id":"func/PollCreatePage._perform","name":"PollCreatePage._perform","line":55,"end_line":60,"hash":"7667bebef3299e042042c8849930966de75acffa5900082d4dccd73e01f7e4fb"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,34 @@
/*
Poll Option Page behavior: compose and broadcast a Memo add-poll-option
action, with a byte counter that counts down from the option limit.
This is the testable controller behind the React poll option composer. It
wraps the Memo poll-option behavior (src/services/memo-poll-option.js)
through the shared PollActionPage base.
The memoPollOption and navigate concerns are injected so this module stays
free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
*/
const PollActionPage = require('./poll-action-page')
const MemoPollOption = require('./memo-poll-option')
class PollOptionPage extends PollActionPage {
static config = {
handlerKey: 'memoPollOption',
busyKey: 'adding',
actionMethod: 'add',
requiresMsg: 'Poll option page requires a memo poll option handler.',
maxBytes: MemoPollOption.MAX_OPTION_BYTES,
validationCodes: ['poll_option_validation', 'poll_option_length']
}
}
PollOptionPage.MAX_OPTION_BYTES = MemoPollOption.MAX_OPTION_BYTES
module.exports = PollOptionPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:42:16.020Z","module_hash":"a48cec069109b655b4ab5749c6dd7da46dbf5962a88da04e6b96db94df68fa4a","functions":[]}
// mutate4javascript-manifest-end
@@ -0,0 +1,34 @@
/*
Poll Vote Page behavior: compose and broadcast a Memo poll-vote action, with
a byte counter that counts down from the comment limit.
This is the testable controller behind the React poll vote composer. It
wraps the Memo poll-vote behavior (src/services/memo-poll-vote.js) through
the shared PollActionPage base.
The memoPollVote and navigate concerns are injected so this module stays
free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
*/
const PollActionPage = require('./poll-action-page')
const MemoPollVote = require('./memo-poll-vote')
class PollVotePage extends PollActionPage {
static config = {
handlerKey: 'memoPollVote',
busyKey: 'voting',
actionMethod: 'vote',
requiresMsg: 'Poll vote page requires a memo poll vote handler.',
maxBytes: MemoPollVote.MAX_COMMENT_BYTES,
validationCodes: ['poll_vote_validation', 'poll_vote_length']
}
}
PollVotePage.MAX_COMMENT_BYTES = MemoPollVote.MAX_COMMENT_BYTES
module.exports = PollVotePage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:42:28.145Z","module_hash":"bd9a88cc558b549e49f8f0b8d9e7eb3352b33a906d345d41a13724ba3782bfe7","functions":[]}
// mutate4javascript-manifest-end
@@ -0,0 +1,162 @@
/*
Property tests for the Memo poll services and their page controllers.
These pin down invariants over broad random inputs that the unit tests only
probe at fixed fixtures:
- Round-trip: buildTxidTextPayload encodes a poll txid + text into the
canonical Memo wire payload, so the stored reverse-hex txid and the
UTF-8 text both decode back unchanged.
- hexToBytes length contract: only 64-character hex txids decode to
32 bytes; everything else throws.
- Page byte conservation: remainingCount is always maxBytes minus the
UTF-8 byte length of the current input.
- Rejection classification: empty inputs fail as validation and over-long
inputs fail as length, in both cases without broadcasting.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll } = require('./harness')
const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex')
const { byteLength } = require('../../src/services/utf8')
const MemoPollOption = require('../../src/services/memo-poll-option')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const PollOptionPage = require('../../src/services/poll-option-page')
const PollVotePage = require('../../src/services/poll-vote-page')
const rng = seededRandom(20260828)
function randomTxid () {
const hex = '0123456789abcdef'
let s = ''
for (let i = 0; i < 64; i++) {
s += hex[Math.floor(rng() * 16)]
}
return s
}
function randomText (maxBytes) {
const charset = ['a', 'b', 'c', 'd', ' ', 'é', '\u{1F600}']
let s = ''
let budget = maxBytes
while (budget > 0) {
const ch = charset[Math.floor(rng() * charset.length)]
s += ch
budget -= byteLength(ch)
}
return s
}
function makeWallet () {
return {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
broadcasts: [],
async getUtxos () { return [] },
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
test('buildTxidTextPayload round-trips the canonical Memo wire format', async () => {
await forAll(
() => ({ txid: randomTxid(), text: randomText(200) }),
({ txid, text }) => {
const bytes = buildTxidTextPayload(txid, text)
if (bytes.length !== 32 + byteLength(text)) return false
// The payload carries the txid's literal 32 bytes in order, followed by
// the UTF-8 bytes of the value.
const expectedTxidBytes = hexToBytes(txid, 32, 'Poll txid')
for (let i = 0; i < 32; i++) {
if (bytes[i] !== expectedTxidBytes[i]) return false
}
const storedText = Buffer.from(bytes.slice(32)).toString('utf8')
return storedText === text
},
{ label: 'poll txid+text wire round-trip' }
)
})
test('hexToBytes decodes only exactly 64 hex characters to 32 bytes', async () => {
await forAll(
() => {
const len = Math.floor(rng() * 80)
const hex = '0123456789abcdef'
let s = ''
for (let i = 0; i < len; i++) {
s += hex[Math.floor(rng() * 16)]
}
return s
},
(s) => {
if (s.length === 64) {
return hexToBytes(s, 32, 'Poll txid').length === 32
}
let threw = false
try {
hexToBytes(s, 32, 'Poll txid')
} catch (err) {
threw = true
}
return threw
},
{ label: 'hexToBytes length contract' }
)
})
test('poll pages conserve the remaining byte count', async () => {
await forAll(
() => {
const mode = Math.floor(rng() * 2)
return { mode, text: randomText(400) }
},
({ mode, text }) => {
const Page = mode === 0 ? PollOptionPage : PollVotePage
const handlerKey = mode === 0 ? 'memoPollOption' : 'memoPollVote'
const page = new Page({ [handlerKey]: {} })
page.setInput(text)
const limit = mode === 0
? MemoPollOption.MAX_OPTION_BYTES
: MemoPollVote.MAX_COMMENT_BYTES
return page.remainingCount() === limit - byteLength(text)
},
{ label: 'poll page remaining byte conservation' }
)
})
test('poll pages reject empty as validation and over-long as length without broadcasting', async () => {
await forAll(
() => {
const mode = Math.floor(rng() * 2)
const kind = Math.floor(rng() * 2) // 0 = empty, 1 = over-long
return { mode, kind }
},
async ({ mode, kind }) => {
const wallet = makeWallet()
const txid = randomTxid()
const Handler = mode === 0 ? MemoPollOption : MemoPollVote
const handler = new Handler({ wallet, pollTxid: txid })
const Page = mode === 0 ? PollOptionPage : PollVotePage
const page = new Page({
[mode === 0 ? 'memoPollOption' : 'memoPollVote']: handler
})
const limit = mode === 0
? MemoPollOption.MAX_OPTION_BYTES
: MemoPollVote.MAX_COMMENT_BYTES
const input = kind === 0 ? '' : 'x'.repeat(limit + 100)
const validationCode = mode === 0 ? 'poll_option_validation' : 'poll_vote_validation'
const lengthCode = mode === 0 ? 'poll_option_length' : 'poll_vote_length'
page.setInput(input)
const result = await page.submit()
if (wallet.broadcasts.length !== 0) return false
if (result.ok !== false) return false
return page.submitError === (kind === 0 ? validationCode : lengthCode)
},
{ label: 'poll page rejection classification' }
)
})
+63
View File
@@ -0,0 +1,63 @@
/*
Unit tests for the hex helpers used by Memo poll actions.
Memo actions that embed a parent poll txid use hexToBytes to decode the
64-character hex txid into 32 raw bytes. These direct tests pin the length
and hex-validity guards independently of the memo-poll broadcast path that
also reaches hexToBytes through buildTxidTextPayload.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex')
test('hexToBytes decodes exactly 64 hex characters into 32 bytes', () => {
const bytes = hexToBytes('ab'.repeat(32))
assert.ok(bytes instanceof Uint8Array)
assert.equal(bytes.length, 32)
assert.equal(bytes[0], 0xab)
})
test('hexToBytes rejects a string of the wrong length', () => {
// 60 characters: the correct string type but not the required 64.
assert.throws(
() => hexToBytes('ab'.repeat(30)),
/64-character hex string/
)
// 128 characters: over-length.
assert.throws(
() => hexToBytes('ab'.repeat(64)),
/64-character hex string/
)
})
test('hexToBytes rejects a non-hex character', () => {
// 64 characters, but 'z' and 'g' are not valid hex digits.
assert.throws(
() => hexToBytes('zz'.repeat(32)),
/valid hex string/
)
assert.throws(
() => hexToBytes('gg'.repeat(32)),
/valid hex string/
)
})
test('hexToBytes rejects a non-string value', () => {
assert.throws(
() => hexToBytes(null, 32, 'Poll txid'),
/Poll txid must be a 64-character hex string/
)
})
test('buildTxidTextPayload prefixes the raw txid bytes', () => {
const raw = buildTxidTextPayload('ab'.repeat(32), 'hi')
const buf = Buffer.from(raw)
assert.equal(buf.length, 32 + 2)
assert.equal(buf[0], 0xab)
assert.equal(buf.slice(32).toString('utf8'), 'hi')
})
@@ -0,0 +1,164 @@
/*
Unit tests for the Memo create-poll behavior.
A create-poll transaction carries the Memo create-poll protocol prefix
(0x6d10) followed by a poll_type byte, an option_count byte, and the
question text. The question is limited to 209 bytes.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoPollCreate = require('../../src/services/memo-poll-create')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
return {
pollType: buf[0],
optionCount: buf[1],
question: buf.slice(2).toString('utf8')
}
}
test('create broadcasts with the create-poll prefix and payload', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await memoPollCreate.create('which is better?', 2)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollCreate.MEMO_CREATE_POLL_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.question, 'which is better?')
assert.equal(decoded.optionCount, 2)
assert.equal(decoded.pollType, 1)
})
test('create accepts an option count of one', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await memoPollCreate.create('one option?', 1)
assert.equal(wallet.broadcasts.length, 1)
})
test('create rejects an option count of zero', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await assert.rejects(
() => memoPollCreate.create('zero options?', 0),
{ code: 'poll_create_validation', message: /positive number/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('create rejects a non-numeric option count', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await assert.rejects(
() => memoPollCreate.create('weird count?', 'abc'),
{ code: 'poll_create_validation', message: /positive number/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('create reflects the new poll on the injected poll store', async () => {
const wallet = makeWallet()
const added = []
const polls = {
addPoll (poll) {
added.push(poll)
}
}
const memoPollCreate = new MemoPollCreate({ wallet, polls })
await memoPollCreate.create('what next?', 3)
assert.equal(added.length, 1)
assert.equal(added[0].question, 'what next?')
assert.equal(added[0].optionCount, 3)
assert.equal(added[0].address, wallet.walletInfo.cashAddress)
})
test('create rejects an empty question', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await assert.rejects(
() => memoPollCreate.create('', 2),
{ code: 'poll_create_validation', message: /must not be empty/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('create rejects a question that exceeds the byte limit', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
const question = 'a'.repeat(210)
await assert.rejects(
() => memoPollCreate.create(question, 2),
{ code: 'poll_create_length', message: /too long/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('create accepts a question at the byte limit', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
const question = 'a'.repeat(209)
await memoPollCreate.create(question, 2)
assert.equal(wallet.broadcasts.length, 1)
})
test('create counts UTF-8 bytes for the limit', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
await assert.rejects(
() => memoPollCreate.create('é'.repeat(105), 2),
{ code: 'poll_create_length' }
)
})
test('create requires a wallet', async () => {
const memoPollCreate = new MemoPollCreate({})
await assert.rejects(
() => memoPollCreate.create('hello', 2),
/requires a wallet/
)
})
test('create surfaces a broadcast failure', async () => {
const wallet = makeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
const memoPollCreate = new MemoPollCreate({ wallet })
await assert.rejects(
() => memoPollCreate.create('hello', 2),
/broadcast failed/
)
})
@@ -0,0 +1,120 @@
/*
Unit tests for the Memo add-poll-option behavior.
An add-poll-option transaction carries the Memo add-poll-option protocol
prefix (0x6d13) followed by the poll's 32-byte txid and the option text.
The option text is limited to 184 bytes.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoPollOption = require('../../src/services/memo-poll-option')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
const option = buf.slice(32).toString('utf8')
return { pollTxid, option }
}
test('add broadcasts with the add-poll-option prefix and payload', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
await memoPollOption.add('yes')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.pollTxid, POLL_TXID)
assert.equal(decoded.option, 'yes')
})
test('add reflects the new option on the injected poll store', async () => {
const wallet = makeWallet()
const added = []
const polls = {
addOption (option) {
added.push(option)
}
}
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID, polls })
await memoPollOption.add('definitely')
assert.equal(added.length, 1)
assert.equal(added[0].option, 'definitely')
assert.equal(added[0].pollTxid, POLL_TXID)
assert.equal(added[0].address, wallet.walletInfo.cashAddress)
})
test('add rejects an empty option', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
await assert.rejects(
() => memoPollOption.add(''),
{ code: 'poll_option_validation', message: /must not be empty/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('add rejects an option that exceeds the byte limit', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
const option = 'a'.repeat(185)
await assert.rejects(
() => memoPollOption.add(option),
{ code: 'poll_option_length', message: /too long/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('add accepts an option at the byte limit', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
const option = 'a'.repeat(MemoPollOption.MAX_OPTION_BYTES)
await memoPollOption.add(option)
assert.equal(wallet.broadcasts.length, 1)
})
test('add requires a wallet', async () => {
const memoPollOption = new MemoPollOption({ pollTxid: POLL_TXID })
await assert.rejects(
() => memoPollOption.add('yes'),
/requires a wallet/
)
})
test('add requires a poll txid', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet })
await assert.rejects(
() => memoPollOption.add('yes'),
{ code: 'poll_option_validation', message: /Poll txid is required/ }
)
})
@@ -0,0 +1,120 @@
/*
Unit tests for the Memo poll-vote behavior.
A poll-vote transaction carries the Memo poll-vote protocol prefix (0x6d14)
followed by the poll's 32-byte txid and a comment. The comment is limited
to 184 bytes.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function decodePayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
const comment = buf.slice(32).toString('utf8')
return { pollTxid, comment }
}
test('vote broadcasts with the poll-vote prefix and payload', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
await memoPollVote.vote('yes')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoPollVote.MEMO_POLL_VOTE_PREFIX)
const decoded = decodePayload(wallet.broadcasts[0].msg)
assert.equal(decoded.pollTxid, POLL_TXID)
assert.equal(decoded.comment, 'yes')
})
test('vote reflects the new vote on the injected poll store', async () => {
const wallet = makeWallet()
const added = []
const polls = {
addVote (vote) {
added.push(vote)
}
}
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID, polls })
await memoPollVote.vote('I choose this one')
assert.equal(added.length, 1)
assert.equal(added[0].comment, 'I choose this one')
assert.equal(added[0].pollTxid, POLL_TXID)
assert.equal(added[0].address, wallet.walletInfo.cashAddress)
})
test('vote rejects an empty comment', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
await assert.rejects(
() => memoPollVote.vote(''),
{ code: 'poll_vote_validation', message: /must not be empty/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('vote rejects a comment that exceeds the byte limit', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
const comment = 'a'.repeat(185)
await assert.rejects(
() => memoPollVote.vote(comment),
{ code: 'poll_vote_length', message: /too long/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('vote accepts a comment at the byte limit', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
const comment = 'a'.repeat(MemoPollVote.MAX_COMMENT_BYTES)
await memoPollVote.vote(comment)
assert.equal(wallet.broadcasts.length, 1)
})
test('vote requires a wallet', async () => {
const memoPollVote = new MemoPollVote({ pollTxid: POLL_TXID })
await assert.rejects(
() => memoPollVote.vote('yes'),
/requires a wallet/
)
})
test('vote requires a poll txid', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet })
await assert.rejects(
() => memoPollVote.vote('yes'),
{ code: 'poll_vote_validation', message: /Poll txid is required/ }
)
})
@@ -0,0 +1,72 @@
/*
Unit tests for the Poll Create Page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const PollCreatePage = require('../../src/services/poll-create-page')
const MemoPollCreate = require('../../src/services/memo-poll-create')
function makeWallet () {
return {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
broadcasts: [],
async getUtxos () { return [] },
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
test('submit creates a poll and navigates on success', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
let navigated = null
const page = new PollCreatePage({
memoPollCreate,
navigate: (path) => { navigated = path }
})
page.setInput('which is better?')
page.setOptionCount(2)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(navigated, '/posts/recent')
})
test('submit records a validation error for an empty question', async () => {
const wallet = makeWallet()
const memoPollCreate = new MemoPollCreate({ wallet })
const page = new PollCreatePage({ memoPollCreate })
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'poll_create_validation')
assert.equal(wallet.broadcasts.length, 0)
})
test('remainingCount counts down from the question limit', () => {
const memoPollCreate = new MemoPollCreate({})
const page = new PollCreatePage({ memoPollCreate })
page.setInput('')
assert.equal(page.remainingCount(), MemoPollCreate.MAX_QUESTION_BYTES)
page.setInput('hello')
assert.equal(page.remainingCount(), MemoPollCreate.MAX_QUESTION_BYTES - 5)
page.setInput('é')
assert.equal(page.remainingCount(), MemoPollCreate.MAX_QUESTION_BYTES - 2)
})
test('starts with the in-flight flag cleared', () => {
const memoPollCreate = new MemoPollCreate({})
const page = new PollCreatePage({ memoPollCreate })
assert.equal(page.creating, false)
})
@@ -0,0 +1,66 @@
/*
Unit tests for the Poll Option Page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const PollOptionPage = require('../../src/services/poll-option-page')
const MemoPollOption = require('../../src/services/memo-poll-option')
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function makeWallet () {
return {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
broadcasts: [],
async getUtxos () { return [] },
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
test('submit adds an option', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
const page = new PollOptionPage({ memoPollOption })
page.setInput('yes')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
})
test('submit records a validation error for an empty option', async () => {
const wallet = makeWallet()
const memoPollOption = new MemoPollOption({ wallet, pollTxid: POLL_TXID })
const page = new PollOptionPage({ memoPollOption })
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'poll_option_validation')
assert.equal(wallet.broadcasts.length, 0)
})
test('remainingCount counts down from the option limit', () => {
const memoPollOption = new MemoPollOption({ pollTxid: POLL_TXID })
const page = new PollOptionPage({ memoPollOption })
page.setInput('')
assert.equal(page.remainingCount(), MemoPollOption.MAX_OPTION_BYTES)
page.setInput('yes')
assert.equal(page.remainingCount(), MemoPollOption.MAX_OPTION_BYTES - 3)
})
test('starts with the in-flight flag cleared', () => {
const memoPollOption = new MemoPollOption({ pollTxid: POLL_TXID })
const page = new PollOptionPage({ memoPollOption })
assert.equal(page.adding, false)
})
@@ -0,0 +1,66 @@
/*
Unit tests for the Poll Vote Page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const PollVotePage = require('../../src/services/poll-vote-page')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function makeWallet () {
return {
walletInfo: { cashAddress: 'bitcoincash:qtest' },
broadcasts: [],
async getUtxos () { return [] },
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
test('submit casts a vote', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
const page = new PollVotePage({ memoPollVote })
page.setInput('yes')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
})
test('submit records a validation error for an empty comment', async () => {
const wallet = makeWallet()
const memoPollVote = new MemoPollVote({ wallet, pollTxid: POLL_TXID })
const page = new PollVotePage({ memoPollVote })
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'poll_vote_validation')
assert.equal(wallet.broadcasts.length, 0)
})
test('remainingCount counts down from the comment limit', () => {
const memoPollVote = new MemoPollVote({ pollTxid: POLL_TXID })
const page = new PollVotePage({ memoPollVote })
page.setInput('')
assert.equal(page.remainingCount(), MemoPollVote.MAX_COMMENT_BYTES)
page.setInput('yes')
assert.equal(page.remainingCount(), MemoPollVote.MAX_COMMENT_BYTES - 3)
})
test('starts with the in-flight flag cleared', () => {
const memoPollVote = new MemoPollVote({ pollTxid: POLL_TXID })
const page = new PollVotePage({ memoPollVote })
assert.equal(page.voting, false)
})
+194
View File
@@ -22,6 +22,9 @@ import ListTopics from '../../src/use-cases/list-topics.js'
import ListTopicPosts from '../../src/use-cases/list-topic-posts.js'
import TopicFollowState from '../../src/use-cases/topic-follow-state.js'
import ListTopicFollowers from '../../src/use-cases/list-topic-followers.js'
import GetPoll from '../../src/use-cases/get-poll.js'
import GetPollOptions from '../../src/use-cases/get-poll-options.js'
import GetPollVotes from '../../src/use-cases/get-poll-votes.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
@@ -100,6 +103,9 @@ async function createWorld () {
const listTopicPosts = new ListTopicPosts({ adapters })
const topicFollowState = new TopicFollowState({ adapters })
const listTopicFollowers = new ListTopicFollowers({ adapters })
const getPoll = new GetPoll({ adapters })
const getPollOptions = new GetPollOptions({ adapters })
const getPollVotes = new GetPollVotes({ adapters })
let lastResponse = null
@@ -115,6 +121,9 @@ async function createWorld () {
listTopicPosts,
topicFollowState,
listTopicFollowers,
getPoll,
getPollOptions,
getPollVotes,
postHeightsIteratorCounter,
addrPostHeightsIteratorCounter,
postChildrenIteratorCounter,
@@ -164,6 +173,11 @@ async function loadFixture (world, name) {
return
}
if (name === 'poll-with-options-and-vote') {
await loadPollWithOptionsAndVote(world)
return
}
if (name !== 'three-top-level-posts-and-one-reply') {
throw new Error(`Unknown fixture: ${name}`)
}
@@ -388,6 +402,43 @@ async function loadTopicFollows (world) {
}
}
async function loadPollWithOptionsAndVote (world) {
const pollTxid = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
await world.adapters.level.pollsDb.put(pollTxid, {
addr: 'bitcoincash:qaddr-a',
pollType: 1,
optionCount: 2,
question: 'which is better?',
seen: 1,
blockHeight: 600100
})
await world.adapters.level.pollOptionsDb.put('option-yes', {
addr: 'bitcoincash:qaddr-a',
pollTxid,
option: 'yes',
seen: 2,
blockHeight: 600101
})
await world.adapters.level.pollOptionsDb.put('option-no', {
addr: 'bitcoincash:qaddr-b',
pollTxid,
option: 'no',
seen: 3,
blockHeight: 600102
})
await world.adapters.level.pollVotesDb.put('vote-yes', {
addr: 'bitcoincash:qaddr-a',
pollTxid,
comment: 'yes',
seen: 4,
blockHeight: 600103
})
}
const handlers = [
{
name: 'db instance with posts and postHeights stores',
@@ -862,6 +913,149 @@ const handlers = [
throw new Error(`Expected topic followers ${expected.join(',')}, got ${actual.join(',')}`)
}
}
},
{
name: 'db instance with polls store',
pattern: /^a psf-memo-db instance with a polls store and pollOptions and pollVotes stores$/,
async run () {
// World is already created with all poll stores.
}
},
{
name: 'load fixture into polls stores',
pattern: /^the fixture "(.+)" is loaded into the polls store and pollOptions and pollVotes stores$/,
async run (m, example, world) {
await loadFixture(world, m[1])
}
},
{
name: 'serve poll',
pattern: /^the psf-memo-db API serves a poll with the txid (.+) with the question "(.+)"$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
const question = m[2]
await world.adapters.level.pollsDb.put(txid, {
addr: 'bitcoincash:qaddr-a',
pollType: 1,
optionCount: 2,
question,
seen: 1,
blockHeight: 600100
})
}
},
{
name: 'serve poll option',
pattern: /^the psf-memo-db API serves the option "(.+)" for the poll (.+)$/,
async run (m, example, world) {
const option = m[1]
const pollTxid = resolveParam(m[2], example)
const optionTxid = `option-${option}-${pollTxid.slice(0, 8)}`
await world.adapters.level.pollOptionsDb.put(optionTxid, {
addr: 'bitcoincash:qaddr-a',
pollTxid,
option,
seen: Date.now(),
blockHeight: 600101
})
}
},
{
name: 'serve poll vote',
pattern: /^the psf-memo-db API serves a vote with the comment "(.+)" for the poll (.+)$/,
async run (m, example, world) {
const comment = m[1]
const pollTxid = resolveParam(m[2], example)
const voteTxid = `vote-${comment}-${pollTxid.slice(0, 8)}`
await world.adapters.level.pollVotesDb.put(voteTxid, {
addr: 'bitcoincash:qaddr-a',
pollTxid,
comment,
seen: Date.now(),
blockHeight: 600102
})
}
},
{
name: 'request poll',
pattern: /^I request the poll with txid (.+)$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
const resp = await world.getPoll.execute({ txid })
world.setLastResponse(resp)
}
},
{
name: 'request poll options',
pattern: /^I request the options for the poll with txid (.+)$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
const resp = await world.getPollOptions.execute({ txid })
world.setLastResponse(resp)
}
},
{
name: 'request poll votes',
pattern: /^I request the votes for the poll with txid (.+)$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
const resp = await world.getPollVotes.execute({ txid })
world.setLastResponse(resp)
}
},
{
name: 'response shows poll question',
pattern: /^the response shows the question "(.+)"$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const actual = world.getLastResponse().question
if (actual !== expected) {
throw new Error(`Expected question "${expected}", got "${actual}".`)
}
}
},
{
name: 'response shows options',
pattern: /^the response shows the options "(.+)" and "(.+)"$/,
run (m, example, world) {
const expected1 = resolveParam(m[1], example)
const expected2 = resolveParam(m[2], example)
const response = world.getLastResponse()
const options = response.options || []
const texts = options.map((o) => o.option)
if (!texts.includes(expected1) || !texts.includes(expected2)) {
throw new Error(`Expected options "${expected1}" and "${expected2}", got ${JSON.stringify(texts)}.`)
}
}
},
{
name: 'response shows vote count',
pattern: /^the response shows (.+) vote$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const response = world.getLastResponse()
const actual = response.votes ? response.votes.length : 0
if (actual !== expected) {
throw new Error(`Expected ${expected} vote(s), got ${actual}.`)
}
}
},
{
name: 'response shows vote count with comment',
pattern: /^the response shows (.+) vote with the comment "(.+)"$/,
run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const expectedComment = resolveParam(m[2], example)
const response = world.getLastResponse()
const votes = response.votes || []
const matching = votes.filter((v) => v.comment === expectedComment)
if (votes.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} vote(s), got ${votes.length}.`)
}
if (matching.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} vote(s) with comment "${expectedComment}".`)
}
}
}
]
+5
View File
@@ -1,3 +1,8 @@
# mutation-stamp: sha256=44dcefb46c7eea5ee1dc75123375996606d3dbc50341b1ea8c7f58e6b8b675ba
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-29T00:41:48.071719683Z","feature_name":"Poll Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/poll-read.feature","background_hash":"bbf9fc6b796218c8b0b26ac54761d2c3c3ba2c5d3e072f1656869a4ef0e37bef","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Poll Read - 1 the poll endpoint returns the poll with its question, options, and votes","scenario_hash":"66e706477b03825fa38975fb9c0bc06e2e9c5ba94298422d728e3980f0aea444","mutation_count":5,"result":{"Total":5,"Killed":5,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.071719683Z"},{"index":1,"name":"Poll Read - 2 the options endpoint returns the poll's options","scenario_hash":"883550e26e7aa9163d5a0eed635378489cd8dd86c8d08e5026ea28b346446467","mutation_count":3,"result":{"Total":3,"Killed":3,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.071719683Z"},{"index":2,"name":"Poll Read - 3 the votes endpoint returns the poll's votes","scenario_hash":"8ab121a13c0e8fb344725981e75ce410701ee713d36b33cce6d95a3ad66ae555","mutation_count":3,"result":{"Total":3,"Killed":3,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.071719683Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Poll Read - 1, Poll Read - 2, Poll Read - 3
#
# The psf-memo-db REST API exposes the read side of Memo polls:
+10
View File
@@ -8,6 +8,7 @@ import ProfileQuery from './profile-query.js'
import PostQuery from './post-query.js'
import FollowQuery from './follow-query.js'
import TopicQuery from './topic-query.js'
import PollQuery from './poll-query.js'
class Adapters {
constructor () {
@@ -40,6 +41,11 @@ class Adapters {
roomsDb: level.roomsDb,
postsDb: level.postsDb
})
this.pollQuery = new PollQuery({
pollsDb: level.pollsDb,
pollOptionsDb: level.pollOptionsDb,
pollVotesDb: level.pollVotesDb
})
return true
}
@@ -51,3 +57,7 @@ class Adapters {
}
export default Adapters
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:02:23.414Z","module_hash":"b21f1e3f06db5dd67606fbc7021677bca4d9db8e900464185e89de2d1c2a61f1","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":18,"hash":"065fc13eb85e8f884084eb672e5bf38175fb8305e576484804398dd902133c40"},{"id":"func/Adapters.openDatabases","name":"Adapters.openDatabases","line":20,"end_line":50,"hash":"6666e105f384adb9885d102b05b1be0f5b9885592c9ff1f8db34843ec351be01"},{"id":"func/Adapters.start","name":"Adapters.start","line":52,"end_line":56,"hash":"f9e62a9199f0259f5c22913625e6e497548b3887cc24871bba475e73e52b3750"}]}
// mutate4javascript-manifest-end
+4 -1
View File
@@ -24,7 +24,10 @@ const DB_NAMES = [
'follows',
'rooms',
'processErrors',
'ptxs'
'ptxs',
'polls',
'pollOptions',
'pollVotes'
]
class LevelDb {
+79
View File
@@ -0,0 +1,79 @@
/*
Adapter for querying Memo polls from the polls, pollOptions, and pollVotes
LevelDB stores.
The indexer stores:
- Polls keyed by txid with { addr, pollType, optionCount, question,
seen, blockHeight }.
- Poll options keyed by txid with { addr, pollTxid, option, seen,
blockHeight }.
- Poll votes keyed by txid with { addr, pollTxid, comment, seen,
blockHeight }.
This adapter exposes:
- getPoll(txid) - poll record plus options and votes
- getPollOptions(txid) - options for a poll
- getPollVotes(txid) - votes for a poll
*/
class PollQuery {
constructor (localConfig = {}) {
const { pollsDb, pollOptionsDb, pollVotesDb } = localConfig
if (!pollsDb) {
throw new Error('pollsDb required when instantiating PollQuery adapter.')
}
if (!pollOptionsDb) {
throw new Error('pollOptionsDb required when instantiating PollQuery adapter.')
}
if (!pollVotesDb) {
throw new Error('pollVotesDb required when instantiating PollQuery adapter.')
}
this.pollsDb = pollsDb
this.pollOptionsDb = pollOptionsDb
this.pollVotesDb = pollVotesDb
this.getPoll = this.getPoll.bind(this)
this.getPollOptions = this.getPollOptions.bind(this)
this.getPollVotes = this.getPollVotes.bind(this)
}
async getPoll (txid) {
try {
const poll = await this.pollsDb.get(txid)
const options = await this.getPollOptions(txid)
const votes = await this.getPollVotes(txid)
return { ...poll, txid, options, votes }
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') {
return null
}
throw err
}
}
async getPollOptions (txid) {
return this._collectByPollTxid(this.pollOptionsDb, txid)
}
async getPollVotes (txid) {
return this._collectByPollTxid(this.pollVotesDb, txid)
}
// Collect every record in `db` that references the given poll txid, keeping
// each stored record's `txid` key alongside its value.
async _collectByPollTxid (db, txid) {
const items = []
for await (const [key, value] of db.iterator()) {
if (value?.pollTxid === txid) {
items.push({ ...value, txid: key })
}
}
return items
}
}
export default PollQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:47:05.014Z","module_hash":"b7e55eb605d5943b533f61016cc9445be11991e805f81a5db64c5898a14caf44","functions":[{"id":"func/PollQuery.constructor","name":"PollQuery.constructor","line":20,"end_line":38,"hash":"32deeef8172fe11e250c70a4f34778045962dfcb62e9ac262288e904e6e3aa62"},{"id":"func/PollQuery.getPoll","name":"PollQuery.getPoll","line":40,"end_line":52,"hash":"80e44cb0a948458726dcc1da0f06ddbd20118981e3d00de14791f480202b941e"},{"id":"func/PollQuery.getPollOptions","name":"PollQuery.getPollOptions","line":54,"end_line":56,"hash":"f391dd3bc4e82a41d3fc0e0c7d6a2ae2f85225ee37b0c07816e20d8ad0250db1"},{"id":"func/PollQuery.getPollVotes","name":"PollQuery.getPollVotes","line":58,"end_line":60,"hash":"c18ab12a96c161e3f516fb8c7b631227e953a67cae8943962b0431526eddf2a9"},{"id":"func/PollQuery._collectByPollTxid","name":"PollQuery._collectByPollTxid","line":64,"end_line":72,"hash":"62be14afb630b72ddbd4c7d554f3e89d8aeffbf22ff7cc06509de13d5c94e4c0"}]}
// mutate4javascript-manifest-end
@@ -8,6 +8,7 @@ import ProfileRouter from './profile/index.js'
import PostsRouter from './posts/index.js'
import FollowRouter from './follow/index.js'
import TopicsRouter from './topics/index.js'
import PollsRouter from './polls/index.js'
class RESTControllers {
constructor (localConfig = {}) {
@@ -39,7 +40,14 @@ class RESTControllers {
const topicsRouter = new TopicsRouter(dependencies)
topicsRouter.attach(app)
const pollsRouter = new PollsRouter(dependencies)
pollsRouter.attach(app)
}
}
export default RESTControllers
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:03:03.560Z","module_hash":"3ddddec1509f140bafe4af35e833b5d7da26c83c285a01aeb2278854fee652b5","functions":[{"id":"func/RESTControllers.constructor","name":"RESTControllers.constructor","line":14,"end_line":18,"hash":"031cf6d9700e200ae1692b726146d49767c8a626b9fc6e5827e950da3883b13c"},{"id":"func/RESTControllers.attachRESTControllers","name":"RESTControllers.attachRESTControllers","line":20,"end_line":46,"hash":"c784ae664454f30c336337e0b68d71655b37a630a4ae13ec4cf4179272591783"}]}
// mutate4javascript-manifest-end
@@ -48,5 +48,12 @@ export const ENTITY_CONFIG = [
{ route: 'follow', dbProp: 'followsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'followData' },
{ route: 'room', dbProp: 'roomsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'roomData' },
{ route: 'processerror', dbProp: 'processErrorsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'errorData' },
{ route: 'ptx', dbProp: 'ptxsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'ptxData' }
{ route: 'ptx', dbProp: 'ptxsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'ptxData' },
{ route: 'poll', dbProp: 'pollsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'pollData' },
{ route: 'polloption', dbProp: 'pollOptionsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'optionData' },
{ route: 'pollvote', dbProp: 'pollVotesDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'voteData' }
]
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:03:44.574Z","module_hash":"af3658a3d322d18b162ddedfbdf71a121b304b9e813c86a2bc7db7ad441c2f05","functions":[{"id":"func/makeCrudHandlers","name":"makeCrudHandlers","line":5,"end_line":35,"hash":"133b135aa115c058291b6df4a0b6b15152dde83c3163890e0eab0c3b018f8d4d"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,85 @@
/*
REST API controller for /polls routes.
*/
import wlogger from '../../../adapters/wlogger.js'
class PollsRESTControllerLib {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
this.useCases = localConfig.useCases
if (!this.adapters) {
throw new Error('Adapters required for Polls REST Controller.')
}
if (!this.useCases) {
throw new Error('Use Cases required for Polls REST Controller.')
}
this.getPoll = this.getPoll.bind(this)
this.getPollOptions = this.getPollOptions.bind(this)
this.getPollVotes = this.getPollVotes.bind(this)
this.handleError = this.handleError.bind(this)
}
handleError (ctx, err) {
if (err.status) {
ctx.throw(err.status, err.message || err)
} else {
wlogger.error('Error in polls controller: ', err)
ctx.throw(500, err.message || 'Internal server error')
}
}
// Run a poll read use case for the txid in ctx.params and surface the result
// on ctx.body, routing any error through handleError.
async _run (ctx, useCase) {
try {
const { txid } = ctx.params
ctx.body = await useCase.execute({ txid })
} catch (err) {
this.handleError(ctx, err)
}
}
/**
* @api {get} /polls/:txid Get a poll
* @apiPermission public
* @apiName GetPoll
* @apiGroup REST Polls
*
* @apiDescription Returns a poll with its question, options, and votes.
*/
async getPoll (ctx) {
await this._run(ctx, this.useCases.getPoll)
}
/**
* @api {get} /polls/:txid/options Get poll options
* @apiPermission public
* @apiName GetPollOptions
* @apiGroup REST Polls
*
* @apiDescription Returns the options for a poll.
*/
async getPollOptions (ctx) {
await this._run(ctx, this.useCases.getPollOptions)
}
/**
* @api {get} /polls/:txid/votes Get poll votes
* @apiPermission public
* @apiName GetPollVotes
* @apiGroup REST Polls
*
* @apiDescription Returns the votes for a poll.
*/
async getPollVotes (ctx) {
await this._run(ctx, this.useCases.getPollVotes)
}
}
export default PollsRESTControllerLib
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-29T00:15:40.936Z","module_hash":"1ba0b711ef8d4702bfd92d282898577110efbf49a3ae239efd938658f7db05fd","functions":[{"id":"func/PollsRESTControllerLib.constructor","name":"PollsRESTControllerLib.constructor","line":8,"end_line":22,"hash":"3ca791734bf514b4dac5fcdb46eacbab0ef4b86628f11ea5d64ee6ce6fe17cf6"},{"id":"func/PollsRESTControllerLib.handleError","name":"PollsRESTControllerLib.handleError","line":24,"end_line":31,"hash":"8acfb5f315721c8866e4b735febb545f530efaa194243a2e921b04fb3ab87f68"},{"id":"func/PollsRESTControllerLib._run","name":"PollsRESTControllerLib._run","line":35,"end_line":42,"hash":"b19e3acdd8b19c8c75a506a89f937c8d6732af50753fa3e5494855cb7a091d95"},{"id":"func/PollsRESTControllerLib.getPoll","name":"PollsRESTControllerLib.getPoll","line":52,"end_line":54,"hash":"4064bdaafd63614d997cc03f592cb49530853ccff8cee61edff973212e48984a"},{"id":"func/PollsRESTControllerLib.getPollOptions","name":"PollsRESTControllerLib.getPollOptions","line":64,"end_line":66,"hash":"d8ee0e6f393d97669bb1d775eeee3c804ddadf546072eaa2a0ffa0dcabf6de3e"},{"id":"func/PollsRESTControllerLib.getPollVotes","name":"PollsRESTControllerLib.getPollVotes","line":76,"end_line":78,"hash":"d25ca37fabc1d307965a33739626512472ff654955056667b2a46c3bf3d86de5"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,39 @@
/*
REST API router for /polls routes.
*/
import Router from 'koa-router'
import PollsRESTControllerLib from './controller.js'
class PollsRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
this.useCases = localConfig.useCases
if (!this.adapters) {
throw new Error('Adapters required when instantiating Polls REST Controller.')
}
if (!this.useCases) {
throw new Error('Use Cases required when instantiating Polls REST Controller.')
}
this.pollsRESTController = new PollsRESTControllerLib({
adapters: this.adapters,
useCases: this.useCases
})
this.router = new Router({ prefix: '/polls' })
}
attach (app) {
this.router.get('/:txid', this.pollsRESTController.getPoll)
this.router.get('/:txid/options', this.pollsRESTController.getPollOptions)
this.router.get('/:txid/votes', this.pollsRESTController.getPollVotes)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
}
export default PollsRouter
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:03:23.586Z","module_hash":"513519de883b3a86c55dbfac0282a28da8c55b234d5bd3da179c67999d3bd641","functions":[{"id":"func/PollsRouter.constructor","name":"PollsRouter.constructor","line":9,"end_line":24,"hash":"15322091dfd4b69a3edffc9381579a6d356db5acbb8c330e17d715b354ba2fbc"},{"id":"func/PollsRouter.attach","name":"PollsRouter.attach","line":26,"end_line":32,"hash":"14d03a2f70baf72738b27d4bf570f9731634db43648ad065019f7724daa3b38a"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,21 @@
/*
Use case: read the options for a single Memo poll.
*/
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPollOptions extends PollReadUseCase {
constructor (localConfig = {}) {
super(localConfig, {
useCaseName: 'GetPollOptions',
adapterMethod: 'getPollOptions',
resultKey: 'options'
})
}
}
export default GetPollOptions
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:40:35.787Z","module_hash":"b71c980598c4c1cc3e4dca02639ab4c3fe2442f26f4671b8b40e2c43de3af969","functions":[{"id":"func/GetPollOptions.constructor","name":"GetPollOptions.constructor","line":8,"end_line":14,"hash":"d70fd8e4fec8049576a4b42bc64c25ff04a4d6fe8b2d60f712fdb552c4767284"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,21 @@
/*
Use case: read the votes for a single Memo poll.
*/
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPollVotes extends PollReadUseCase {
constructor (localConfig = {}) {
super(localConfig, {
useCaseName: 'GetPollVotes',
adapterMethod: 'getPollVotes',
resultKey: 'votes'
})
}
}
export default GetPollVotes
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:41:07.936Z","module_hash":"c1bd922f19d2b0f7c126071c7b991f31e0dea1461ab897e962a9d3b4628ca632","functions":[{"id":"func/GetPollVotes.constructor","name":"GetPollVotes.constructor","line":8,"end_line":14,"hash":"3b4ee6c985d4a52bb7690d63d876443c04c878f1a4603201cf93a16e94f65bbc"}]}
// mutate4javascript-manifest-end
+28
View File
@@ -0,0 +1,28 @@
/*
Use case: read a single Memo poll, including its options and votes.
*/
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPoll extends PollReadUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'GetPoll' })
}
async execute (inObj = {}) {
const txid = this.parseTxid(inObj.txid)
const poll = await this.adapters.pollQuery.getPoll(txid)
if (!poll) {
const err = new Error('poll not found')
err.status = 404
throw err
}
return poll
}
}
export default GetPoll
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T22:47:58.609Z","module_hash":"8feea2939b6c387aac2fc9d4bac7fb70746985efd2986d68b111f108087933b7","functions":[{"id":"func/GetPoll.constructor","name":"GetPoll.constructor","line":8,"end_line":10,"hash":"d22cd8ab0cf5d9bc3f9a83be61cee40ecaf93a05e85639b3e07c3a67463318f7"},{"id":"func/GetPoll.execute","name":"GetPoll.execute","line":12,"end_line":21,"hash":"e8c18d07e50ee2bdced28b5b9a77ba79789a21e38ed6a6d67f63e4681b078b11"}]}
// mutate4javascript-manifest-end
+19 -1
View File
@@ -13,6 +13,9 @@ import ListTopics from './list-topics.js'
import ListTopicPosts from './list-topic-posts.js'
import TopicFollowState from './topic-follow-state.js'
import ListTopicFollowers from './list-topic-followers.js'
import GetPoll from './get-poll.js'
import GetPollOptions from './get-poll-options.js'
import GetPollVotes from './get-poll-votes.js'
class UseCases {
constructor (localConfig = {}) {
@@ -35,6 +38,9 @@ class UseCases {
this.listTopicPosts = null
this.topicFollowState = null
this.listTopicFollowers = null
this.getPoll = null
this.getPollOptions = null
this.getPollVotes = null
}
async start () {
@@ -82,6 +88,18 @@ class UseCases {
adapters: this.adapters
})
this.getPoll = new GetPoll({
adapters: this.adapters
})
this.getPollOptions = new GetPollOptions({
adapters: this.adapters
})
this.getPollVotes = new GetPollVotes({
adapters: this.adapters
})
console.log('Use cases initialized.')
return true
@@ -91,5 +109,5 @@ class UseCases {
export default UseCases
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T20:07:25.668Z","module_hash":"4056d0f29e6828f95ab07f34186b3cef9d93f244c3d6914aa52463abdc224f32","functions":[{"id":"func/UseCases.constructor","name":"UseCases.constructor","line":18,"end_line":38,"hash":"bf14b8bc15f8a9ad966bb4d2a2de92072be3ec695af1aef5604495f5cf681e34"},{"id":"func/UseCases.start","name":"UseCases.start","line":40,"end_line":88,"hash":"f6fbf584c0e6786294a352fdae4e7a01fef1d24d31ca5202a33f5ed5e1772020"}]}
// {"version":1,"tested_at":"2026-08-28T23:02:43.390Z","module_hash":"cf2bffa7de787c479a3849641f6f6ead863cea555a6c2db995179732bcf05466","functions":[{"id":"func/UseCases.constructor","name":"UseCases.constructor","line":21,"end_line":44,"hash":"3fb83ec6337d0634fa8b1e7431e0e94c2aa4be2d3cbc930841d294a08b465f98"},{"id":"func/UseCases.start","name":"UseCases.start","line":46,"end_line":106,"hash":"c876c85df5fd28146e8f6bce6aa7c48eed7ff3303cb3384cf0a9704926b837a3"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,47 @@
/*
Shared construction and txid-validation contract for poll read use cases.
Each poll read use case validates that an adapters bundle with a pollQuery
adapter is supplied and binds its execute method, and shares the identical
`txid is required` input validation. Centralizing this removes per-class
constructor and parseTxid boilerplate.
*/
export class PollReadUseCase {
constructor (localConfig = {}, { useCaseName, adapterMethod, resultKey } = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(`Adapters required when instantiating ${useCaseName} use case.`)
}
if (!this.adapters.pollQuery) {
throw new Error(`pollQuery adapter required for ${useCaseName} use case.`)
}
this.adapterMethod = adapterMethod
this.resultKey = resultKey
this.execute = this.execute.bind(this)
}
// A poll read always targets a single txid; reject empty or non-string ids
// with a 400 status so the REST layer maps it to a client error.
parseTxid (txid) {
if (!txid || typeof txid !== 'string') {
const err = new Error('txid is required')
err.status = 400
throw err
}
return txid
}
// Shared list-read behavior for poll children (options, votes). Subclasses
// set `adapterMethod` (the pollQuery method to call) and `resultKey` (the
// key under which the returned list is exposed).
async execute (inObj = {}) {
const txid = this.parseTxid(inObj.txid)
const result = await this.adapters.pollQuery[this.adapterMethod](txid)
return { [this.resultKey]: result }
}
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:39:52.390Z","module_hash":"8a17267645dc3869fcbcc44f22d6fab8fc12dacfabb85c6f333b1b852b0f0ffc","functions":[{"id":"func/PollReadUseCase.constructor","name":"PollReadUseCase.constructor","line":11,"end_line":22,"hash":"853d2c32753cbac65260835dc0a07012da87ca2bf4acfbe8c64075d7be14cbae"},{"id":"func/PollReadUseCase.parseTxid","name":"PollReadUseCase.parseTxid","line":26,"end_line":33,"hash":"a47f8a8031755be93355ffbafbce70cf857c67dfd99536221754a7049dd5be1c"},{"id":"func/PollReadUseCase.execute","name":"PollReadUseCase.execute","line":38,"end_line":42,"hash":"b67523b8241b3fc1ce0532978dbbaccc64d1bd1aa2dfc96d7f18d57b9c436c95"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,149 @@
/*
Property tests for the PollQuery adapter's option and vote filtering.
These pin down invariants the unit tests probe at fixed fixtures:
- Partition/conservation: getPollOptions(txid) returns exactly the
records that reference that poll, and the records are partitioned such
that every stored record is returned by exactly one poll.
- Value fidelity: each returned record keeps its own txid key and its
original option/comment text.
*/
import test from 'node:test'
import { seededRandom, forAll, txidGen } from './harness.js'
import PollQuery from '../../src/adapters/poll-query.js'
const rng = seededRandom(20260828)
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async put (key, value) {
store.set(key, value)
},
iterator () {
const entries = Array.from(store.entries())
let i = 0
return {
[Symbol.asyncIterator] () {
return this
},
async next () {
if (i >= entries.length) return { done: true }
const entry = entries[i++]
return { value: entry, done: false }
}
}
}
}
}
function randomText (prefix) {
return `${prefix}-${Math.floor(rng() * 1e6)}`
}
test('getPollOptions returns exactly the options for the requested poll', async () => {
await forAll(
() => {
const pollCount = 1 + Math.floor(rng() * 8)
const polls = []
for (let i = 0; i < pollCount; i++) {
polls.push({ txid: txidGen(rng), count: Math.floor(rng() * 6) })
}
return polls
},
async (polls) => {
const pollOptionsDb = makeDb()
const query = new PollQuery({ pollsDb: makeDb(), pollOptionsDb, pollVotesDb: makeDb() })
const byPoll = new Map()
for (const poll of polls) {
const records = []
for (let i = 0; i < poll.count; i++) {
const key = `${poll.txid}-opt-${i}`
const value = { pollTxid: poll.txid, option: randomText('opt') }
await pollOptionsDb.put(key, value)
records.push({ ...value, txid: key })
}
byPoll.set(poll.txid, records)
}
for (const poll of polls) {
const got = await query.getPollOptions(poll.txid)
const expected = byPoll.get(poll.txid)
if (got.length !== expected.length) return false
const gotMap = new Map(got.map((r) => [r.txid, r.option]))
for (const rec of expected) {
if (gotMap.get(rec.txid) !== rec.option) return false
}
}
return true
},
{ label: 'getPollOptions per-poll fidelity', samples: 200 }
)
})
test('getPollOptions partitions all stored options without loss', async () => {
await forAll(
() => {
const n = Math.floor(rng() * 30)
const records = []
for (let i = 0; i < n; i++) {
records.push({ key: `opt-${i}`, pollTxid: txidGen(rng), option: randomText('opt') })
}
return records
},
async (records) => {
const pollOptionsDb = makeDb()
for (const rec of records) {
await pollOptionsDb.put(rec.key, { pollTxid: rec.pollTxid, option: rec.option })
}
const query = new PollQuery({ pollsDb: makeDb(), pollOptionsDb, pollVotesDb: makeDb() })
const distinctPolls = [...new Set(records.map((r) => r.pollTxid))]
let seen = 0
const seenKeys = new Set()
for (const pollTxid of distinctPolls) {
for (const rec of await query.getPollOptions(pollTxid)) {
seen++
seenKeys.add(rec.txid)
}
}
return seen === records.length && seenKeys.size === records.length
},
{ label: 'getPollOptions partition conservation' }
)
})
test('getPollVotes returns votes that preserve their comment and key', async () => {
await forAll(
() => {
const pollTxid = txidGen(rng)
const n = Math.floor(rng() * 10)
return { pollTxid, n }
},
async ({ pollTxid, n }) => {
const pollVotesDb = makeDb()
const query = new PollQuery({ pollsDb: makeDb(), pollOptionsDb: makeDb(), pollVotesDb })
for (let i = 0; i < n; i++) {
await pollVotesDb.put(`vote-${i}`, { pollTxid, comment: randomText('vote') })
}
// Add an unrelated vote that must be excluded.
await pollVotesDb.put('other', { pollTxid: txidGen(rng), comment: 'x' })
const got = await query.getPollVotes(pollTxid)
return got.length === n && got.every((r) => r.pollTxid === pollTxid && r.comment.startsWith('vote-'))
},
{ label: 'getPollVotes fidelity and exclusion' }
)
})
@@ -0,0 +1,80 @@
/*
Unit tests for the PollQuery adapter.
*/
import { assert } from 'chai'
import PollQuery from '../../../src/adapters/poll-query.js'
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async put (key, value) {
store.set(key, value)
},
iterator () {
const entries = Array.from(store.entries())
let i = 0
return {
[Symbol.asyncIterator] () {
return this
},
async next () {
if (i >= entries.length) return { done: true }
const entry = entries[i++]
return { value: entry, done: false }
}
}
}
}
}
describe('PollQuery', () => {
let pollsDb, pollOptionsDb, pollVotesDb, query
beforeEach(() => {
pollsDb = makeDb()
pollOptionsDb = makeDb()
pollVotesDb = makeDb()
query = new PollQuery({ pollsDb, pollOptionsDb, pollVotesDb })
})
it('should return null when the poll does not exist', async () => {
const result = await query.getPoll('missing')
assert.isNull(result)
})
it('should return a poll with its options and votes', async () => {
await pollsDb.put('poll-1', { question: 'which?', optionCount: 2, pollType: 1 })
await pollOptionsDb.put('opt-1', { pollTxid: 'poll-1', option: 'yes' })
await pollVotesDb.put('vote-1', { pollTxid: 'poll-1', comment: 'yes' })
const result = await query.getPoll('poll-1')
assert.equal(result.question, 'which?')
assert.equal(result.options.length, 1)
assert.equal(result.options[0].option, 'yes')
assert.equal(result.votes.length, 1)
assert.equal(result.votes[0].comment, 'yes')
})
it('should only return options and votes for the requested poll', async () => {
await pollsDb.put('poll-1', { question: 'which?', optionCount: 2, pollType: 1 })
await pollOptionsDb.put('opt-1', { pollTxid: 'poll-1', option: 'yes' })
await pollOptionsDb.put('opt-2', { pollTxid: 'poll-2', option: 'no' })
await pollVotesDb.put('vote-1', { pollTxid: 'poll-1', comment: 'yes' })
await pollVotesDb.put('vote-2', { pollTxid: 'poll-2', comment: 'no' })
const result = await query.getPoll('poll-1')
assert.equal(result.options.length, 1)
assert.equal(result.votes.length, 1)
})
})
@@ -0,0 +1,104 @@
import { assert } from 'chai'
import sinon from 'sinon'
import PollsRESTControllerLib from '../../../src/controllers/rest-api/polls/controller.js'
describe('#PollsRESTController', () => {
let uut
let sandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
uut = new PollsRESTControllerLib({
adapters: {},
useCases: {
getPoll: {
execute: sandbox.stub().resolves({
txid: 'poll-1',
question: 'which?',
options: [],
votes: []
})
},
getPollOptions: {
execute: sandbox.stub().resolves({
txid: 'poll-1',
options: [{ option: 'yes' }, { option: 'no' }]
})
},
getPollVotes: {
execute: sandbox.stub().resolves({
txid: 'poll-1',
votes: [{ comment: 'hello' }]
})
}
}
})
})
afterEach(() => sandbox.restore())
it('should require adapters on instantiation', () => {
assert.throws(
() => new PollsRESTControllerLib({ useCases: {} }),
/Adapters required/
)
})
it('should require use cases on instantiation', () => {
assert.throws(
() => new PollsRESTControllerLib({ adapters: {} }),
/Use Cases required/
)
})
it('should return a poll from the use case', async () => {
const ctx = { params: { txid: 'poll-1' }, body: null, throw: sandbox.stub() }
await uut.getPoll(ctx)
assert.equal(uut.useCases.getPoll.execute.callCount, 1)
assert.deepEqual(uut.useCases.getPoll.execute.firstCall.args[0], { txid: 'poll-1' })
assert.equal(ctx.body.question, 'which?')
})
it('should return poll options from the use case', async () => {
const ctx = { params: { txid: 'poll-1' }, body: null, throw: sandbox.stub() }
await uut.getPollOptions(ctx)
assert.equal(uut.useCases.getPollOptions.execute.callCount, 1)
assert.equal(ctx.body.options.length, 2)
assert.equal(ctx.body.options[0].option, 'yes')
})
it('should return poll votes from the use case', async () => {
const ctx = { params: { txid: 'poll-1' }, body: null, throw: sandbox.stub() }
await uut.getPollVotes(ctx)
assert.equal(uut.useCases.getPollVotes.execute.callCount, 1)
assert.equal(ctx.body.votes.length, 1)
assert.equal(ctx.body.votes[0].comment, 'hello')
})
it('should preserve the status and message of a statused error', async () => {
const err = new Error('poll not found')
err.status = 404
uut.useCases.getPoll.execute = sandbox.stub().rejects(err)
const ctx = { params: { txid: 'poll-1' }, body: null, throw: sandbox.stub() }
await uut.getPoll(ctx)
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 404)
assert.equal(ctx.throw.firstCall.args[1], 'poll not found')
})
it('should throw a 500 when the use case fails without a status', async () => {
uut.useCases.getPollOptions.execute = sandbox.stub().rejects(new Error('boom'))
const ctx = { params: { txid: 'poll-1' }, body: null, throw: sandbox.stub() }
await uut.getPollOptions(ctx)
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 500)
assert.equal(ctx.throw.firstCall.args[1], 'boom')
})
})
@@ -0,0 +1,41 @@
/*
Unit tests for the GetPollOptions use case.
*/
import { assert } from 'chai'
import GetPollOptions from '../../../src/use-cases/get-poll-options.js'
describe('GetPollOptions', () => {
it('should throw 400 when txid is missing', async () => {
const useCase = new GetPollOptions({
adapters: {
pollQuery: {
async getPollOptions () { return [] }
}
}
})
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.equal(err.status, 400)
}
})
it('should return the options for the poll', async () => {
const useCase = new GetPollOptions({
adapters: {
pollQuery: {
async getPollOptions (txid) {
return [{ txid: 'opt-1', pollTxid: txid, option: 'yes' }]
}
}
}
})
const result = await useCase.execute({ txid: 'poll-1' })
assert.equal(result.options.length, 1)
assert.equal(result.options[0].option, 'yes')
})
})
@@ -0,0 +1,41 @@
/*
Unit tests for the GetPollVotes use case.
*/
import { assert } from 'chai'
import GetPollVotes from '../../../src/use-cases/get-poll-votes.js'
describe('GetPollVotes', () => {
it('should throw 400 when txid is missing', async () => {
const useCase = new GetPollVotes({
adapters: {
pollQuery: {
async getPollVotes () { return [] }
}
}
})
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.equal(err.status, 400)
}
})
it('should return the votes for the poll', async () => {
const useCase = new GetPollVotes({
adapters: {
pollQuery: {
async getPollVotes (txid) {
return [{ txid: 'vote-1', pollTxid: txid, comment: 'yes' }]
}
}
}
})
const result = await useCase.execute({ txid: 'poll-1' })
assert.equal(result.votes.length, 1)
assert.equal(result.votes[0].comment, 'yes')
})
})
@@ -0,0 +1,74 @@
/*
Unit tests for the GetPoll use case.
*/
import { assert } from 'chai'
import GetPoll from '../../../src/use-cases/get-poll.js'
describe('GetPoll', () => {
it('should throw 400 when txid is missing', async () => {
const useCase = new GetPoll({
adapters: {
pollQuery: {
async getPoll () { return null }
}
}
})
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.equal(err.status, 400)
}
})
it('should throw 400 when txid is an empty string', async () => {
const useCase = new GetPoll({
adapters: {
pollQuery: {
async getPoll () { return null }
}
}
})
try {
await useCase.execute({ txid: '' })
assert.fail('expected error')
} catch (err) {
assert.equal(err.status, 400)
}
})
it('should throw 404 when poll does not exist', async () => {
const useCase = new GetPoll({
adapters: {
pollQuery: {
async getPoll () { return null }
}
}
})
try {
await useCase.execute({ txid: 'missing' })
assert.fail('expected error')
} catch (err) {
assert.equal(err.status, 404)
}
})
it('should return the poll from the query adapter', async () => {
const useCase = new GetPoll({
adapters: {
pollQuery: {
async getPoll (txid) {
return { txid, question: 'which?', options: [], votes: [] }
}
}
}
})
const result = await useCase.execute({ txid: 'poll-1' })
assert.equal(result.question, 'which?')
})
})
+203
View File
@@ -10,6 +10,9 @@ import crypto from 'node:crypto'
import { handlePost } from '../../src/use-cases/action-types/post.js'
import { handleReply } from '../../src/use-cases/action-types/reply.js'
import { handleLike } from '../../src/use-cases/action-types/like.js'
import { handleCreatePoll } from '../../src/use-cases/action-types/poll-create.js'
import { handleAddPollOption } from '../../src/use-cases/action-types/poll-option.js'
import { handlePollVote } from '../../src/use-cases/action-types/poll-vote.js'
import BackupDb from '../../src/use-cases/backup-db.js'
function makeInMemoryDb () {
@@ -77,6 +80,9 @@ async function createWorld () {
const likesDb = makeInMemoryDb()
const postLikesDb = makeInMemoryDb()
const backupRequestsDb = makeInMemoryDb()
const pollDb = makeInMemoryDb()
const pollOptionDb = makeInMemoryDb()
const pollVoteDb = makeInMemoryDb()
const adapters = {
postDb: postsDb,
@@ -86,6 +92,9 @@ async function createWorld () {
postChildDb: postChildrenDb,
likeDb: likesDb,
postLikeDb: postLikesDb,
pollDb,
pollOptionDb,
pollVoteDb,
processErrorDb: makeInMemoryDb(),
dbCtrl: {
backupDb: async (height, epoch) => {
@@ -105,6 +114,9 @@ async function createWorld () {
likesDb,
postLikesDb,
backupRequestsDb,
pollsDb: pollDb,
pollOptionsDb: pollOptionDb,
pollVotesDb: pollVoteDb,
txidMap: new Map(),
lastTxid: null,
lastHeight: null,
@@ -236,6 +248,197 @@ const handlers = [
})
}
},
{
name: 'db instance that records poll records',
pattern: /^a psf-memo-db instance that records poll records$/,
async run () {
// World is already created with poll stores.
}
},
{
name: 'process a create-poll transaction',
pattern: /^the indexer processes a create-poll transaction with the question "(.+)" and (.+) options$/,
async run (m, example, world) {
const txid = deriveTxid(`poll-${Date.now()}-${Math.random().toString(36).slice(2)}`)
const question = resolveParam(m[1], example)
const optionCount = parseInt(resolveParam(m[2], example), 10)
const height = 600100
const addr = 'bitcoincash:qaddr-a'
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d10', 'hex')
const pollTypeBuf = Buffer.from([1])
const optionCountBuf = Buffer.from([optionCount])
const questionBuf = Buffer.from(question, 'utf8')
await handleCreatePoll({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
decoded: {
action: 'createPoll',
prefix,
pushDatas: [prefix, pollTypeBuf, optionCountBuf, questionBuf]
}
})
}
},
{
name: 'process an add-option transaction',
pattern: /^the indexer processes an add-option transaction for the poll (.+) with the option "(.+)"$/,
async run (m, example, world) {
const txid = deriveTxid(`option-${Date.now()}-${Math.random().toString(36).slice(2)}`)
const pollTxid = resolveTxid(m[1], example, world)
const option = resolveParam(m[2], example)
const height = 600101
const addr = 'bitcoincash:qaddr-a'
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d13', 'hex')
const pollHash = Buffer.from(pollTxid, 'hex').reverse()
const optionBuf = Buffer.from(option, 'utf8')
await handleAddPollOption({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
decoded: {
action: 'addPollOption',
prefix,
pushDatas: [prefix, pollHash, optionBuf]
}
})
}
},
{
name: 'process a vote transaction',
pattern: /^the indexer processes a vote transaction for the poll (.+) with the comment "(.+)"$/,
async run (m, example, world) {
const txid = deriveTxid(`vote-${Date.now()}-${Math.random().toString(36).slice(2)}`)
const pollTxid = resolveTxid(m[1], example, world)
const comment = resolveParam(m[2], example)
const height = 600102
const addr = 'bitcoincash:qaddr-a'
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d14', 'hex')
const pollHash = Buffer.from(pollTxid, 'hex').reverse()
const commentBuf = Buffer.from(comment, 'utf8')
await handlePollVote({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
decoded: {
action: 'pollVote',
prefix,
pushDatas: [prefix, pollHash, commentBuf]
}
})
}
},
{
name: 'polls store contains poll document',
pattern: /^the psf-memo-db stores a poll with the question "(.+)" and (.+) options$/,
run (m, example, world) {
const expectedQuestion = resolveParam(m[1], example)
const expectedOptionCount = parseInt(resolveParam(m[2], example), 10)
const matching = world.pollsDb.entries().filter(([key, value]) => {
return value?.question === expectedQuestion && value?.optionCount === expectedOptionCount
})
if (matching.length === 0) {
throw new Error(`Expected a poll document with question "${expectedQuestion}" and ${expectedOptionCount} options.`)
}
}
},
{
name: 'poll options store contains option document',
pattern: /^the psf-memo-db stores the option "(.+)" for the poll (.+)$/,
run (m, example, world) {
const expectedOption = resolveParam(m[1], example)
const pollTxid = resolveTxid(m[2], example, world)
const matching = world.pollOptionsDb.entries().filter(([key, value]) => {
return value?.pollTxid === pollTxid && value?.option === expectedOption
})
if (matching.length === 0) {
throw new Error(`Expected an option document "${expectedOption}" for poll ${pollTxid}.`)
}
}
},
{
name: 'poll votes store contains vote document',
pattern: /^the psf-memo-db stores the vote "(.+)" for the poll (.+)$/,
run (m, example, world) {
const expectedComment = resolveParam(m[1], example)
const pollTxid = resolveTxid(m[2], example, world)
const matching = world.pollVotesDb.entries().filter(([key, value]) => {
return value?.pollTxid === pollTxid && value?.comment === expectedComment
})
if (matching.length === 0) {
throw new Error(`Expected a vote document "${expectedComment}" for poll ${pollTxid}.`)
}
}
},
{
name: 'process error recorded and no poll stored',
pattern: /^the indexer records a process error and stores no poll$/,
run (m, example, world) {
const txid = world.lastTxid
const errors = world.adapters.processErrorDb.entries().filter(([key]) => key === txid)
if (errors.length === 0) {
throw new Error(`Expected a process error for txid ${txid}.`)
}
const polls = world.pollsDb.entries().filter(([key]) => key === txid)
if (polls.length !== 0) {
throw new Error(`Expected no poll document for txid ${txid}, but one was stored.`)
}
}
},
{
name: 'process error recorded and no option stored',
pattern: /^the indexer records a process error and stores no option$/,
run (m, example, world) {
const txid = world.lastTxid
const errors = world.adapters.processErrorDb.entries().filter(([key]) => key === txid)
if (errors.length === 0) {
throw new Error(`Expected a process error for txid ${txid}.`)
}
const options = world.pollOptionsDb.entries().filter(([key]) => key === txid)
if (options.length !== 0) {
throw new Error(`Expected no option document for txid ${txid}, but one was stored.`)
}
}
},
{
name: 'process error recorded and no vote stored',
pattern: /^the indexer records a process error and stores no vote$/,
run (m, example, world) {
const txid = world.lastTxid
const errors = world.adapters.processErrorDb.entries().filter(([key]) => key === txid)
if (errors.length === 0) {
throw new Error(`Expected a process error for txid ${txid}.`)
}
const votes = world.pollVotesDb.entries().filter(([key]) => key === txid)
if (votes.length !== 0) {
throw new Error(`Expected no vote document for txid ${txid}, but one was stored.`)
}
}
},
{
name: 'process the same Memo post transaction again',
pattern: /^the indexer processes the same Memo post transaction (.+) again$/,
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-29T00:41:48.609388971Z","feature_name":"Poll Indexer","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-indexer/specs/poll-indexer.feature","background_hash":"5a9e350be3961c678832c72e88a0f70aad794d227c57c5cd13c81f9fb63803d8","implementation_hash":"unknown","scenarios":[{"index":3,"name":"Poll Indexer - 4 a create-poll transaction with a missing question is rejected","scenario_hash":"f8dd901cc91034ed13b1eb7158e019af3db0db009fbdd58406f0b3f3b63912c3","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.609388971Z"},{"index":4,"name":"Poll Indexer - 5 an add-option transaction with a missing option is rejected","scenario_hash":"fad1157744eadd9eda2e172235e4d599aa3223e19fe9a1b45d8cae35d632e930","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.609388971Z"},{"index":5,"name":"Poll Indexer - 6 a vote transaction with a missing comment is rejected","scenario_hash":"dcfe3185e083709f448792d371441a2e4f3dbb442bbda276c6a781d14172af81","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-29T00:41:48.609388971Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Poll Indexer - 1, Poll Indexer - 2, Poll Indexer - 3, Poll Indexer - 4, Poll Indexer - 5, Poll Indexer - 6
#
# The indexer parses Memo poll actions and stores structured records:
@@ -31,6 +31,9 @@ class Adapters {
this.profilePicDb = createEntityDb('profilepic', 'addr', 'profilePicData')
this.followDb = createEntityDb('follow', 'key', 'followData')
this.roomDb = createEntityDb('room', 'key', 'roomData')
this.pollDb = createEntityDb('poll', 'txid', 'pollData')
this.pollOptionDb = createEntityDb('polloption', 'txid', 'optionData')
this.pollVoteDb = createEntityDb('pollvote', 'txid', 'voteData')
this.processErrorDb = createEntityDb('processerror', 'txid', 'errorData')
this.ptxDb = createEntityDb('ptx', 'txid', 'ptxData')
@@ -44,3 +47,7 @@ class Adapters {
}
export default Adapters
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:35:03.972Z","module_hash":"812d0c3cd8f63eb6c028112cd26891c3b9bc7a7bc1c22e97d96a153a590f1e44","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":41,"hash":"17fe3057a44fa0f8a17ddbbc5ddb285e1d29bf651bae778ac87097196d34aab0"},{"id":"func/Adapters.initAdapters","name":"Adapters.initAdapters","line":43,"end_line":46,"hash":"e3fd321225d51de1199b7ea45e4c9ab323a474c6fe99252041271e45daa59650"}]}
// mutate4javascript-manifest-end
+14 -1
View File
@@ -15,6 +15,9 @@ export const CODE_SET_PROFILE_PIC = 0x0a
export const CODE_TOPIC_MESSAGE = 0x0c
export const CODE_TOPIC_FOLLOW = 0x0d
export const CODE_TOPIC_UNFOLLOW = 0x0e
export const CODE_CREATE_POLL = 0x10
export const CODE_ADD_POLL_OPTION = 0x13
export const CODE_POLL_VOTE = 0x14
export const PREFIX_SET_NAME = Buffer.from([CODE_PREFIX, CODE_SET_NAME])
export const PREFIX_POST = Buffer.from([CODE_PREFIX, CODE_POST])
@@ -27,6 +30,9 @@ export const PREFIX_SET_PROFILE_PIC = Buffer.from([CODE_PREFIX, CODE_SET_PROFILE
export const PREFIX_TOPIC_MESSAGE = Buffer.from([CODE_PREFIX, CODE_TOPIC_MESSAGE])
export const PREFIX_TOPIC_FOLLOW = Buffer.from([CODE_PREFIX, CODE_TOPIC_FOLLOW])
export const PREFIX_TOPIC_UNFOLLOW = Buffer.from([CODE_PREFIX, CODE_TOPIC_UNFOLLOW])
export const PREFIX_CREATE_POLL = Buffer.from([CODE_PREFIX, CODE_CREATE_POLL])
export const PREFIX_ADD_POLL_OPTION = Buffer.from([CODE_PREFIX, CODE_ADD_POLL_OPTION])
export const PREFIX_POLL_VOTE = Buffer.from([CODE_PREFIX, CODE_POLL_VOTE])
export const MAX_POST_SIZE = 65000
export const MAX_REPLY_SIZE = 65000
@@ -44,7 +50,10 @@ export const ACTION_NAMES = {
[`${CODE_PREFIX}-${CODE_SET_PROFILE_PIC}`]: 'setProfilePic',
[`${CODE_PREFIX}-${CODE_TOPIC_MESSAGE}`]: 'topicMessage',
[`${CODE_PREFIX}-${CODE_TOPIC_FOLLOW}`]: 'topicFollow',
[`${CODE_PREFIX}-${CODE_TOPIC_UNFOLLOW}`]: 'topicUnfollow'
[`${CODE_PREFIX}-${CODE_TOPIC_UNFOLLOW}`]: 'topicUnfollow',
[`${CODE_PREFIX}-${CODE_CREATE_POLL}`]: 'createPoll',
[`${CODE_PREFIX}-${CODE_ADD_POLL_OPTION}`]: 'addPollOption',
[`${CODE_PREFIX}-${CODE_POLL_VOTE}`]: 'pollVote'
}
export function isMemoPrefix (buf) {
@@ -55,3 +64,7 @@ export function getActionFromPrefix (prefixBuf) {
if (!isMemoPrefix(prefixBuf)) return null
return ACTION_NAMES[`${prefixBuf[0]}-${prefixBuf[1]}`] || null
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:34:16.405Z","module_hash":"d2cadfbbec174eedf255ae25df8891bd27f5a337b390df97064fe80f9c1ee712","functions":[{"id":"func/isMemoPrefix","name":"isMemoPrefix","line":59,"end_line":61,"hash":"38aab4da302e269a0b2206cb93704a17ae8f42f49d3c969ef525243ce9a42792"},{"id":"func/getActionFromPrefix","name":"getActionFromPrefix","line":63,"end_line":66,"hash":"31ff61d788f5043d043f07abe79e9242ec1d31180b2737e81af95e7c97fdc71c"}]}
// mutate4javascript-manifest-end
@@ -7,6 +7,9 @@ import { handleFollow } from './follow.js'
import { handleSetProfilePic } from './set-profile-pic.js'
import { handleTopicMessage } from './topic-message.js'
import { handleTopicFollow } from './topic-follow.js'
import { handleCreatePoll } from './poll-create.js'
import { handleAddPollOption } from './poll-option.js'
import { handlePollVote } from './poll-vote.js'
export const ACTION_HANDLERS = {
setName: handleSetName,
@@ -19,7 +22,10 @@ export const ACTION_HANDLERS = {
setProfilePic: handleSetProfilePic,
topicMessage: handleTopicMessage,
topicFollow: handleTopicFollow,
topicUnfollow: handleTopicFollow
topicUnfollow: handleTopicFollow,
createPoll: handleCreatePoll,
addPollOption: handleAddPollOption,
pollVote: handlePollVote
}
export async function dispatchMemoAction (ctx) {
@@ -31,3 +37,7 @@ export async function dispatchMemoAction (ctx) {
await handler(ctx)
return true
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:35:27.947Z","module_hash":"a46edb4e6c6464ab3bad2bcb1eb502f6f5e1a78a8cc21006086c7543e9ed1ce1","functions":[{"id":"func/dispatchMemoAction","name":"dispatchMemoAction","line":31,"end_line":39,"hash":"975fe763535301df68af8ec017104e93f5cc591539b79e850f5b70e01cb37fec"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,64 @@
/*
Shared record creation for Memo actions that reference a parent poll by
txid and carry a single UTF-8 value (an add-poll-option or a poll-vote).
Both payloads have the same shape: exactly three pushes, a 32-byte poll
txid, and a non-empty value. Each handler stores the parsed record on its
own store, differing only in the store, the value field name, and the error
label used in process-error messages.
*/
import { utf8FromPush, logProcessError, txHashFromPush } from './helpers.js'
import { TX_HASH_LENGTH } from '../../lib/memo-codes.js'
// Validate a poll-txid child action payload and store the resulting record.
// Returns true when stored, false when an error was logged.
//
// `ctx` - the handler context (adapters, txid, signerAddr, decoded, seen,
// blockHeight)
// `db` - the poll option or poll vote store
// `valueField` - record field holding the parsed UTF-8 value
// `label` - action label used in process-error messages
// `emptyMessage` - process-error message for an empty value
export async function storePollChildRecord ({
ctx,
db,
valueField,
label,
emptyMessage
}) {
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
const { pushDatas } = decoded
if (pushDatas.length !== 3) {
await logProcessError(adapters, txid, `invalid ${label} push data count ${pushDatas.length}`, blockHeight)
return false
}
if (pushDatas[1].length !== TX_HASH_LENGTH) {
await logProcessError(adapters, txid, `${label} poll tx hash wrong size`, blockHeight)
return false
}
const pollTxid = txHashFromPush(pushDatas[1])
const value = utf8FromPush(pushDatas[2])
if (!value.length) {
await logProcessError(adapters, txid, emptyMessage, blockHeight)
return false
}
await db.create(txid, {
addr: signerAddr,
pollTxid,
[valueField]: value,
seen,
blockHeight
})
return true
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:32:27.654Z","module_hash":"20bba06dde1c1b346b14d0ad321452309c266524993e59db802294075e2c9338","functions":[{"id":"func/storePollChildRecord","name":"storePollChildRecord","line":23,"end_line":60,"hash":"b703abce31773d096540029d909d6f04d50b6b5f846184a5cff3099452e279ff"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,98 @@
import { utf8FromPush, logProcessError, postHeightKey, addrPostHeightKey } from './helpers.js'
// Create a record only when it does not already exist (idempotent writes).
async function createIfMissing (db, key, value) {
try {
await db.get(key)
} catch (err) {
await db.create(key, value)
}
}
// Normalize the create-poll push datas. Memo wallets may encode the action as
// either separate pushes ([prefix, poll_type, option_count, question]) or as
// a single combined push (prefix + poll_type + option_count + question).
// Returns { ok: false, error } on malformed input, or { ok: true, pollType,
// optionCount, question }.
function normalizePollCreateDatas (pushDatas) {
if (!pushDatas || pushDatas.length < 2) {
return { ok: false, error: `invalid create-poll push data count ${pushDatas?.length || 0}` }
}
const { ok, payload, error } = buildCreatePollPayload(pushDatas)
if (!ok) {
return { ok: false, error }
}
return {
ok: true,
pollType: payload[0],
optionCount: payload[1],
question: utf8FromPush(payload.subarray(2))
}
}
// Resolve the poll payload buffer the create-poll action encodes, which Memo
// wallets may write as either a single combined push (prefix + poll_type +
// option_count + question) or as separate pushes. Returns
// { ok: false, error } on malformed input, or { ok: true, payload }.
function buildCreatePollPayload (pushDatas) {
if (pushDatas.length === 2) {
// Combined push: prefix(2) + poll_type(1) + option_count(1) + question.
const combined = pushDatas[1]
if (combined.length < 2) {
return { ok: false, error: 'create-poll payload too short' }
}
return { ok: true, payload: combined }
}
// Separate pushes for poll_type, option_count, and question.
if (pushDatas.length !== 4) {
return { ok: false, error: `invalid create-poll push data count ${pushDatas.length}` }
}
const typeBuf = pushDatas[1]
const countBuf = pushDatas[2]
const questionBuf = pushDatas[3]
return { ok: true, payload: Buffer.concat([typeBuf, countBuf, questionBuf]) }
}
export async function handleCreatePoll (ctx) {
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
const normalized = normalizePollCreateDatas(decoded.pushDatas)
if (!normalized.ok) {
await logProcessError(adapters, txid, normalized.error, blockHeight)
return
}
const { pollType, optionCount, question } = normalized
if (!question.length) {
await logProcessError(adapters, txid, 'empty poll question', blockHeight)
return
}
const pollData = {
addr: signerAddr,
pollType,
optionCount,
question,
seen,
blockHeight
}
await createIfMissing(adapters.pollDb, txid, pollData)
const heightKey = postHeightKey(blockHeight, txid)
await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight })
const addrHeightKey = addrPostHeightKey(signerAddr, blockHeight, txid)
await createIfMissing(adapters.addrPostHeightDb, addrHeightKey, { txid, addr: signerAddr, blockHeight })
}
export { normalizePollCreateDatas }
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:33:08.576Z","module_hash":"a81b3c5b31a18d2f9af2063deb06c66c7dbc3198e401969dceb096228c7da095","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":4,"end_line":10,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/normalizePollCreateDatas","name":"normalizePollCreateDatas","line":17,"end_line":33,"hash":"6684a343c21f34d45ae584c50d95b18b4258d8b5a37f2eba84fff4270028ade1"},{"id":"func/buildCreatePollPayload","name":"buildCreatePollPayload","line":39,"end_line":58,"hash":"5ab749a5515defd5900429d8273781deee98bde27f45824c27982f0f872ae6e8"},{"id":"func/handleCreatePoll","name":"handleCreatePoll","line":60,"end_line":92,"hash":"2de305b58270638fd3cd9f46e1f2bd6decaba4c731b05ce640512e0885173f0c"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,15 @@
import { storePollChildRecord } from './poll-child.js'
export async function handleAddPollOption (ctx) {
return storePollChildRecord({
ctx,
db: ctx.adapters.pollOptionDb,
valueField: 'option',
label: 'add-poll-option',
emptyMessage: 'empty poll option'
})
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:29:33.229Z","module_hash":"50ee5536a207f62677a966a88ecd995602dde333aa18d3f0d71dd9d3b8853874","functions":[{"id":"func/handleAddPollOption","name":"handleAddPollOption","line":3,"end_line":11,"hash":"50244872bf651830f3bc2587a8ad42634a2a40fe3a65bcba0e19a3f0d0a5da88"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,15 @@
import { storePollChildRecord } from './poll-child.js'
export async function handlePollVote (ctx) {
return storePollChildRecord({
ctx,
db: ctx.adapters.pollVoteDb,
valueField: 'comment',
label: 'poll-vote',
emptyMessage: 'empty poll vote comment'
})
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T23:29:57.015Z","module_hash":"8be639071a4a0e8fb44c6a083a8625571b14b3abc47c455ce9e83067ed1f8a1f","functions":[{"id":"func/handlePollVote","name":"handlePollVote","line":3,"end_line":11,"hash":"c734c3f5457394be8929e28526ea2f1ff5cc456e8ea9a4a2eaa9792ee11ef135"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,192 @@
/*
Property tests for the indexer's poll action handlers.
These pin down invariants the unit tests only probe at fixed fixtures:
- normalizePollCreateDatas round-trips a poll_type / option_count /
question triple through both the separate-push and the combined-push
encodings.
- storePollChildRecord (used by add-poll-option and poll-vote) stores the
reverse-hex poll txid and the exact UTF-8 value for any valid payload,
and rejects malformed payloads without storing.
*/
import test from 'node:test'
import { seededRandom, forAll } from './harness.js'
import { normalizePollCreateDatas } from '../../src/use-cases/action-types/poll-create.js'
import { handleAddPollOption } from '../../src/use-cases/action-types/poll-option.js'
import { handlePollVote } from '../../src/use-cases/action-types/poll-vote.js'
const rng = seededRandom(20260828)
function randomHex (len) {
const hex = '0123456789abcdef'
let out = ''
for (let i = 0; i < len; i++) {
out += hex[Math.floor(rng() * 16)]
}
return out
}
function randomText () {
const charset = ['a', 'b', ' ', 'é']
let s = ''
const n = Math.floor(rng() * 20)
for (let i = 0; i < n; i++) {
s += charset[Math.floor(rng() * charset.length)]
}
return s
}
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
test('normalizePollCreateDatas round-trips separate pushes', async () => {
await forAll(
() => ({
pollType: Math.floor(rng() * 256),
optionCount: Math.floor(rng() * 256),
question: randomText()
}),
({ pollType, optionCount, question }) => {
const prefix = Buffer.from('6d10', 'hex')
const pushDatas = [
prefix,
Buffer.from([pollType]),
Buffer.from([optionCount]),
Buffer.from(question, 'utf8')
]
const result = normalizePollCreateDatas(pushDatas)
return result.ok &&
result.pollType === pollType &&
result.optionCount === optionCount &&
result.question === question
},
{ label: 'normalizePollCreateDatas separate-push round-trip' }
)
})
test('normalizePollCreateDatas round-trips a combined push', async () => {
await forAll(
() => ({
pollType: Math.floor(rng() * 256),
optionCount: Math.floor(rng() * 256),
question: randomText()
}),
({ pollType, optionCount, question }) => {
const prefix = Buffer.from('6d10', 'hex')
const combined = Buffer.concat([
Buffer.from([pollType]),
Buffer.from([optionCount]),
Buffer.from(question, 'utf8')
])
const result = normalizePollCreateDatas([prefix, combined])
return result.ok &&
result.pollType === pollType &&
result.optionCount === optionCount &&
result.question === question
},
{ label: 'normalizePollCreateDatas combined-push round-trip' }
)
})
test('add-option and poll-vote store the reverse txid and exact value', async () => {
await forAll(
() => ({
kind: Math.floor(rng() * 2),
txidHex: randomHex(64),
value: randomText()
}),
async ({ kind, txidHex, value }) => {
if (value.length === 0) return true
const adapters = {
pollOptionDb: makeDb(),
pollVoteDb: makeDb(),
processErrorDb: makeDb()
}
const txidBytes = Buffer.from(txidHex, 'hex').reverse()
const handler = kind === 0 ? handleAddPollOption : handlePollVote
const store = kind === 0 ? adapters.pollOptionDb : adapters.pollVoteDb
const field = kind === 0 ? 'option' : 'comment'
await handler({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: kind === 0 ? 'addPollOption' : 'pollVote',
prefix: Buffer.from(kind === 0 ? '6d13' : '6d14', 'hex'),
pushDatas: [Buffer.from('6d', 'hex'), txidBytes, Buffer.from(value, 'utf8')]
}
})
const record = await store.get('txid-1')
return record.pollTxid === txidHex && record[field] === value &&
adapters.processErrorDb.entries().length === 0
},
{ label: 'add-option and poll-vote record invariants' }
)
})
test('add-option and poll-vote reject a wrong-size txid without storing', async () => {
await forAll(
() => ({
kind: Math.floor(rng() * 2),
badLen: 1 + Math.floor(rng() * 40)
}),
async ({ kind, badLen }) => {
if (badLen === 32) return true
const adapters = {
pollOptionDb: makeDb(),
pollVoteDb: makeDb(),
processErrorDb: makeDb()
}
const handler = kind === 0 ? handleAddPollOption : handlePollVote
const store = kind === 0 ? adapters.pollOptionDb : adapters.pollVoteDb
await handler({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: kind === 0 ? 'addPollOption' : 'pollVote',
prefix: Buffer.from(kind === 0 ? '6d13' : '6d14', 'hex'),
pushDatas: [Buffer.from('6d', 'hex'), Buffer.alloc(badLen, 1), Buffer.from('yes', 'utf8')]
}
})
let stored = false
try {
await store.get('txid-1')
stored = true
} catch (err) {
// expected: not stored
}
return !stored && adapters.processErrorDb.entries().length > 0
},
{ label: 'wrong-size txid rejection' }
)
})
@@ -0,0 +1,61 @@
/*
Unit tests for the Memo protocol action codes and prefix helpers.
*/
import { assert } from 'chai'
import {
CODE_PREFIX,
CODE_SET_NAME,
CODE_CREATE_POLL,
CODE_ADD_POLL_OPTION,
CODE_POLL_VOTE,
PREFIX_CREATE_POLL,
PREFIX_ADD_POLL_OPTION,
PREFIX_POLL_VOTE,
TX_HASH_LENGTH,
isMemoPrefix,
getActionFromPrefix
} from '../../../src/lib/memo-codes.js'
describe('memo-codes', () => {
it('should expose the poll action codes', () => {
assert.equal(CODE_PREFIX, 0x6d)
assert.equal(CODE_SET_NAME, 0x01)
assert.equal(CODE_CREATE_POLL, 0x10)
assert.equal(CODE_ADD_POLL_OPTION, 0x13)
assert.equal(CODE_POLL_VOTE, 0x14)
assert.equal(TX_HASH_LENGTH, 32)
})
it('should build the poll prefixes from the code prefix', () => {
assert.deepEqual(PREFIX_CREATE_POLL, Buffer.from([0x6d, 0x10]))
assert.deepEqual(PREFIX_ADD_POLL_OPTION, Buffer.from([0x6d, 0x13]))
assert.deepEqual(PREFIX_POLL_VOTE, Buffer.from([0x6d, 0x14]))
})
it('should recognize a valid two-byte memo prefix', () => {
assert.isTrue(isMemoPrefix(Buffer.from([0x6d, 0x10])))
})
it('should reject a single-byte buffer', () => {
assert.isFalse(isMemoPrefix(Buffer.from([0x6d])))
})
it('should reject a buffer that does not start with the code prefix', () => {
assert.isFalse(isMemoPrefix(Buffer.from([0x00, 0x10])))
})
it('should reject a null buffer', () => {
assert.isNotOk(isMemoPrefix(null))
})
it('should map a poll prefix to its action name', () => {
assert.equal(getActionFromPrefix(Buffer.from([0x6d, 0x10])), 'createPoll')
assert.equal(getActionFromPrefix(Buffer.from([0x6d, 0x13])), 'addPollOption')
assert.equal(getActionFromPrefix(Buffer.from([0x6d, 0x14])), 'pollVote')
})
it('should return null for an unknown prefix', () => {
assert.equal(getActionFromPrefix(Buffer.from([0x6d, 0xff])), null)
})
})
@@ -0,0 +1,202 @@
/*
Unit tests for the create-poll indexer handler.
*/
import { assert } from 'chai'
import { handleCreatePoll, normalizePollCreateDatas } from '../../../../src/use-cases/action-types/poll-create.js'
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
function makeAdapters () {
return {
pollDb: makeDb(),
postHeightDb: makeDb(),
addrPostHeightDb: makeDb(),
processErrorDb: makeDb()
}
}
describe('handleCreatePoll', () => {
it('should store a poll with question and option count', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d10', 'hex')
const question = 'which is better?'
await handleCreatePoll({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'createPoll',
prefix,
pushDatas: [prefix, Buffer.from([1]), Buffer.from([2]), Buffer.from(question, 'utf8')]
}
})
const poll = await adapters.pollDb.get('txid-1')
assert.equal(poll.question, question)
assert.equal(poll.optionCount, 2)
assert.equal(poll.pollType, 1)
})
it('should accept a combined prefix+payload push', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d10', 'hex')
const question = 'which is better?'
const combined = Buffer.concat([Buffer.from([1]), Buffer.from([2]), Buffer.from(question, 'utf8')])
await handleCreatePoll({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'createPoll',
prefix,
pushDatas: [prefix, combined]
}
})
const poll = await adapters.pollDb.get('txid-1')
assert.equal(poll.question, question)
assert.equal(poll.optionCount, 2)
})
it('should reject an empty question and log an error', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d10', 'hex')
await handleCreatePoll({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'createPoll',
prefix,
pushDatas: [prefix, Buffer.from([1]), Buffer.from([2]), Buffer.from('', 'utf8')]
}
})
try {
await adapters.pollDb.get('txid-1')
assert.fail('expected poll to not be stored')
} catch (err) {
assert.isTrue(err.notFound)
}
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
it('should reject malformed push data count and log an error', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d10', 'hex')
await handleCreatePoll({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'createPoll',
prefix,
pushDatas: [prefix]
}
})
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
})
describe('normalizePollCreateDatas', () => {
it('should normalize separate pushes', () => {
const result = normalizePollCreateDatas([
Buffer.from('6d10', 'hex'),
Buffer.from([1]),
Buffer.from([2]),
Buffer.from('hello', 'utf8')
])
assert.isTrue(result.ok)
assert.equal(result.pollType, 1)
assert.equal(result.optionCount, 2)
assert.equal(result.question, 'hello')
})
it('should normalize a combined push', () => {
const result = normalizePollCreateDatas([
Buffer.from('6d10', 'hex'),
Buffer.concat([Buffer.from([1]), Buffer.from([2]), Buffer.from('hello', 'utf8')])
])
assert.isTrue(result.ok)
assert.equal(result.question, 'hello')
})
it('should reject a null push data list', () => {
const result = normalizePollCreateDatas(null)
assert.isFalse(result.ok)
})
it('should report a zero push count for an empty list', () => {
const result = normalizePollCreateDatas([])
assert.isFalse(result.ok)
assert.match(result.error, /count 0/)
})
it('should report the actual push count for a short list', () => {
const result = normalizePollCreateDatas([Buffer.from('6d10', 'hex')])
assert.isFalse(result.ok)
assert.match(result.error, /count 1/)
})
it('should accept a combined push of exactly two bytes', () => {
const result = normalizePollCreateDatas([
Buffer.from('6d10', 'hex'),
Buffer.from([1, 2])
])
assert.isTrue(result.ok)
assert.equal(result.pollType, 1)
assert.equal(result.optionCount, 2)
})
it('should reject a combined push that is too short', () => {
const result = normalizePollCreateDatas([
Buffer.from('6d10', 'hex'),
Buffer.from([1])
])
assert.isFalse(result.ok)
})
it('should reject a three-push payload', () => {
const result = normalizePollCreateDatas([
Buffer.from('6d10', 'hex'),
Buffer.from([1]),
Buffer.from([2])
])
assert.isFalse(result.ok)
})
})
@@ -0,0 +1,133 @@
/*
Unit tests for the add-poll-option indexer handler.
*/
import { assert } from 'chai'
import { handleAddPollOption } from '../../../../src/use-cases/action-types/poll-option.js'
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
function makeAdapters () {
return {
pollOptionDb: makeDb(),
processErrorDb: makeDb()
}
}
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
describe('handleAddPollOption', () => {
it('should store an option for a poll', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d13', 'hex')
const result = await handleAddPollOption({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'addPollOption',
prefix,
pushDatas: [prefix, Buffer.from(POLL_TXID, 'hex').reverse(), Buffer.from('yes', 'utf8')]
}
})
assert.isTrue(result)
const option = await adapters.pollOptionDb.get('txid-1')
assert.equal(option.option, 'yes')
assert.equal(option.pollTxid, POLL_TXID)
})
it('should reject an empty option and log an error', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d13', 'hex')
const result = await handleAddPollOption({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'addPollOption',
prefix,
pushDatas: [prefix, Buffer.from(POLL_TXID, 'hex').reverse(), Buffer.from('', 'utf8')]
}
})
assert.isFalse(result)
try {
await adapters.pollOptionDb.get('txid-1')
assert.fail('expected option to not be stored')
} catch (err) {
assert.isTrue(err.notFound)
}
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
it('should reject a poll tx hash with the wrong size', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d13', 'hex')
const result = await handleAddPollOption({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'addPollOption',
prefix,
pushDatas: [prefix, Buffer.from('1234', 'hex'), Buffer.from('yes', 'utf8')]
}
})
assert.isFalse(result)
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
it('should reject a wrong push data count', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d13', 'hex')
const result = await handleAddPollOption({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'addPollOption',
prefix,
pushDatas: [prefix, Buffer.from('yes', 'utf8')]
}
})
assert.isFalse(result)
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
})
@@ -0,0 +1,108 @@
/*
Unit tests for the poll-vote indexer handler.
*/
import { assert } from 'chai'
import { handlePollVote } from '../../../../src/use-cases/action-types/poll-vote.js'
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
function makeAdapters () {
return {
pollVoteDb: makeDb(),
processErrorDb: makeDb()
}
}
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
describe('handlePollVote', () => {
it('should store a vote for a poll', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d14', 'hex')
await handlePollVote({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'pollVote',
prefix,
pushDatas: [prefix, Buffer.from(POLL_TXID, 'hex').reverse(), Buffer.from('yes', 'utf8')]
}
})
const vote = await adapters.pollVoteDb.get('txid-1')
assert.equal(vote.comment, 'yes')
assert.equal(vote.pollTxid, POLL_TXID)
})
it('should reject an empty comment and log an error', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d14', 'hex')
await handlePollVote({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'pollVote',
prefix,
pushDatas: [prefix, Buffer.from(POLL_TXID, 'hex').reverse(), Buffer.from('', 'utf8')]
}
})
try {
await adapters.pollVoteDb.get('txid-1')
assert.fail('expected vote to not be stored')
} catch (err) {
assert.isTrue(err.notFound)
}
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
it('should reject a poll tx hash with the wrong size', async () => {
const adapters = makeAdapters()
const prefix = Buffer.from('6d14', 'hex')
await handlePollVote({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'pollVote',
prefix,
pushDatas: [prefix, Buffer.from('1234', 'hex'), Buffer.from('yes', 'utf8')]
}
})
const errors = adapters.processErrorDb.entries()
assert.isAbove(errors.length, 0)
})
})