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