mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Implement Post a Memo behavior with acceptance pipeline
Implements the post-memo behavior slice per specs/post-memo.feature: - src/services/memo-post.js: compose, validate (empty/length), and broadcast a Memo post OP_RETURN (0x6d02) via the minimal-slp-wallet adapter surface. - Stands up the Babashka APS acceptance pipeline (entrypoint generator, runtime, regex step handlers, convenience runner). - Adds focused unit tests for the memo behavior. By coder.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
|
||||
.gitsigners
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Normal acceptance runner for psf-memo-client.
|
||||
|
||||
Orchestrates the acceptance pipeline:
|
||||
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
|
||||
generator -> generated test entry points -> node test runner
|
||||
|
||||
It procures the latest Babashka APS tools from the Acceptance-Pipeline-
|
||||
Specification repository on first use, then parses, generates, and runs every
|
||||
Gherkin feature under specs/.
|
||||
|
||||
Exit code 0 when all acceptance tests pass; non-zero otherwise.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const specsDir = path.join(root, 'specs')
|
||||
const buildDir = path.join(root, 'build', 'acceptance')
|
||||
const irDir = path.join(buildDir, 'ir')
|
||||
const genDir = path.join(buildDir, 'generated')
|
||||
const apsDir = path.join(root, 'tmp', 'aps-spec')
|
||||
|
||||
function sh (cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...opts
|
||||
}).toString()
|
||||
}
|
||||
|
||||
// Procure the latest APS tools if not already present in the worktree.
|
||||
function ensureAps () {
|
||||
if (fs.existsSync(apsDir)) return
|
||||
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
|
||||
sh('git', ['clone', '--depth', '1',
|
||||
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
|
||||
}
|
||||
|
||||
function main () {
|
||||
ensureAps()
|
||||
|
||||
const features = fs
|
||||
.readdirSync(specsDir)
|
||||
.filter((f) => f.endsWith('.feature'))
|
||||
.sort()
|
||||
|
||||
if (features.length === 0) {
|
||||
console.log('No feature files found under specs/.')
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(irDir, { recursive: true })
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
for (const featureFile of features) {
|
||||
const base = featureFile.replace(/\.feature$/i, '')
|
||||
const featurePath = path.join(specsDir, featureFile)
|
||||
const irPath = path.join(irDir, `${base}.json`)
|
||||
|
||||
// 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser.
|
||||
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
|
||||
|
||||
// 2) Generate executable acceptance entry points from the IR.
|
||||
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir])
|
||||
}
|
||||
|
||||
// 3) Run every generated acceptance test.
|
||||
const tests = fs
|
||||
.readdirSync(genDir)
|
||||
.filter((f) => f.endsWith('.acceptance.test.js'))
|
||||
.sort()
|
||||
|
||||
let failures = 0
|
||||
for (const testFile of tests) {
|
||||
try {
|
||||
const out = sh('node', [path.join(genDir, testFile)])
|
||||
process.stdout.write(out)
|
||||
console.log(`ACCEPTANCE PASS: ${testFile}`)
|
||||
} catch (err) {
|
||||
failures++
|
||||
process.stdout.write(err.stdout || '')
|
||||
process.stderr.write(err.stderr || '')
|
||||
console.error(`ACCEPTANCE FAIL: ${testFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Project-specific acceptance entrypoint generator.
|
||||
|
||||
Reads parser JSON IR and writes executable generated test entry points plus
|
||||
per-feature metadata. Generated tests delegate all step behavior to the
|
||||
acceptance runtime and project step handlers.
|
||||
|
||||
Usage:
|
||||
node acceptance/lib/generate.js <json-ir> <generated-test-output-dir>
|
||||
|
||||
Exit codes:
|
||||
0 generation succeeded
|
||||
1 generation error
|
||||
2 wrong command usage
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
// Convert a feature path to a strict lowercase-and-hyphen metadata filename.
|
||||
function metadataName (featureName) {
|
||||
const slug = featureName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return `${slug || 'feature'}.json`
|
||||
}
|
||||
|
||||
// Compute a stable relative require path from a generated file's directory to
|
||||
// a target module, with a './' or '../' prefix for require().
|
||||
function relativeRequire (fromDir, targetFile) {
|
||||
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
|
||||
if (!rel.startsWith('.')) rel = `./${rel}`
|
||||
return rel
|
||||
}
|
||||
|
||||
function main () {
|
||||
const irArg = process.argv[2]
|
||||
const outArg = process.argv[3]
|
||||
|
||||
if (!irArg || !outArg) {
|
||||
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let ir
|
||||
try {
|
||||
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
|
||||
} catch (err) {
|
||||
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const genDir = outArg
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
|
||||
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
|
||||
const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js'))
|
||||
|
||||
const body = `'use strict'
|
||||
const { runFeature } = require('${relRuntime}')
|
||||
const ir = ${JSON.stringify(ir, null, 2)}
|
||||
async function main () {
|
||||
const report = await runFeature(ir)
|
||||
for (const r of report.results) {
|
||||
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
|
||||
if (r.detail) console.log(' ' + r.detail)
|
||||
}
|
||||
if (report.failures > 0) {
|
||||
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(testFile, body)
|
||||
} catch (err) {
|
||||
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Per-feature metadata with an implementation hash over generated files only.
|
||||
const metaDir = path.join(genDir, 'metadata')
|
||||
fs.mkdirSync(metaDir, { recursive: true })
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(testFile))
|
||||
.digest('hex')
|
||||
|
||||
const metadata = {
|
||||
schema_version: 1,
|
||||
feature_path: `${featureKey}.feature`,
|
||||
ir_path: path.resolve(irArg),
|
||||
implementation_hash: `sha256:${hash}`,
|
||||
hash_scope: 'generated_files',
|
||||
generated_files: [testFile]
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(metaDir, metadataName(featureKey)),
|
||||
JSON.stringify(metadata, null, 2)
|
||||
)
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
Project step handlers for the psf-memo-client acceptance pipeline.
|
||||
|
||||
These handlers connect Gherkin step text to the real project behavior in
|
||||
src/services/memo-post.js, driving it through small injected adapters (a fake
|
||||
wallet and a fake feed) so the acceptance run is deterministic and offline.
|
||||
|
||||
Regex matching with placeholder-name capture is the default style: a single
|
||||
handler pattern captures the placeholder name (e.g. <message>) and fetches
|
||||
the example value from the scenario example store.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
|
||||
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||
function makeWallet (address) {
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress: address },
|
||||
utxos: [],
|
||||
broadcasts: [],
|
||||
getUtxos: async function () {
|
||||
return this.utxos
|
||||
},
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
}
|
||||
return wallet
|
||||
}
|
||||
|
||||
// A fake feed reflecting posts added to the recent posts feed.
|
||||
function makeFeed () {
|
||||
const posts = []
|
||||
return {
|
||||
posts,
|
||||
addPost: (post) => posts.push(post)
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh world/state object for a single scenario execution.
|
||||
function createWorld () {
|
||||
const wallet = makeWallet('')
|
||||
const feed = makeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
return {
|
||||
wallet,
|
||||
feed,
|
||||
memoPost,
|
||||
message: null,
|
||||
submitted: null
|
||||
}
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world).
|
||||
const handlers = [
|
||||
{
|
||||
name: 'wallet authenticated for address',
|
||||
pattern: /^a wallet authenticated for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
world.wallet.walletInfo.cashAddress = m[1].trim()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet has spendable output',
|
||||
pattern: /^the wallet has a spendable output to pay the transaction fee$/,
|
||||
run (m, example, world) {
|
||||
world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'viewing recent posts feed',
|
||||
pattern: /^I am viewing the recent posts feed$/,
|
||||
run () {}
|
||||
},
|
||||
{
|
||||
name: 'compose memo text',
|
||||
pattern: /^I compose a memo 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.message = example[param]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit memo',
|
||||
pattern: /^I submit the memo$/,
|
||||
async run (m, example, world) {
|
||||
world.submitted = { error: null, txid: null }
|
||||
try {
|
||||
world.submitted.txid = await world.memoPost.post(world.message)
|
||||
} catch (err) {
|
||||
world.submitted.error = err
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo post prefix',
|
||||
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo post prefix$/,
|
||||
run (m, example, world) {
|
||||
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_POST_PREFIX) {
|
||||
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg !== world.message) {
|
||||
throw new Error('Broadcast message text did not match the composed memo.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed shows new post from my address',
|
||||
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expectedText = example[param]
|
||||
const myAddress = world.wallet.walletInfo.cashAddress
|
||||
const found = world.feed.posts.find(
|
||||
(p) => p.text === expectedText && p.address === myAddress
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Feed does not show the new post with text "${expectedText}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'app shows validation/length error',
|
||||
pattern: /^the app shows a (validation|length) error$/,
|
||||
run (m, example, world) {
|
||||
const kind = m[1]
|
||||
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
|
||||
if (!world.submitted || !world.submitted.error) {
|
||||
throw new Error(`Expected a ${kind} error but the submit succeeded.`)
|
||||
}
|
||||
if (world.submitted.error.code !== expectedCode) {
|
||||
throw new Error(`Expected ${expectedCode}, got ${world.submitted.error.code}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet does not broadcast any transaction',
|
||||
pattern: /^the wallet does not broadcast any transaction$/,
|
||||
run (m, example, world) {
|
||||
if (world.wallet.broadcasts.length !== 0) {
|
||||
throw new Error('A transaction was broadcast when none was expected.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Route a single step to its handler. Throws on unsupported step text.
|
||||
async function handleStep (step, example, world) {
|
||||
for (const handler of handlers) {
|
||||
const match = handler.pattern.exec(step.text)
|
||||
if (match) {
|
||||
await handler.run(match, example, world)
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
|
||||
}
|
||||
|
||||
module.exports = { createWorld, handleStep }
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Acceptance runtime for psf-memo-client.
|
||||
|
||||
Expands each scenario (and each example row) from parser JSON IR into
|
||||
scenario executions, prepends background steps, and dispatches every step to
|
||||
the project step handlers. Unsupported steps, invalid example values, or
|
||||
failed assertions fail that execution.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { createWorld, handleStep } = require('./handlers')
|
||||
|
||||
// Expand the IR scenarios into concrete executions.
|
||||
// For scenario outlines with examples, one execution per example row; for
|
||||
// scenarios without examples, one execution with an empty example store.
|
||||
function expandScenarios (ir) {
|
||||
const executions = []
|
||||
const background = ir.background || []
|
||||
|
||||
for (const scenario of ir.scenarios) {
|
||||
const examples = (scenario.examples && scenario.examples.length > 0)
|
||||
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
|
||||
: [{ example: {}, suffix: 'example_1' }]
|
||||
|
||||
for (const { example, suffix } of examples) {
|
||||
executions.push({
|
||||
name: `${scenario.name}/${suffix}`,
|
||||
steps: [...background, ...scenario.steps],
|
||||
example
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return executions
|
||||
}
|
||||
|
||||
// Run a full feature and return a report of scenario outcomes.
|
||||
async function runFeature (ir) {
|
||||
const executions = expandScenarios(ir)
|
||||
const results = []
|
||||
let failures = 0
|
||||
|
||||
for (const ex of executions) {
|
||||
// A fresh world/state object for each scenario execution.
|
||||
const world = createWorld()
|
||||
let failure = null
|
||||
|
||||
for (const step of ex.steps) {
|
||||
try {
|
||||
await handleStep(step, ex.example, world)
|
||||
} catch (err) {
|
||||
failure = err.message
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
failures++
|
||||
results.push({ name: ex.name, status: 'failed', detail: failure })
|
||||
} else {
|
||||
results.push({ name: ex.name, status: 'passed' })
|
||||
}
|
||||
}
|
||||
|
||||
return { feature: ir.name, results, failures, total: results.length }
|
||||
}
|
||||
|
||||
module.exports = { expandScenarios, runFeature }
|
||||
+2
-1
@@ -25,7 +25,8 @@
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "echo 'no tests'",
|
||||
"test": "node --test \"test/unit/*.test.js\"",
|
||||
"test:acceptance": "node acceptance/acceptance.js",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"pub": "node deploy/publish-main.js",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
Memo post behavior: compose, validate, and broadcast a Memo "post" message.
|
||||
|
||||
A Memo post is an OP_RETURN Bitcoin Cash transaction carrying the Memo post
|
||||
protocol prefix (0x6d02) followed by the message text. Broadcasting is done
|
||||
through a wallet that exposes the minimal-slp-wallet adapter surface
|
||||
(walletInfo, getUtxos(), sendOpReturn()).
|
||||
|
||||
The wallet and feed are injected so this module stays testable and free of
|
||||
network/UI concerns; environmentally unsuitable I/O lives behind those small
|
||||
adapter boundaries.
|
||||
|
||||
Constants
|
||||
MEMO_POST_PREFIX : hex prefix for the Memo "post" action (0x6d02)
|
||||
MAX_MEMO_CHARS : maximum allowed memo length (spec boundary: 217 valid,
|
||||
218 rejected)
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const MEMO_POST_PREFIX = '6d02'
|
||||
const MAX_MEMO_CHARS = 217
|
||||
|
||||
class MemoPost {
|
||||
constructor (deps = {}) {
|
||||
this.wallet = deps.wallet
|
||||
this.feed = deps.feed
|
||||
}
|
||||
|
||||
// Validate a candidate memo message.
|
||||
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
||||
validate (message) {
|
||||
if (typeof message !== 'string' || message.trim().length === 0) {
|
||||
return { ok: false, type: 'validation' }
|
||||
}
|
||||
|
||||
if (message.length > MAX_MEMO_CHARS) {
|
||||
return { ok: false, type: 'length' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Compose and broadcast a Memo post for the given message.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async post (message) {
|
||||
const check = this.validate(message)
|
||||
if (!check.ok) {
|
||||
const err = new Error(
|
||||
check.type === 'length'
|
||||
? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.`
|
||||
: 'Memo must not be empty.'
|
||||
)
|
||||
err.code = check.type === 'length' ? 'memo_length' : 'memo_validation'
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!this.wallet) {
|
||||
throw new Error('Memo post requires a wallet.')
|
||||
}
|
||||
|
||||
// Spendable outputs used to pay the transaction fee.
|
||||
const bchUtxos = await this.wallet.getUtxos()
|
||||
|
||||
// Broadcast the OP_RETURN transaction with the Memo post prefix.
|
||||
const txid = await this.wallet.sendOpReturn(
|
||||
this.wallet.walletInfo,
|
||||
bchUtxos,
|
||||
message,
|
||||
MEMO_POST_PREFIX
|
||||
)
|
||||
|
||||
// Reflect the new post in the feed once broadcast succeeds.
|
||||
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||
this.feed.addPost({
|
||||
txid,
|
||||
address: this.wallet.walletInfo.cashAddress,
|
||||
text: message
|
||||
})
|
||||
}
|
||||
|
||||
return txid
|
||||
}
|
||||
}
|
||||
|
||||
MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX
|
||||
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
||||
|
||||
module.exports = MemoPost
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Unit tests for the Memo post behavior slice (src/services/memo-post.js).
|
||||
|
||||
These tests express the observable behavior described by
|
||||
specs/post-memo.feature:
|
||||
- a valid memo broadcasts an OP_RETURN transaction carrying the Memo post
|
||||
prefix (0x6d02) and the message text, and the feed reflects the new post.
|
||||
- an empty memo is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long memo is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
|
||||
// A fake wallet that records every broadcast attempt. It satisfies the small
|
||||
// adapter surface the MemoPost module needs: walletInfo, getUtxos(),
|
||||
// sendOpReturn().
|
||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => {
|
||||
broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
return 'fake-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
// A fake feed that records posts added to the recent posts feed.
|
||||
function fakeFeed () {
|
||||
const posts = []
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
|
||||
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
|
||||
})
|
||||
|
||||
test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and message', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
const txid = await memoPost.post('hello memo')
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d02')
|
||||
assert.equal(b.msg, 'hello memo')
|
||||
// The broadcast uses the wallet's spendable UTXOs.
|
||||
assert.equal(b.bchUtxos.length, 1)
|
||||
// The wallet info passed to sendOpReturn is the authenticated wallet.
|
||||
assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress)
|
||||
|
||||
// The feed reflects the new post from this address with this text.
|
||||
assert.equal(feed.posts.length, 1)
|
||||
assert.equal(feed.posts[0].text, 'hello memo')
|
||||
assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress)
|
||||
})
|
||||
|
||||
test('posting a memo at the maximum length (217) is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoPost = new MemoPost({ wallet })
|
||||
|
||||
const msg = 'x'.repeat(217)
|
||||
const txid = await memoPost.post(msg)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts[0].msg, msg)
|
||||
})
|
||||
|
||||
test('posting an empty memo throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post(''),
|
||||
(err) => err.code === 'memo_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
})
|
||||
|
||||
test('posting a whitespace-only memo throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoPost = new MemoPost({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post(' '),
|
||||
(err) => err.code === 'memo_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('posting a non-string memo throws a validation error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoPost = new MemoPost({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post(42),
|
||||
(err) => err.code === 'memo_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
|
||||
await assert.rejects(
|
||||
memoPost.post('y'.repeat(218)),
|
||||
(err) => err.code === 'memo_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(feed.posts.length, 0)
|
||||
})
|
||||
|
||||
test('posting without a wallet reports a missing-wallet error', async () => {
|
||||
const memoPost = new MemoPost({})
|
||||
await assert.rejects(
|
||||
memoPost.post('hello memo'),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user