Implement poll actions (create, option, vote) across client, indexer, and DB

- Client: add MemoPollCreate, MemoPollOption, MemoPollVote services and page controllers.
- Indexer: add create-poll, add-poll-option, and poll-vote handlers with new poll DB adapters.
- DB: add polls/pollOptions/pollVotes stores, PollQuery adapter, GET /polls/:txid endpoints, and use cases.
- Add Gherkin acceptance handlers and focused unit tests for all three components.

By coder.
This commit is contained in:
Chris Troutner
2026-08-28 14:50:05 -07:00
parent 5d7ce7601b
commit cf05de6630
39 changed files with 2787 additions and 4 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) {
@@ -0,0 +1,102 @@
/*
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
@@ -0,0 +1,100 @@
/*
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.
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_ADD_POLL_OPTION_PREFIX : hex prefix for the Memo add-poll-option action (0x6d13)
MAX_OPTION_BYTES : maximum option byte length (184)
POLL_TXID_BYTES : poll txid byte length (32)
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { hexToBytes } = require('./hex')
const MEMO_ADD_POLL_OPTION_PREFIX = '6d13'
const MAX_OPTION_BYTES = 184
const POLL_TXID_BYTES = 32
class MemoPollOption extends MemoAction {
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'
}
constructor (deps = {}) {
super(deps)
this.pollTxid = deps.pollTxid || ''
this.polls = deps.polls || null
}
// A poll option is over-length when its UTF-8 byte count exceeds the limit.
isTooLong (option) {
return byteLength(option) > MAX_OPTION_BYTES
}
// Compose and broadcast a Memo add-poll-option action.
async add (option) {
const check = this.validate(option)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
if (!this.pollTxid) {
const err = new Error('Poll txid is required.')
err.code = 'poll_option_validation'
throw err
}
await this.wallet.getUtxos()
const raw = buildAddPollOptionPayload(this.pollTxid, option)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
this.reflect(txid, option)
return txid
}
// Record the new option on the injected poll store when one is present.
reflect (txid, option) {
if (this.polls && typeof this.polls.addOption === 'function') {
this.polls.addOption({
txid,
pollTxid: this.pollTxid,
address: this.wallet.walletInfo.cashAddress,
option
})
}
}
}
// Build the raw OP_RETURN message payload for an add-poll-option action.
// The protocol wire format is: <poll txid 32 bytes><option UTF-8 bytes>.
function buildAddPollOptionPayload (pollTxid, option) {
const txidBytes = hexToBytes(pollTxid, POLL_TXID_BYTES, 'Poll txid')
const textBytes = new TextEncoder().encode(option)
const raw = new Uint8Array(txidBytes.length + textBytes.length)
raw.set(txidBytes, 0)
raw.set(textBytes, txidBytes.length)
return raw
}
MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX = MEMO_ADD_POLL_OPTION_PREFIX
MemoPollOption.MAX_OPTION_BYTES = MAX_OPTION_BYTES
module.exports = MemoPollOption
@@ -0,0 +1,100 @@
/*
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.
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_POLL_VOTE_PREFIX : hex prefix for the Memo poll-vote action (0x6d14)
MAX_COMMENT_BYTES : maximum comment byte length (184)
POLL_TXID_BYTES : poll txid byte length (32)
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { hexToBytes } = require('./hex')
const MEMO_POLL_VOTE_PREFIX = '6d14'
const MAX_COMMENT_BYTES = 184
const POLL_TXID_BYTES = 32
class MemoPollVote extends MemoAction {
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'
}
constructor (deps = {}) {
super(deps)
this.pollTxid = deps.pollTxid || ''
this.polls = deps.polls || null
}
// A poll vote comment is over-length when its UTF-8 byte count exceeds the limit.
isTooLong (comment) {
return byteLength(comment) > MAX_COMMENT_BYTES
}
// Compose and broadcast a Memo poll-vote action.
async vote (comment) {
const check = this.validate(comment)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
if (!this.pollTxid) {
const err = new Error('Poll txid is required.')
err.code = 'poll_vote_validation'
throw err
}
await this.wallet.getUtxos()
const raw = buildPollVotePayload(this.pollTxid, comment)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
this.reflect(txid, comment)
return txid
}
// Record the new vote on the injected poll store when one is present.
reflect (txid, comment) {
if (this.polls && typeof this.polls.addVote === 'function') {
this.polls.addVote({
txid,
pollTxid: this.pollTxid,
address: this.wallet.walletInfo.cashAddress,
comment
})
}
}
}
// Build the raw OP_RETURN message payload for a poll-vote action.
// The protocol wire format is: <poll txid 32 bytes><comment UTF-8 bytes>.
function buildPollVotePayload (pollTxid, comment) {
const txidBytes = hexToBytes(pollTxid, POLL_TXID_BYTES, 'Poll txid')
const textBytes = new TextEncoder().encode(comment)
const raw = new Uint8Array(txidBytes.length + textBytes.length)
raw.set(txidBytes, 0)
raw.set(textBytes, txidBytes.length)
return raw
}
MemoPollVote.MEMO_POLL_VOTE_PREFIX = MEMO_POLL_VOTE_PREFIX
MemoPollVote.MAX_COMMENT_BYTES = MAX_COMMENT_BYTES
module.exports = MemoPollVote
@@ -0,0 +1,66 @@
/*
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
@@ -0,0 +1,52 @@
/*
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).
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 PageController = require('./page-controller')
const MemoPollOption = require('./memo-poll-option')
class PollOptionPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoPollOption = deps.memoPollOption || null
this.adding = false
this.validationCodes = ['poll_option_validation', 'poll_option_length']
}
// Bytes remaining for the option text.
remainingCount () {
if (!this.memoPollOption) {
throw new Error('Poll option page requires a memo poll option handler.')
}
return MemoPollOption.MAX_OPTION_BYTES - this._optionBytes()
}
_optionBytes () {
return new TextEncoder().encode(this.input).length
}
// Set the in-flight flag.
_setBusy (value) {
this.adding = value
}
// Run the memo poll option action for the current input.
async _perform (input) {
if (!this.memoPollOption) {
throw new Error('Poll option page requires a memo poll option handler.')
}
return this.memoPollOption.add(input)
}
}
PollOptionPage.MAX_OPTION_BYTES = MemoPollOption.MAX_OPTION_BYTES
module.exports = PollOptionPage
@@ -0,0 +1,52 @@
/*
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).
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 PageController = require('./page-controller')
const MemoPollVote = require('./memo-poll-vote')
class PollVotePage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoPollVote = deps.memoPollVote || null
this.voting = false
this.validationCodes = ['poll_vote_validation', 'poll_vote_length']
}
// Bytes remaining for the vote comment.
remainingCount () {
if (!this.memoPollVote) {
throw new Error('Poll vote page requires a memo poll vote handler.')
}
return MemoPollVote.MAX_COMMENT_BYTES - this._commentBytes()
}
_commentBytes () {
return new TextEncoder().encode(this.input).length
}
// Set the in-flight flag.
_setBusy (value) {
this.voting = value
}
// Run the memo poll vote action for the current input.
async _perform (input) {
if (!this.memoPollVote) {
throw new Error('Poll vote page requires a memo poll vote handler.')
}
return this.memoPollVote.vote(input)
}
}
PollVotePage.MAX_COMMENT_BYTES = MemoPollVote.MAX_COMMENT_BYTES
module.exports = PollVotePage
@@ -0,0 +1,132 @@
/*
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)
})
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,110 @@
/*
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 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,110 @@
/*
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 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,65 @@
/*
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)
})
@@ -0,0 +1,59 @@
/*
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)
})
@@ -0,0 +1,59 @@
/*
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)
})