diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 898336d..0e38216 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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) { diff --git a/psf-memo-client/specs/poll-create.feature b/psf-memo-client/specs/poll-create.feature index 0041f5f..b9d1dd3 100644 --- a/psf-memo-client/specs/poll-create.feature +++ b/psf-memo-client/specs/poll-create.feature @@ -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 diff --git a/psf-memo-client/specs/poll-option.feature b/psf-memo-client/specs/poll-option.feature index 912c5e1..c5c01bb 100644 --- a/psf-memo-client/specs/poll-option.feature +++ b/psf-memo-client/specs/poll-option.feature @@ -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). diff --git a/psf-memo-client/specs/poll-vote.feature b/psf-memo-client/specs/poll-vote.feature index 5682cc7..d355753 100644 --- a/psf-memo-client/specs/poll-vote.feature +++ b/psf-memo-client/specs/poll-vote.feature @@ -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 diff --git a/psf-memo-client/src/services/hex.js b/psf-memo-client/src/services/hex.js index 5ef989a..5c35922 100644 --- a/psf-memo-client/src/services/hex.js +++ b/psf-memo-client/src/services/hex.js @@ -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 diff --git a/psf-memo-client/src/services/memo-poll-create.js b/psf-memo-client/src/services/memo-poll-create.js new file mode 100644 index 0000000..3ffb825 --- /dev/null +++ b/psf-memo-client/src/services/memo-poll-create.js @@ -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: . +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 diff --git a/psf-memo-client/src/services/memo-poll-option.js b/psf-memo-client/src/services/memo-poll-option.js new file mode 100644 index 0000000..2a70d4b --- /dev/null +++ b/psf-memo-client/src/services/memo-poll-option.js @@ -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 diff --git a/psf-memo-client/src/services/memo-poll-vote.js b/psf-memo-client/src/services/memo-poll-vote.js new file mode 100644 index 0000000..896d6d0 --- /dev/null +++ b/psf-memo-client/src/services/memo-poll-vote.js @@ -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 diff --git a/psf-memo-client/src/services/memo-txid-action.js b/psf-memo-client/src/services/memo-txid-action.js new file mode 100644 index 0000000..4d659e2 --- /dev/null +++ b/psf-memo-client/src/services/memo-txid-action.js @@ -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 diff --git a/psf-memo-client/src/services/poll-action-page.js b/psf-memo-client/src/services/poll-action-page.js new file mode 100644 index 0000000..ffcbc96 --- /dev/null +++ b/psf-memo-client/src/services/poll-action-page.js @@ -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 diff --git a/psf-memo-client/src/services/poll-create-page.js b/psf-memo-client/src/services/poll-create-page.js new file mode 100644 index 0000000..3200fa9 --- /dev/null +++ b/psf-memo-client/src/services/poll-create-page.js @@ -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 diff --git a/psf-memo-client/src/services/poll-option-page.js b/psf-memo-client/src/services/poll-option-page.js new file mode 100644 index 0000000..f7485e6 --- /dev/null +++ b/psf-memo-client/src/services/poll-option-page.js @@ -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 diff --git a/psf-memo-client/src/services/poll-vote-page.js b/psf-memo-client/src/services/poll-vote-page.js new file mode 100644 index 0000000..b440fe8 --- /dev/null +++ b/psf-memo-client/src/services/poll-vote-page.js @@ -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 diff --git a/psf-memo-client/test/property/poll-services.property.test.js b/psf-memo-client/test/property/poll-services.property.test.js new file mode 100644 index 0000000..fa7ac6d --- /dev/null +++ b/psf-memo-client/test/property/poll-services.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-client/test/unit/hex.test.js b/psf-memo-client/test/unit/hex.test.js new file mode 100644 index 0000000..b94c511 --- /dev/null +++ b/psf-memo-client/test/unit/hex.test.js @@ -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') +}) diff --git a/psf-memo-client/test/unit/memo-poll-create.test.js b/psf-memo-client/test/unit/memo-poll-create.test.js new file mode 100644 index 0000000..10aaf26 --- /dev/null +++ b/psf-memo-client/test/unit/memo-poll-create.test.js @@ -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/ + ) +}) diff --git a/psf-memo-client/test/unit/memo-poll-option.test.js b/psf-memo-client/test/unit/memo-poll-option.test.js new file mode 100644 index 0000000..0f48425 --- /dev/null +++ b/psf-memo-client/test/unit/memo-poll-option.test.js @@ -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/ } + ) +}) diff --git a/psf-memo-client/test/unit/memo-poll-vote.test.js b/psf-memo-client/test/unit/memo-poll-vote.test.js new file mode 100644 index 0000000..42bb964 --- /dev/null +++ b/psf-memo-client/test/unit/memo-poll-vote.test.js @@ -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/ } + ) +}) diff --git a/psf-memo-client/test/unit/poll-create-page.test.js b/psf-memo-client/test/unit/poll-create-page.test.js new file mode 100644 index 0000000..c0a7915 --- /dev/null +++ b/psf-memo-client/test/unit/poll-create-page.test.js @@ -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) +}) diff --git a/psf-memo-client/test/unit/poll-option-page.test.js b/psf-memo-client/test/unit/poll-option-page.test.js new file mode 100644 index 0000000..a308bf7 --- /dev/null +++ b/psf-memo-client/test/unit/poll-option-page.test.js @@ -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) +}) diff --git a/psf-memo-client/test/unit/poll-vote-page.test.js b/psf-memo-client/test/unit/poll-vote-page.test.js new file mode 100644 index 0000000..d593cd3 --- /dev/null +++ b/psf-memo-client/test/unit/poll-vote-page.test.js @@ -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) +}) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 124442e..4c7aad9 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -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}".`) + } + } } ] diff --git a/psf-memo-db/specs/poll-read.feature b/psf-memo-db/specs/poll-read.feature index 3f131be..37f9ea2 100644 --- a/psf-memo-db/specs/poll-read.feature +++ b/psf-memo-db/specs/poll-read.feature @@ -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: diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index 3e67c63..92af592 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -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 diff --git a/psf-memo-db/src/adapters/level-db.js b/psf-memo-db/src/adapters/level-db.js index 13212ab..a79b236 100644 --- a/psf-memo-db/src/adapters/level-db.js +++ b/psf-memo-db/src/adapters/level-db.js @@ -24,7 +24,10 @@ const DB_NAMES = [ 'follows', 'rooms', 'processErrors', - 'ptxs' + 'ptxs', + 'polls', + 'pollOptions', + 'pollVotes' ] class LevelDb { diff --git a/psf-memo-db/src/adapters/poll-query.js b/psf-memo-db/src/adapters/poll-query.js new file mode 100644 index 0000000..71871c7 --- /dev/null +++ b/psf-memo-db/src/adapters/poll-query.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/index.js b/psf-memo-db/src/controllers/rest-api/index.js index 455ad27..64b3b2a 100644 --- a/psf-memo-db/src/controllers/rest-api/index.js +++ b/psf-memo-db/src/controllers/rest-api/index.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js index ae14bcf..8d94f3f 100644 --- a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js +++ b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/polls/controller.js b/psf-memo-db/src/controllers/rest-api/polls/controller.js new file mode 100644 index 0000000..b6083d2 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/polls/controller.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/polls/index.js b/psf-memo-db/src/controllers/rest-api/polls/index.js new file mode 100644 index 0000000..3524e53 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/polls/index.js @@ -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 diff --git a/psf-memo-db/src/use-cases/get-poll-options.js b/psf-memo-db/src/use-cases/get-poll-options.js new file mode 100644 index 0000000..c71f1ef --- /dev/null +++ b/psf-memo-db/src/use-cases/get-poll-options.js @@ -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 diff --git a/psf-memo-db/src/use-cases/get-poll-votes.js b/psf-memo-db/src/use-cases/get-poll-votes.js new file mode 100644 index 0000000..a8e47a0 --- /dev/null +++ b/psf-memo-db/src/use-cases/get-poll-votes.js @@ -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 diff --git a/psf-memo-db/src/use-cases/get-poll.js b/psf-memo-db/src/use-cases/get-poll.js new file mode 100644 index 0000000..9fad638 --- /dev/null +++ b/psf-memo-db/src/use-cases/get-poll.js @@ -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 diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index eecc365..7a8f430 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -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 diff --git a/psf-memo-db/src/use-cases/lib/poll-read-use-case.js b/psf-memo-db/src/use-cases/lib/poll-read-use-case.js new file mode 100644 index 0000000..8043a53 --- /dev/null +++ b/psf-memo-db/src/use-cases/lib/poll-read-use-case.js @@ -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 diff --git a/psf-memo-db/test/property/poll-query.property.test.js b/psf-memo-db/test/property/poll-query.property.test.js new file mode 100644 index 0000000..c0a4145 --- /dev/null +++ b/psf-memo-db/test/property/poll-query.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-db/test/unit/adapters/poll-query.unit.js b/psf-memo-db/test/unit/adapters/poll-query.unit.js new file mode 100644 index 0000000..cce6837 --- /dev/null +++ b/psf-memo-db/test/unit/adapters/poll-query.unit.js @@ -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) + }) +}) diff --git a/psf-memo-db/test/unit/controllers/polls.controller.unit.js b/psf-memo-db/test/unit/controllers/polls.controller.unit.js new file mode 100644 index 0000000..d08d631 --- /dev/null +++ b/psf-memo-db/test/unit/controllers/polls.controller.unit.js @@ -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') + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/get-poll-options.unit.js b/psf-memo-db/test/unit/use-cases/get-poll-options.unit.js new file mode 100644 index 0000000..546633b --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/get-poll-options.unit.js @@ -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') + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/get-poll-votes.unit.js b/psf-memo-db/test/unit/use-cases/get-poll-votes.unit.js new file mode 100644 index 0000000..90202ae --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/get-poll-votes.unit.js @@ -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') + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/get-poll.unit.js b/psf-memo-db/test/unit/use-cases/get-poll.unit.js new file mode 100644 index 0000000..11ab204 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/get-poll.unit.js @@ -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?') + }) +}) diff --git a/psf-memo-indexer/acceptance/lib/handlers.js b/psf-memo-indexer/acceptance/lib/handlers.js index 2784c35..609be19 100644 --- a/psf-memo-indexer/acceptance/lib/handlers.js +++ b/psf-memo-indexer/acceptance/lib/handlers.js @@ -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$/, diff --git a/psf-memo-indexer/specs/poll-indexer.feature b/psf-memo-indexer/specs/poll-indexer.feature index 81f7a07..51b11ea 100644 --- a/psf-memo-indexer/specs/poll-indexer.feature +++ b/psf-memo-indexer/specs/poll-indexer.feature @@ -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: diff --git a/psf-memo-indexer/src/adapters/adapters-index.js b/psf-memo-indexer/src/adapters/adapters-index.js index 687a4b7..ec8829e 100644 --- a/psf-memo-indexer/src/adapters/adapters-index.js +++ b/psf-memo-indexer/src/adapters/adapters-index.js @@ -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 diff --git a/psf-memo-indexer/src/lib/memo-codes.js b/psf-memo-indexer/src/lib/memo-codes.js index e8df196..4965b2d 100644 --- a/psf-memo-indexer/src/lib/memo-codes.js +++ b/psf-memo-indexer/src/lib/memo-codes.js @@ -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 diff --git a/psf-memo-indexer/src/use-cases/action-types/index.js b/psf-memo-indexer/src/use-cases/action-types/index.js index 2822e81..3e099ef 100644 --- a/psf-memo-indexer/src/use-cases/action-types/index.js +++ b/psf-memo-indexer/src/use-cases/action-types/index.js @@ -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 diff --git a/psf-memo-indexer/src/use-cases/action-types/poll-child.js b/psf-memo-indexer/src/use-cases/action-types/poll-child.js new file mode 100644 index 0000000..ef5110c --- /dev/null +++ b/psf-memo-indexer/src/use-cases/action-types/poll-child.js @@ -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 diff --git a/psf-memo-indexer/src/use-cases/action-types/poll-create.js b/psf-memo-indexer/src/use-cases/action-types/poll-create.js new file mode 100644 index 0000000..04a5da6 --- /dev/null +++ b/psf-memo-indexer/src/use-cases/action-types/poll-create.js @@ -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 diff --git a/psf-memo-indexer/src/use-cases/action-types/poll-option.js b/psf-memo-indexer/src/use-cases/action-types/poll-option.js new file mode 100644 index 0000000..dd387ef --- /dev/null +++ b/psf-memo-indexer/src/use-cases/action-types/poll-option.js @@ -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 diff --git a/psf-memo-indexer/src/use-cases/action-types/poll-vote.js b/psf-memo-indexer/src/use-cases/action-types/poll-vote.js new file mode 100644 index 0000000..8ed7961 --- /dev/null +++ b/psf-memo-indexer/src/use-cases/action-types/poll-vote.js @@ -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 diff --git a/psf-memo-indexer/test/property/poll-action.property.test.js b/psf-memo-indexer/test/property/poll-action.property.test.js new file mode 100644 index 0000000..6b3e709 --- /dev/null +++ b/psf-memo-indexer/test/property/poll-action.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-indexer/test/unit/lib/memo-codes.unit.js b/psf-memo-indexer/test/unit/lib/memo-codes.unit.js new file mode 100644 index 0000000..edeb8c9 --- /dev/null +++ b/psf-memo-indexer/test/unit/lib/memo-codes.unit.js @@ -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) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/poll-create.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/poll-create.unit.js new file mode 100644 index 0000000..c5183f6 --- /dev/null +++ b/psf-memo-indexer/test/unit/use-cases/action-types/poll-create.unit.js @@ -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) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/poll-option.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/poll-option.unit.js new file mode 100644 index 0000000..4b61f39 --- /dev/null +++ b/psf-memo-indexer/test/unit/use-cases/action-types/poll-option.unit.js @@ -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) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/poll-vote.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/poll-vote.unit.js new file mode 100644 index 0000000..95a98ca --- /dev/null +++ b/psf-memo-indexer/test/unit/use-cases/action-types/poll-vote.unit.js @@ -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) + }) +})