mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user