Refactor poll actions: reduce CRAP/DRY, add property coverage

Extract shared txid-action and poll-page bases in the client, a shared
poll-read use-case base and a poll-txid iteration helper in the DB, and a
shared poll-child record helper in the indexer. Reduce normalizePollCreateDatas
complexity (CRAP 7.1 -> 5.0). Add property tests for poll services, handlers,
and query filtering. Preserve behavior; all unit, acceptance, and property
suites pass across client, DB, and indexer.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-28 15:23:04 -07:00
parent cf05de6630
commit 739b13bfb2
19 changed files with 815 additions and 309 deletions
+11 -11
View File
@@ -52,23 +52,23 @@ class PollQuery {
}
async getPollOptions (txid) {
const options = []
for await (const [key, value] of this.pollOptionsDb.iterator()) {
if (value?.pollTxid === txid) {
options.push({ ...value, txid: key })
}
}
return options
return this._collectByPollTxid(this.pollOptionsDb, txid)
}
async getPollVotes (txid) {
const votes = []
for await (const [key, value] of this.pollVotesDb.iterator()) {
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) {
votes.push({ ...value, txid: key })
items.push({ ...value, txid: key })
}
}
return votes
return items
}
}
+4 -19
View File
@@ -2,26 +2,11 @@
Use case: read the options for a single Memo poll.
*/
class GetPollOptions {
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPollOptions extends PollReadUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating GetPollOptions use case.')
}
if (!this.adapters.pollQuery) {
throw new Error('pollQuery adapter required for GetPollOptions use case.')
}
this.execute = this.execute.bind(this)
}
parseTxid (txid) {
if (!txid || typeof txid !== 'string') {
const err = new Error('txid is required')
err.status = 400
throw err
}
return txid
super(localConfig, { useCaseName: 'GetPollOptions' })
}
async execute (inObj = {}) {
+4 -19
View File
@@ -2,26 +2,11 @@
Use case: read the votes for a single Memo poll.
*/
class GetPollVotes {
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPollVotes extends PollReadUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating GetPollVotes use case.')
}
if (!this.adapters.pollQuery) {
throw new Error('pollQuery adapter required for GetPollVotes use case.')
}
this.execute = this.execute.bind(this)
}
parseTxid (txid) {
if (!txid || typeof txid !== 'string') {
const err = new Error('txid is required')
err.status = 400
throw err
}
return txid
super(localConfig, { useCaseName: 'GetPollVotes' })
}
async execute (inObj = {}) {
+4 -19
View File
@@ -2,26 +2,11 @@
Use case: read a single Memo poll, including its options and votes.
*/
class GetPoll {
import { PollReadUseCase } from './lib/poll-read-use-case.js'
class GetPoll extends PollReadUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating GetPoll use case.')
}
if (!this.adapters.pollQuery) {
throw new Error('pollQuery adapter required for GetPoll use case.')
}
this.execute = this.execute.bind(this)
}
parseTxid (txid) {
if (!txid || typeof txid !== 'string') {
const err = new Error('txid is required')
err.status = 400
throw err
}
return txid
super(localConfig, { useCaseName: 'GetPoll' })
}
async execute (inObj = {}) {
@@ -0,0 +1,32 @@
/*
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 } = {}) {
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.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
}
}
@@ -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' }
)
})