mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Refactor memo actions and page controllers to share base classes
Extract MemoAction and PageController base classes to remove structural duplication between the memo post/set-name actions and the new post/set-name page controllers. Add property tests for the set-name byte counter and validation invariants. Refresh mutation manifests for the refactored files. By refactorer.
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
Shared base for Memo protocol actions that broadcast an OP_RETURN
|
||||||
|
transaction through a wallet and reflect the result on an injected store.
|
||||||
|
|
||||||
|
Subclasses supply the protocol-specific pieces:
|
||||||
|
prefix - hex protocol prefix (e.g. 0x6d02 for a post)
|
||||||
|
walletRequiredMsg - error message when no wallet is present
|
||||||
|
lengthMessage - error text for an over-length value
|
||||||
|
emptyMessage - error text for an empty value
|
||||||
|
lengthCode - error code for an over-length value
|
||||||
|
validationCode - error code for an empty value
|
||||||
|
isTooLong(value) - true when the value exceeds the action's limit
|
||||||
|
reflect(txid, value) - record the broadcast result on the injected store
|
||||||
|
*/
|
||||||
|
|
||||||
|
class MemoAction {
|
||||||
|
constructor (deps = {}) {
|
||||||
|
this.wallet = deps.wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a candidate value.
|
||||||
|
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
||||||
|
validate (value) {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
return { ok: false, type: 'validation' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTooLong(value)) {
|
||||||
|
return { ok: false, type: 'length' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose and broadcast the action for the given value.
|
||||||
|
// Resolves with the transaction id, or rejects with a typed error.
|
||||||
|
async broadcast (value) {
|
||||||
|
const check = this.validate(value)
|
||||||
|
this._throwIfInvalid(check)
|
||||||
|
|
||||||
|
if (!this.wallet) {
|
||||||
|
throw new Error(this.walletRequiredMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||||
|
await this.wallet.getUtxos()
|
||||||
|
|
||||||
|
// The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and its
|
||||||
|
// own spendable UTXOs internally, so only the value and prefix are passed.
|
||||||
|
const txid = await this.wallet.sendOpReturn(value, this.prefix)
|
||||||
|
|
||||||
|
// Reflect the result on the injected store once broadcast succeeds.
|
||||||
|
this.reflect(txid, value)
|
||||||
|
|
||||||
|
return txid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw the appropriate typed error when a value fails validation.
|
||||||
|
_throwIfInvalid (check) {
|
||||||
|
if (check.ok) return
|
||||||
|
|
||||||
|
const err = new Error(
|
||||||
|
check.type === 'length' ? this.lengthMessage : this.emptyMessage
|
||||||
|
)
|
||||||
|
err.code = check.type === 'length' ? this.lengthCode : this.validationCode
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MemoAction
|
||||||
+16
-48
@@ -16,68 +16,36 @@
|
|||||||
218 rejected)
|
218 rejected)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const MemoAction = require('./memo-action')
|
||||||
|
|
||||||
const MEMO_POST_PREFIX = '6d02'
|
const MEMO_POST_PREFIX = '6d02'
|
||||||
const MAX_MEMO_CHARS = 217
|
const MAX_MEMO_CHARS = 217
|
||||||
|
|
||||||
class MemoPost {
|
class MemoPost extends MemoAction {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
this.wallet = deps.wallet
|
super(deps)
|
||||||
this.feed = deps.feed
|
this.feed = deps.feed
|
||||||
|
this.prefix = MEMO_POST_PREFIX
|
||||||
|
this.walletRequiredMsg = 'Memo post requires a wallet.'
|
||||||
|
this.lengthMessage = `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.`
|
||||||
|
this.emptyMessage = 'Memo must not be empty.'
|
||||||
|
this.lengthCode = 'memo_length'
|
||||||
|
this.validationCode = 'memo_validation'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate a candidate memo message.
|
// A memo is over-length when it exceeds the character limit.
|
||||||
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
isTooLong (message) {
|
||||||
validate (message) {
|
return message.length > MAX_MEMO_CHARS
|
||||||
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.
|
// Compose and broadcast a Memo post for the given message.
|
||||||
// Resolves with the transaction id, or rejects with a typed error.
|
// Resolves with the transaction id, or rejects with a typed error.
|
||||||
async post (message) {
|
async post (message) {
|
||||||
const check = this.validate(message)
|
return this.broadcast(message)
|
||||||
this._throwIfInvalid(check)
|
|
||||||
|
|
||||||
if (!this.wallet) {
|
|
||||||
throw new Error('Memo post requires a wallet.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
|
||||||
await this.wallet.getUtxos()
|
|
||||||
|
|
||||||
// The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and
|
|
||||||
// its own spendable UTXOs internally, so only the message and Memo post
|
|
||||||
// prefix are passed here.
|
|
||||||
const txid = await this.wallet.sendOpReturn(message, MEMO_POST_PREFIX)
|
|
||||||
|
|
||||||
// Reflect the new post in the feed once broadcast succeeds.
|
|
||||||
this._reflectPost(txid, message)
|
|
||||||
|
|
||||||
return txid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw the appropriate typed error when a memo fails validation.
|
|
||||||
_throwIfInvalid (check) {
|
|
||||||
if (check.ok) return
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the new post on the injected feed when one is present.
|
// Record the new post on the injected feed when one is present.
|
||||||
_reflectPost (txid, message) {
|
reflect (txid, message) {
|
||||||
if (this.feed && typeof this.feed.addPost === 'function') {
|
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||||
this.feed.addPost({
|
this.feed.addPost({
|
||||||
txid,
|
txid,
|
||||||
@@ -94,5 +62,5 @@ MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
|||||||
module.exports = MemoPost
|
module.exports = MemoPost
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// mutate4javascript-manifest-begin
|
||||||
// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]}
|
// {"version":1,"tested_at":"2026-08-26T02:54:15.221Z","module_hash":"7060e3997af5f6340180385c1e96e055614280683bffdeb737e49c37ad6ce946","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":25,"end_line":34,"hash":"f1843343deb758b304364862c2c58af3a8a5b03e3d8a70f3056c8679e869a9ed"},{"id":"func/MemoPost.isTooLong","name":"MemoPost.isTooLong","line":37,"end_line":39,"hash":"833f9f66eae849df0248d0c696c95c767a121b394b1c728bc3ec675668dff5de"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":43,"end_line":45,"hash":"26d8e84b520fae1928f7f72215e6ed95f7219c14266c99b710e2af5373cb6faf"},{"id":"func/MemoPost.reflect","name":"MemoPost.reflect","line":48,"end_line":56,"hash":"87e2168a71309a572c60b63f382dd6f681cb28ed543090dbde399e29556cfcdf"}]}
|
||||||
// mutate4javascript-manifest-end
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -16,65 +16,36 @@
|
|||||||
MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv)
|
MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const MemoAction = require('./memo-action')
|
||||||
|
|
||||||
const MEMO_SET_NAME_PREFIX = '6d01'
|
const MEMO_SET_NAME_PREFIX = '6d01'
|
||||||
const MAX_NAME_BYTES = 77
|
const MAX_NAME_BYTES = 77
|
||||||
|
|
||||||
class MemoSetName {
|
class MemoSetName extends MemoAction {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
this.wallet = deps.wallet
|
super(deps)
|
||||||
this.profiles = deps.profiles
|
this.profiles = deps.profiles
|
||||||
|
this.prefix = MEMO_SET_NAME_PREFIX
|
||||||
|
this.walletRequiredMsg = 'Memo set name requires a wallet.'
|
||||||
|
this.lengthMessage = `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.`
|
||||||
|
this.emptyMessage = 'Name must not be empty.'
|
||||||
|
this.lengthCode = 'name_length'
|
||||||
|
this.validationCode = 'name_validation'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate a candidate name.
|
// A name is over-length when it exceeds the byte limit.
|
||||||
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
isTooLong (name) {
|
||||||
validate (name) {
|
return Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES
|
||||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
||||||
return { ok: false, type: 'validation' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) {
|
|
||||||
return { ok: false, type: 'length' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose and broadcast a Memo set-name transaction for the given name.
|
// Compose and broadcast a Memo set-name transaction for the given name.
|
||||||
// Resolves with the transaction id, or rejects with a typed error.
|
// Resolves with the transaction id, or rejects with a typed error.
|
||||||
async setName (name) {
|
async setName (name) {
|
||||||
const check = this.validate(name)
|
return this.broadcast(name)
|
||||||
this._throwIfInvalid(check)
|
|
||||||
|
|
||||||
if (!this.wallet) {
|
|
||||||
throw new Error('Memo set name requires a wallet.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
|
||||||
await this.wallet.getUtxos()
|
|
||||||
|
|
||||||
const txid = await this.wallet.sendOpReturn(name, MEMO_SET_NAME_PREFIX)
|
|
||||||
|
|
||||||
// Reflect the new name in the injected profile store once broadcast succeeds.
|
|
||||||
this._reflectName(name)
|
|
||||||
|
|
||||||
return txid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw the appropriate typed error when a name fails validation.
|
|
||||||
_throwIfInvalid (check) {
|
|
||||||
if (check.ok) return
|
|
||||||
|
|
||||||
const err = new Error(
|
|
||||||
check.type === 'length'
|
|
||||||
? `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.`
|
|
||||||
: 'Name must not be empty.'
|
|
||||||
)
|
|
||||||
err.code = check.type === 'length' ? 'name_length' : 'name_validation'
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the new name on the injected profile store when one is present.
|
// Record the new name on the injected profile store when one is present.
|
||||||
_reflectName (name) {
|
reflect (txid, name) {
|
||||||
if (this.profiles && typeof this.profiles.setName === 'function') {
|
if (this.profiles && typeof this.profiles.setName === 'function') {
|
||||||
this.profiles.setName(this.wallet.walletInfo.cashAddress, name)
|
this.profiles.setName(this.wallet.walletInfo.cashAddress, name)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-44
@@ -13,21 +13,20 @@
|
|||||||
adapter boundaries.
|
adapter boundaries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const PageController = require('./page-controller')
|
||||||
const MemoPost = require('./memo-post')
|
const MemoPost = require('./memo-post')
|
||||||
|
|
||||||
const NEW_POST_PATH = '/posts/new'
|
const NEW_POST_PATH = '/posts/new'
|
||||||
const RECENT_FEED_PATH = '/posts/recent'
|
const RECENT_FEED_PATH = '/posts/recent'
|
||||||
|
|
||||||
class NewPostPage {
|
class NewPostPage extends PageController {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
|
super(deps)
|
||||||
this.memoPost = deps.memoPost || null
|
this.memoPost = deps.memoPost || null
|
||||||
this.navigate = deps.navigate || (() => {})
|
|
||||||
this.menuLinks = deps.menuLinks || []
|
this.menuLinks = deps.menuLinks || []
|
||||||
|
|
||||||
this.input = ''
|
|
||||||
this.submitError = null
|
|
||||||
this.broadcastError = null
|
|
||||||
this.posting = false
|
this.posting = false
|
||||||
|
this.successPath = RECENT_FEED_PATH
|
||||||
|
this.validationCodes = ['memo_validation', 'memo_length']
|
||||||
|
|
||||||
// The navigation menu links to the new post page.
|
// The navigation menu links to the new post page.
|
||||||
this.addMenuLink(NEW_POST_PATH)
|
this.addMenuLink(NEW_POST_PATH)
|
||||||
@@ -44,51 +43,22 @@ class NewPostPage {
|
|||||||
return this.menuLinks.includes(path)
|
return this.menuLinks.includes(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the draft memo text and update the counter.
|
|
||||||
setInput (text) {
|
|
||||||
this.input = typeof text === 'string' ? text : ''
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
// Characters remaining before the memo limit is reached.
|
// Characters remaining before the memo limit is reached.
|
||||||
remainingCount () {
|
remainingCount () {
|
||||||
return MemoPost.MAX_MEMO_CHARS - this.input.length
|
return MemoPost.MAX_MEMO_CHARS - this.input.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and post the current draft. On success, navigate to the recent
|
// Set the in-flight posting flag.
|
||||||
// feed. On failure, record the typed error and stay on the page. Resolves
|
_setBusy (value) {
|
||||||
// with a result object.
|
this.posting = value
|
||||||
async submit () {
|
|
||||||
this.posting = true
|
|
||||||
this.submitError = null
|
|
||||||
this.broadcastError = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.memoPost) {
|
|
||||||
throw new Error('New post requires a memo post handler.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const txid = await this.memoPost.post(this.input)
|
|
||||||
this.navigate(RECENT_FEED_PATH)
|
|
||||||
this.posting = false
|
|
||||||
return { ok: true, txid }
|
|
||||||
} catch (err) {
|
|
||||||
return this._handleSubmitFailure(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify a submit failure, record the typed state, and return the failure
|
// Run the memo post action for the current input.
|
||||||
// result. Local validation failures set submitError; broadcast or handler
|
async _perform (input) {
|
||||||
// failures surface the real error message via broadcastError.
|
if (!this.memoPost) {
|
||||||
_handleSubmitFailure (err) {
|
throw new Error('New post requires a memo post handler.')
|
||||||
if (err.code === 'memo_validation' || err.code === 'memo_length') {
|
|
||||||
this.submitError = err.code
|
|
||||||
} else {
|
|
||||||
this.broadcastError = err.message || String(err)
|
|
||||||
this.submitError = 'broadcast'
|
|
||||||
}
|
}
|
||||||
this.posting = false
|
return this.memoPost.post(input)
|
||||||
return { ok: false, error: this.submitError, message: this.broadcastError }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,5 +68,5 @@ NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
|||||||
module.exports = NewPostPage
|
module.exports = NewPostPage
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// mutate4javascript-manifest-begin
|
||||||
// {"version":1,"tested_at":"2026-08-26T00:39:54.163Z","module_hash":"8d50d002e9c6094a1bd2d6e764023c942b1eb0f085b019255dc80f0a72ab1ec6","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":34,"hash":"d61d01986c51dc4ed4185594fa3e35612924846db321a7107aaec191d17c419d"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":37,"end_line":40,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":43,"end_line":45,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":48,"end_line":51,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":54,"end_line":56,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":61,"end_line":78,"hash":"c280ee1244bcb80c3a9ffe4f52befc8a05629ec879ef9d86666ea11f493b3c5b"},{"id":"func/NewPostPage._handleSubmitFailure","name":"NewPostPage._handleSubmitFailure","line":83,"end_line":92,"hash":"b13d8cd6b48b1f42d72e0031cabcaa00bb78b813f42250517d711f0d6fb23126"}]}
|
// {"version":1,"tested_at":"2026-08-26T02:54:16.145Z","module_hash":"45d772cfe40b8a34018092abbe924c01cbde741c5cbaa737a283ba35214a99ff","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":23,"end_line":33,"hash":"686653c181941b27c19cd8c1f9fa7b3fee50db6e19c1e39d0db79e6bfbe52a81"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":47,"end_line":49,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":52,"end_line":54,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":57,"end_line":62,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"}]}
|
||||||
// mutate4javascript-manifest-end
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Shared base for page controllers that compose and submit a single action,
|
||||||
|
surface validation/broadcast errors, and navigate on success.
|
||||||
|
|
||||||
|
Subclasses supply the page-specific pieces:
|
||||||
|
successPath - path to navigate to on success
|
||||||
|
validationCodes - error codes that represent local validation failures
|
||||||
|
_setBusy(value) - set the page's in-flight flag
|
||||||
|
_perform(input) - run the action for the current input, resolving with txid
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PageController {
|
||||||
|
constructor (deps = {}) {
|
||||||
|
this.navigate = deps.navigate || (() => {})
|
||||||
|
this.input = ''
|
||||||
|
this.submitError = null
|
||||||
|
this.broadcastError = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the draft input.
|
||||||
|
setInput (text) {
|
||||||
|
this.input = typeof text === 'string' ? text : ''
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and submit the current input. On success, navigate to the success
|
||||||
|
// path. On failure, record the typed error and stay on the page. Resolves
|
||||||
|
// with a result object.
|
||||||
|
async submit () {
|
||||||
|
this._setBusy(true)
|
||||||
|
this.submitError = null
|
||||||
|
this.broadcastError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const txid = await this._perform(this.input)
|
||||||
|
this.navigate(this.successPath)
|
||||||
|
this._setBusy(false)
|
||||||
|
return { ok: true, txid }
|
||||||
|
} catch (err) {
|
||||||
|
return this._handleSubmitFailure(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify a submit failure, record the typed state, and return the failure
|
||||||
|
// result. Local validation failures set submitError; broadcast or handler
|
||||||
|
// failures surface the real error message via broadcastError.
|
||||||
|
_handleSubmitFailure (err) {
|
||||||
|
if (this.validationCodes.includes(err.code)) {
|
||||||
|
this.submitError = err.code
|
||||||
|
} else {
|
||||||
|
this.broadcastError = err.message || String(err)
|
||||||
|
this.submitError = 'broadcast'
|
||||||
|
}
|
||||||
|
this._setBusy(false)
|
||||||
|
return { ok: false, error: this.submitError, message: this.broadcastError }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PageController
|
||||||
@@ -13,26 +13,19 @@
|
|||||||
adapter boundaries.
|
adapter boundaries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const PageController = require('./page-controller')
|
||||||
const MemoSetName = require('./memo-set-name')
|
const MemoSetName = require('./memo-set-name')
|
||||||
|
|
||||||
const SET_NAME_PATH = '/memo/set-name'
|
const SET_NAME_PATH = '/memo/set-name'
|
||||||
const ACCOUNT_PATH = '/account'
|
const ACCOUNT_PATH = '/account'
|
||||||
|
|
||||||
class SetNamePage {
|
class SetNamePage extends PageController {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
|
super(deps)
|
||||||
this.memoSetName = deps.memoSetName || null
|
this.memoSetName = deps.memoSetName || null
|
||||||
this.navigate = deps.navigate || (() => {})
|
|
||||||
|
|
||||||
this.input = ''
|
|
||||||
this.submitError = null
|
|
||||||
this.broadcastError = null
|
|
||||||
this.settingName = false
|
this.settingName = false
|
||||||
}
|
this.successPath = ACCOUNT_PATH
|
||||||
|
this.validationCodes = ['name_validation', 'name_length']
|
||||||
// Set the draft name and update the counter.
|
|
||||||
setInput (text) {
|
|
||||||
this.input = typeof text === 'string' ? text : ''
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes remaining before the name limit is reached.
|
// Bytes remaining before the name limit is reached.
|
||||||
@@ -40,40 +33,17 @@ class SetNamePage {
|
|||||||
return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8')
|
return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and broadcast the current draft name. On success, navigate to the
|
// Set the in-flight setting-name flag.
|
||||||
// account page. On failure, record the typed error and stay on the page.
|
_setBusy (value) {
|
||||||
// Resolves with a result object.
|
this.settingName = value
|
||||||
async submit () {
|
|
||||||
this.settingName = true
|
|
||||||
this.submitError = null
|
|
||||||
this.broadcastError = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.memoSetName) {
|
|
||||||
throw new Error('Set name requires a memo set-name handler.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const txid = await this.memoSetName.setName(this.input)
|
|
||||||
this.navigate(ACCOUNT_PATH)
|
|
||||||
this.settingName = false
|
|
||||||
return { ok: true, txid }
|
|
||||||
} catch (err) {
|
|
||||||
return this._handleSubmitFailure(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify a submit failure, record the typed state, and return the failure
|
// Run the memo set-name action for the current input.
|
||||||
// result. Local validation failures set submitError; broadcast or handler
|
async _perform (input) {
|
||||||
// failures surface the real error message via broadcastError.
|
if (!this.memoSetName) {
|
||||||
_handleSubmitFailure (err) {
|
throw new Error('Set name requires a memo set-name handler.')
|
||||||
if (err.code === 'name_validation' || err.code === 'name_length') {
|
|
||||||
this.submitError = err.code
|
|
||||||
} else {
|
|
||||||
this.broadcastError = err.message || String(err)
|
|
||||||
this.submitError = 'broadcast'
|
|
||||||
}
|
}
|
||||||
this.settingName = false
|
return this.memoSetName.setName(input)
|
||||||
return { ok: false, error: this.submitError, message: this.broadcastError }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
Property tests for the Set Name behavior slice.
|
||||||
|
|
||||||
|
These assert useful invariants that unit tests cover only at a few fixed
|
||||||
|
points:
|
||||||
|
- Set-name validation classification is stable across a broad input range:
|
||||||
|
any non-blank string up to the byte limit is accepted, any longer string
|
||||||
|
is rejected with a length error, and blank/non-string input is a
|
||||||
|
validation error.
|
||||||
|
- The Set Name byte counter conserves its relationship to input byte
|
||||||
|
length: Buffer.byteLength(input) + remainingCount() === MAX_NAME_BYTES
|
||||||
|
for any string.
|
||||||
|
- setInput round-trips the exact draft text.
|
||||||
|
- A broadcast failure surfaces the error and never navigates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
|
||||||
|
const { seededRandom, forAll, makeStringGen } = require('./harness')
|
||||||
|
|
||||||
|
const MemoSetName = require('../../src/services/memo-set-name')
|
||||||
|
const SetNamePage = require('../../src/services/set-name-page')
|
||||||
|
|
||||||
|
const MAX = MemoSetName.MAX_NAME_BYTES // 77
|
||||||
|
|
||||||
|
const rng = seededRandom(20260827)
|
||||||
|
const stringOf = makeStringGen(rng)
|
||||||
|
|
||||||
|
function buildPage () {
|
||||||
|
return new SetNamePage({
|
||||||
|
memoSetName: new MemoSetName({}),
|
||||||
|
navigate: () => {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake wallet recording broadcast attempts; fails when failWith is set.
|
||||||
|
function fakeWallet () {
|
||||||
|
const wallet = {
|
||||||
|
walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' },
|
||||||
|
utxos: [{ txid: 'utxo-fee' }],
|
||||||
|
getUtxos: async function () { return this.utxos },
|
||||||
|
sendOpReturn: async function (msg, prefix) {
|
||||||
|
if (this.failWith) throw new Error(this.failWith)
|
||||||
|
return 'prop-txid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeProfiles () {
|
||||||
|
const names = new Map()
|
||||||
|
return {
|
||||||
|
names,
|
||||||
|
setName: (addr, name) => names.set(addr, name),
|
||||||
|
getName: (addr) => names.get(addr) || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('set-name validation: any non-blank string at or below the byte limit is valid', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => {
|
||||||
|
const len = 1 + Math.floor(rng() * MAX) // 1..MAX
|
||||||
|
return stringOf(len)
|
||||||
|
},
|
||||||
|
(name) => {
|
||||||
|
// A random ASCII string may occasionally be all whitespace; whitespace-only
|
||||||
|
// input is a validation error, so only assert for non-blank strings.
|
||||||
|
if (name.trim().length === 0) return true
|
||||||
|
const result = new MemoSetName({}).validate(name)
|
||||||
|
return result.ok === true
|
||||||
|
},
|
||||||
|
{ label: 'valid byte length' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('set-name validation: any string above the byte limit is a length error', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
|
||||||
|
(name) => {
|
||||||
|
const result = new MemoSetName({}).validate(name)
|
||||||
|
return result.ok === false && result.type === 'length'
|
||||||
|
},
|
||||||
|
{ label: 'over-long rejected as length' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('set-name validation: blank and non-string input are validation errors', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => (i % 2 === 0 ? ' ' : null),
|
||||||
|
(name) => {
|
||||||
|
const result = new MemoSetName({}).validate(name)
|
||||||
|
return result.ok === false && result.type === 'validation'
|
||||||
|
},
|
||||||
|
{ label: 'blank/non-string rejected as validation' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('byte counter conserves byte length: remaining === MAX - byteLength(input)', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(Math.floor(rng() * (MAX + 8))),
|
||||||
|
(name) => {
|
||||||
|
const page = buildPage()
|
||||||
|
page.setInput(name)
|
||||||
|
return page.remainingCount() === MAX - Buffer.byteLength(name, 'utf8')
|
||||||
|
},
|
||||||
|
{ label: 'byte counter conservation' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setInput round-trips the draft text exactly', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(Math.floor(rng() * 50)),
|
||||||
|
(name) => {
|
||||||
|
const page = buildPage()
|
||||||
|
page.setInput(name)
|
||||||
|
return page.input === name
|
||||||
|
},
|
||||||
|
{ label: 'setInput round-trip' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a broadcast failure surfaces the error and never navigates', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => ({ name: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }),
|
||||||
|
({ name, failWith }) => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
wallet.failWith = failWith
|
||||||
|
const navigations = []
|
||||||
|
const page = new SetNamePage({
|
||||||
|
memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }),
|
||||||
|
navigate: (p) => navigations.push(p)
|
||||||
|
})
|
||||||
|
page.setInput(name)
|
||||||
|
|
||||||
|
return page.submit().then((result) => {
|
||||||
|
if (result.ok) return false
|
||||||
|
if (page.submitError !== 'broadcast') return false
|
||||||
|
if (!page.broadcastError || !page.broadcastError.includes('boom')) return false
|
||||||
|
return navigations.length === 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ label: 'broadcast failure does not navigate' }
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user