Implement recent profile identity join and Account column

DB: GET /profile/recent joins each profile's display name (names store)
and avatar URL (profilePics store) by address, reporting null when a
record is absent. The join runs on the requested page only and leaves the
profile order and pagination unchanged.

Client: the /profile/recent table gains a leftmost Account column showing
the display name and avatar, both linking to the profile, with a
truncated-address fallback when the name is absent and an identicon
fallback when the avatar is absent. The existing columns are preserved.

Add focused unit tests, acceptance fixtures, and regex step handlers for
both components.

By coder.
This commit is contained in:
Chris Troutner
2026-09-20 12:46:12 -07:00
parent dda94b1a81
commit 9fb092e845
14 changed files with 639 additions and 13 deletions
+102
View File
@@ -45,6 +45,7 @@ const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const NotificationsPage = require('../../src/services/notifications-page')
const RecentProfilesPage = require('../../src/services/recent-profiles-page')
const { buildRecentProfilesTable } = require('../../src/services/recent-profiles-table')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -60,6 +61,7 @@ const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result')
const { renderMuteResult } = require('./render-mute-result')
const { renderNotificationEntry } = require('./render-notification-entry')
const { renderRecentProfileAccount } = require('./render-recent-profile-account')
const { VIEW_POST_LABEL } = require('../../src/services/notification-entry')
const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
@@ -714,6 +716,36 @@ function isTopicFeedActive (world) {
return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/'))
}
// Fixture "recent-profiles-identities" from recent-profile-display.feature:
// the GET /profile/recent response already carrying each profile's display
// name (name) and avatar URL (profilePicUrl), null when absent.
function loadRecentProfilesFixture (world, name) {
if (name !== 'recent-profiles-identities') {
throw new Error(`Unknown recent profiles fixture: ${name}`)
}
const fixture = [
{ addr: 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy', text: 'alice bio', name: 'alice', profilePicUrl: 'https://example.com/alice.png' },
{ addr: 'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r', text: 'bob bio', name: 'bob', profilePicUrl: 'https://example.com/bob.jpg' },
{ addr: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', text: 'carol bio', name: 'carol', profilePicUrl: null },
{ addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', text: 'dave bio', name: null, profilePicUrl: 'https://example.com/dave.png' }
]
for (const profile of fixture) {
world.memoDb.profiles.push(profile)
}
}
// The account view model for the recent profile with the given address.
function recentProfileAccountFor (world, addr) {
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
const row = table.rows.find((candidate) => candidate.addr === addr)
if (!row) {
throw new Error(`No recent profiles account for the address ${addr}.`)
}
return row.account
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
@@ -3764,6 +3796,76 @@ const handlers = [
world.currentPath = RecentProfilesPage.RECENT_PROFILES_PATH
}
},
{
name: 'API serves recent profiles fixture',
pattern: /^the psf-memo-db API serves the recent profiles fixture "([^"]+)"$/,
run (m, example, world) {
loadRecentProfilesFixture(world, m[1])
}
},
{
name: 'recent profiles account shows identity field',
pattern: /^the recent profiles account for the address (.+) shows the (display name|avatar) "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const field = m[2] === 'display name' ? 'displayName' : 'avatarUrl'
const expected = resolveParam(m[3], example)
const account = recentProfileAccountFor(world, addr)
const actual = account[field] ?? null
if (actual !== expected) {
throw new Error(`Expected the recent profiles account for ${addr} to show ${m[2]} "${expected}", got "${actual}".`)
}
const html = renderRecentProfileAccount(account)
const rendered = m[2] === 'avatar' ? `src="${expected}"` : `>${expected}<`
if (!html.includes(rendered)) {
throw new Error(`Rendered recent profiles account does not show ${m[2]} "${expected}".`)
}
}
},
{
name: 'recent profiles account shows identicon',
pattern: /^the recent profiles account for the address (.+) shows an identicon avatar$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const account = recentProfileAccountFor(world, addr)
if (account.avatarUrl) {
throw new Error(`Expected the recent profiles account for ${addr} to fall back to an identicon, got avatar "${account.avatarUrl}".`)
}
const html = renderRecentProfileAccount(account)
if (!html.includes('recent-profile-identicon') || !html.includes('data-jdenticon-value')) {
throw new Error('Rendered recent profiles account does not show an identicon avatar.')
}
}
},
{
name: 'recent profiles account links identity to profile',
pattern: /^the recent profiles account for the address (.+) links the (avatar|display name) to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const linkField = m[2]
const expected = resolveParam(m[3], example)
const account = recentProfileAccountFor(world, addr)
if (account.profilePath !== expected) {
throw new Error(`Expected the recent profiles account for ${addr} to link the ${linkField} to "${expected}", got "${account.profilePath}".`)
}
const className = linkField === 'avatar' ? 'recent-profile-avatar-link' : 'recent-profile-name-link'
const href = anchorHref(renderRecentProfileAccount(account), className)
if (href !== expected) {
throw new Error(`Rendered recent profiles ${linkField} does not link to ${expected}.`)
}
}
},
{
name: 'recent profiles table has column headers',
pattern: /^the recent profiles table has the column headers (.+)$/,
run (m, example, world) {
const expected = [...m[1].matchAll(/"([^"]*)"/g)].map((match) => match[1])
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
if (table.headers.join('|') !== expected.join('|')) {
throw new Error(`Expected recent profiles headers ${expected.join(', ')}, got ${table.headers.join(', ')}.`)
}
}
},
{
name: 'open profile page for address',
pattern: /^I open the profile page for the address (.+)$/,
@@ -0,0 +1,20 @@
/*
Acceptance rendering adapter for the Recent Profiles account cell.
Renders the same RecentProfileAccount component the browser uses to a static
HTML string, so acceptance assertions can inspect the avatar, profile links,
and display name without running a browser.
*/
'use strict'
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const RecentProfileAccount = require('../../src/components/app-body/recent-profiles/recent-profile-account')
function renderRecentProfileAccount (account) {
const element = React.createElement(RecentProfileAccount, { account })
return ReactDOMServer.renderToStaticMarkup(element)
}
module.exports = { renderRecentProfileAccount }
@@ -3,12 +3,14 @@
*/
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import RecentProfilesPage from '../../../services/recent-profiles-page'
import { RECENT_PROFILES_TABLE_HEADERS, buildRecentProfileAccount } from '../../../services/recent-profiles-table'
import RecentProfileAccount from './recent-profile-account'
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
import '../../../App.css'
@@ -22,6 +24,7 @@ function formatSeen (seen) {
}
function RecentProfiles () {
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [profiles, setProfiles] = useState([])
@@ -85,16 +88,17 @@ function RecentProfiles () {
<Table striped bordered hover responsive className='mt-3'>
<thead>
<tr>
<th>Address</th>
<th>Bio</th>
<th>Block</th>
<th>Seen</th>
<th>TXID</th>
{RECENT_PROFILES_TABLE_HEADERS.map((header) => (
<th key={header}>{header}</th>
))}
</tr>
</thead>
<tbody>
{profiles.map((profile) => (
<tr key={`${profile.addr}-${profile.txid}`}>
<td>
<RecentProfileAccount account={buildRecentProfileAccount(profile)} onProfileClick={navigate} />
</td>
<td>
<Link
to={`/profile/${encodeURIComponent(profile.addr)}`}
@@ -0,0 +1,74 @@
/*
The Account cell of the Recent Profiles table.
Shows the profile's display name and avatar, links both to the profile, and
falls back to an identicon when the profile has no avatar. The display name
itself already carries the truncated-address fallback from the view model.
Written in plain React.createElement style so the same module can be used by
the JSX Recent Profiles page in the browser build and by the acceptance
adapter that renders HTML under Node.
*/
const React = require('react')
const JdenticonModule = require('@chris.troutner/react-jdenticon')
const Jdenticon = JdenticonModule.default || JdenticonModule
// The profile's avatar: the profile picture when present, an identicon
// otherwise.
function RecentProfileAvatar ({ account }) {
if (account.avatarUrl) {
return React.createElement('img', {
src: account.avatarUrl,
alt: 'Account avatar',
className: 'recent-profile-avatar',
width: 36,
height: 36
})
}
return React.createElement(
'div',
{ className: 'recent-profile-avatar recent-profile-identicon' },
React.createElement(Jdenticon, { value: account.addr, size: '36' })
)
}
function RecentProfileAccount ({ account, onProfileClick }) {
if (!account) return null
const handleProfileClick = (event) => {
if (!onProfileClick) return
event.preventDefault()
onProfileClick(account.profilePath)
}
return React.createElement(
'div',
{ className: 'recent-profile-account' },
React.createElement(
'a',
{
className: 'recent-profile-avatar-link',
href: account.profilePath,
onClick: handleProfileClick,
'aria-label': `View ${account.displayName}'s profile`
},
React.createElement(RecentProfileAvatar, { account })
),
React.createElement(
'a',
{
className: 'recent-profile-name-link',
href: account.profilePath,
onClick: handleProfileClick,
title: account.addr
},
account.displayName
)
)
}
module.exports = RecentProfileAccount
module.exports.RecentProfileAvatar = RecentProfileAvatar
@@ -0,0 +1,58 @@
/*
Build the Recent Profiles page table view model.
The /profile/recent response already carries each profile's display name
(name, joined from the names store) and avatar URL (profilePicUrl, joined
from the profilePics store). The React Recent Profiles page renders the table
from this model so the leftmost Account column — display name and avatar,
both linking to the profile, with truncated-address and identicon fallbacks —
stays testable without a DOM.
*/
const { truncateAddr } = require('../util')
const PROFILE_PATH_PREFIX = '/profile'
// Column headers, left to right. Account is first; the other columns preserve
// the pre-existing order.
const RECENT_PROFILES_TABLE_HEADERS = ['Account', 'Address', 'Bio', 'Block', 'Seen', 'TXID']
function profilePath (addr) {
return `${PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
// The name to show in the account cell: the display name when present, the
// truncated address otherwise.
function accountDisplayName (addr, name) {
return name || truncateAddr(addr, 24)
}
// The account-cell view model for one profile.
function buildRecentProfileAccount (profile = {}) {
return {
addr: profile.addr,
displayName: accountDisplayName(profile.addr, profile.name),
avatarUrl: profile.profilePicUrl || null,
profilePath: profilePath(profile.addr)
}
}
// The table view model: the header row plus one row per profile.
function buildRecentProfilesTable (profiles = []) {
return {
headers: [...RECENT_PROFILES_TABLE_HEADERS],
rows: profiles.map((profile) => ({
addr: profile.addr,
account: buildRecentProfileAccount(profile)
}))
}
}
module.exports = {
PROFILE_PATH_PREFIX,
RECENT_PROFILES_TABLE_HEADERS,
profilePath,
accountDisplayName,
buildRecentProfileAccount,
buildRecentProfilesTable
}
@@ -0,0 +1,68 @@
/*
Unit tests for the Recent Profile account cell renderer.
The account cell shows the profile's display name and avatar, links both to
the profile, and falls back to an identicon when the profile has no avatar.
Written against the same rendering seam the acceptance suite uses so the
module stays covered by the standard unit suite.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const RecentProfileAccount = require('../../src/components/app-body/recent-profiles/recent-profile-account')
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const PROFILE_PATH = `/profile/${encodeURIComponent(ALICE)}`
function makeAccount (overrides = {}) {
return {
addr: ALICE,
displayName: 'alice',
avatarUrl: null,
profilePath: PROFILE_PATH,
...overrides
}
}
function render (account) {
return ReactDOMServer.renderToStaticMarkup(
React.createElement(RecentProfileAccount, { account })
)
}
test('renders nothing without an account', () => {
assert.equal(render(null), '')
})
test('renders the display name', () => {
const html = render(makeAccount())
assert.ok(html.includes('alice'))
})
test('renders the avatar image when the profile has an avatar URL', () => {
const html = render(makeAccount({ avatarUrl: 'https://example.com/alice.png' }))
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/alice\.png"/)
assert.doesNotMatch(html, /recent-profile-identicon/)
})
test('renders an identicon when the profile has no avatar URL', () => {
const html = render(makeAccount({ avatarUrl: null }))
assert.match(html, /recent-profile-identicon/)
assert.match(html, /data-jdenticon-value/)
assert.doesNotMatch(html, /<img/)
})
test('links the avatar and display name to the profile', () => {
const html = render(makeAccount())
assert.ok(html.includes('class="recent-profile-avatar-link"'))
assert.ok(html.includes('class="recent-profile-name-link"'))
assert.equal(html.split(`href="${PROFILE_PATH}"`).length - 1, 2)
})
@@ -0,0 +1,73 @@
/*
Unit tests for the Recent Profiles table view model.
The /profile/recent response already carries each profile's display name
(name) and avatar URL (profilePicUrl), joined by the DB. These tests pin the
pure table/account view model independent of the React shell: the leftmost
Account column, its truncated-address fallback, and the preserved columns.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const {
PROFILE_PATH_PREFIX,
RECENT_PROFILES_TABLE_HEADERS,
profilePath,
accountDisplayName,
buildRecentProfileAccount,
buildRecentProfilesTable
} = require('../../src/services/recent-profiles-table')
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const DAVE = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
test('profilePath encodes the address for the profile route', () => {
assert.equal(profilePath(ALICE), `${PROFILE_PATH_PREFIX}/${encodeURIComponent(ALICE)}`)
})
test('accountDisplayName prefers the display name', () => {
assert.equal(accountDisplayName(ALICE, 'alice'), 'alice')
})
test('accountDisplayName falls back to the truncated address without a name', () => {
assert.equal(accountDisplayName(DAVE, null), 'bitcoincas...py26r63g3d')
assert.equal(accountDisplayName(DAVE, ''), 'bitcoincas...py26r63g3d')
})
test('buildRecentProfileAccount carries the name, avatar, and profile path', () => {
const account = buildRecentProfileAccount({
addr: ALICE,
name: 'alice',
profilePicUrl: 'https://example.com/alice.png'
})
assert.equal(account.addr, ALICE)
assert.equal(account.displayName, 'alice')
assert.equal(account.avatarUrl, 'https://example.com/alice.png')
assert.equal(account.profilePath, `/profile/${encodeURIComponent(ALICE)}`)
})
test('buildRecentProfileAccount reports a null avatar when the profile has no picture', () => {
const account = buildRecentProfileAccount({ addr: ALICE, name: 'alice', profilePicUrl: null })
assert.equal(account.avatarUrl, null)
})
test('the table headers put Account first and preserve the existing columns', () => {
assert.deepEqual(RECENT_PROFILES_TABLE_HEADERS, ['Account', 'Address', 'Bio', 'Block', 'Seen', 'TXID'])
})
test('buildRecentProfilesTable builds one row per profile with its account', () => {
const table = buildRecentProfilesTable([
{ addr: ALICE, name: 'alice', profilePicUrl: 'https://example.com/alice.png' },
{ addr: DAVE, name: null, profilePicUrl: 'https://example.com/dave.png' }
])
assert.deepEqual(table.headers, RECENT_PROFILES_TABLE_HEADERS)
assert.equal(table.rows.length, 2)
assert.equal(table.rows[0].addr, ALICE)
assert.equal(table.rows[0].account.displayName, 'alice')
assert.equal(table.rows[1].account.displayName, 'bitcoincas...py26r63g3d')
})
+91
View File
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
import { DB_NAMES } from '../../src/adapters/level-db.js'
import Adapters from '../../src/adapters/index.js'
import ListRecentPosts from '../../src/use-cases/list-recent-posts.js'
import ListRecentProfiles from '../../src/use-cases/list-recent-profiles.js'
import ListPostsByAddr from '../../src/use-cases/list-posts-by-addr.js'
import GetPostThread from '../../src/use-cases/get-post-thread.js'
import FollowState from '../../src/use-cases/follow-state.js'
@@ -163,6 +164,7 @@ async function createWorld () {
wrapIterator(adapters.level.topicRecencyDb, topicRecencyIteratorCounter)
const listRecentPosts = new ListRecentPosts({ adapters })
const listRecentProfiles = new ListRecentProfiles({ adapters })
const listPostsByAddr = new ListPostsByAddr({ adapters })
const getPostThread = new GetPostThread({ adapters })
const followState = new FollowState({ adapters })
@@ -185,6 +187,7 @@ async function createWorld () {
return {
adapters,
listRecentPosts,
listRecentProfiles,
listPostsByAddr,
getPostThread,
followState,
@@ -327,6 +330,11 @@ async function loadFixture (world, name) {
return
}
if (name === 'profiles-with-identities') {
await loadProfilesWithIdentities(world)
return
}
if (name !== 'three-top-level-posts-and-one-reply') {
throw new Error(`Unknown fixture: ${name}`)
}
@@ -511,6 +519,49 @@ async function loadManyTopLevelPosts (world) {
}
}
// Fixture "profiles-with-identities" from recent-profile-identity.feature:
// three profiles plus separate address-keyed name and profile-picture records
// that the /profile/recent join must fold into each profile.
async function loadProfilesWithIdentities (world) {
const profiles = [
{ addr: 'bitcoincash:qaddr-alice', text: 'alice bio', txid: 'profile-alice', seen: 3, blockHeight: 600300 },
{ addr: 'bitcoincash:qaddr-bob', text: 'bob bio', txid: 'profile-bob', seen: 2, blockHeight: 600200 },
{ addr: 'bitcoincash:qaddr-carol', text: 'carol bio', txid: 'profile-carol', seen: 1, blockHeight: 600100 }
]
for (const profile of profiles) {
await world.adapters.level.profilesDb.put(profile.addr, {
text: profile.text,
txid: profile.txid,
seen: profile.seen,
blockHeight: profile.blockHeight
})
}
const names = [
{ addr: 'bitcoincash:qaddr-alice', name: 'alice', txid: 'name-alice', blockHeight: 600250 },
{ addr: 'bitcoincash:qaddr-carol', name: 'carol', txid: 'name-carol', blockHeight: 600050 }
]
for (const name of names) {
await world.adapters.level.namesDb.put(name.addr, {
name: name.name,
txid: name.txid,
blockHeight: name.blockHeight
})
}
const pictures = [
{ addr: 'bitcoincash:qaddr-alice', url: 'https://example.com/alice.png', txid: 'pic-alice', blockHeight: 600260 },
{ addr: 'bitcoincash:qaddr-bob', url: 'https://example.com/bob.jpg', txid: 'pic-bob', blockHeight: 600150 }
]
for (const picture of pictures) {
await world.adapters.level.profilePicsDb.put(picture.addr, {
url: picture.url,
txid: picture.txid,
blockHeight: picture.blockHeight
})
}
}
async function loadFollowingFeedCapped (world) {
const viewer = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const followee = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
@@ -1093,6 +1144,46 @@ const handlers = [
await repairTxidEncoding(world.adapters.level)
}
},
{
name: 'db instance with profiles, names, and profilePics stores',
pattern: /^a psf-memo-db instance with profiles, names, and profilePics stores$/,
async run () {
// World is already created with all three stores.
}
},
{
name: 'load fixture into profiles, names, and profilePics stores',
pattern: /^the fixture "(.+)" is loaded into the profiles, names, and profilePics stores$/,
async run (m, example, world) {
await loadFixture(world, m[1])
}
},
{
name: 'request recent profiles',
pattern: /^the client requests \/profile\/recent$/,
async run (m, example, world) {
const resp = await world.listRecentProfiles.execute({})
world.setLastResponse(resp)
}
},
{
name: 'recent profile identity field',
pattern: /^the response profile for (.+) has (display name|avatar) "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const field = m[2] === 'display name' ? 'name' : 'profilePicUrl'
const resolved = resolveParam(m[3], example)
const expected = resolved === '' ? null : resolved
const profile = world.getLastResponse().profiles.find((p) => p.addr === addr)
if (!profile) {
throw new Error(`No response profile for ${addr}`)
}
const actual = profile[field] ?? null
if (actual !== expected) {
throw new Error(`Expected ${field} "${expected}" for ${addr}, got "${actual}"`)
}
}
},
{
name: 'request recent posts',
pattern: /^the client requests \/posts\/recent with limit (<limit>) and offset (<offset>)$/,
+3 -1
View File
@@ -27,7 +27,9 @@ class Adapters {
this.level = level
this.dbBackup = new DbBackup(level)
this.profileQuery = new ProfileQuery({
profilesDb: level.profilesDb
profilesDb: level.profilesDb,
namesDb: level.namesDb,
profilePicsDb: level.profilePicsDb
})
// muteQuery must be constructed before postQuery: downstream adapters read
// this.muteQuery at construction time, so declaring postQuery first would
+32 -2
View File
@@ -1,15 +1,19 @@
/*
Adapter for scanning profiles with stored block height.
Adapter for scanning profiles with stored block height and joining the
address-keyed display name and avatar URL.
*/
class ProfileQuery {
constructor (localConfig = {}) {
const { profilesDb } = localConfig
const { profilesDb, namesDb, profilePicsDb } = localConfig
if (!profilesDb) {
throw new Error('profilesDb required when instantiating ProfileQuery adapter.')
}
this.profilesDb = profilesDb
this.namesDb = namesDb || null
this.profilePicsDb = profilePicsDb || null
this.scanProfilesWithBlockHeight = this.scanProfilesWithBlockHeight.bind(this)
this.getProfileIdentity = this.getProfileIdentity.bind(this)
}
async scanProfilesWithBlockHeight () {
@@ -27,6 +31,32 @@ class ProfileQuery {
return profiles
}
// Join one profile's display name (names store) and avatar URL (profilePics
// store). Both stores are keyed by address and hold the newest record, so a
// point lookup is enough. A missing record reports null so a profile with no
// name or picture still renders.
async getProfileIdentity (addr) {
const [nameRecord, picRecord] = await Promise.all([
this.getRecordOrNull(this.namesDb, addr),
this.getRecordOrNull(this.profilePicsDb, addr)
])
return {
name: nameRecord?.name || null,
profilePicUrl: picRecord?.url || null
}
}
async getRecordOrNull (db, key) {
if (!db) return null
try {
return await db.get(key)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
throw err
}
}
}
export default ProfileQuery
@@ -29,7 +29,7 @@ class ProfileRESTControllerLib {
* @apiName GetRecentProfiles
* @apiGroup REST Profile
*
* @apiDescription Returns profiles sorted by block height (newest first), with seen timestamp as tie-breaker.
* @apiDescription Returns profiles sorted by block height (newest first), with seen timestamp as tie-breaker. Each profile is joined with its address-keyed display name (names store) and avatar URL (profilePics store), reported as null when absent.
*
* @apiQuery {Number} [limit=100] Page size (max 100)
* @apiQuery {Number} [offset=0] Number of profiles to skip after sorting
@@ -40,6 +40,8 @@ class ProfileRESTControllerLib {
* @apiSuccess {Object[]} profiles Array of profile objects
* @apiSuccess {String} profiles.addr Cash address
* @apiSuccess {String} profiles.text Profile message text
* @apiSuccess {String} profiles.name Display name from the names store, or null when absent
* @apiSuccess {String} profiles.profilePicUrl Avatar URL from the profilePics store, or null when absent
* @apiSuccess {String} profiles.txid Provenance transaction id
* @apiSuccess {Number} profiles.seen Unix epoch milliseconds
* @apiSuccess {Number} profiles.blockHeight Block height when indexed
@@ -18,7 +18,13 @@ class ListRecentProfiles extends ListUseCase {
const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight()
const sorted = allProfiles.sort(sortByHeightDesc)
const total = sorted.length
const profiles = sorted.slice(offset, offset + limit)
const page = sorted.slice(offset, offset + limit)
const profiles = await Promise.all(
page.map(async (profile) => ({
...profile,
...(await this.adapters.profileQuery.getProfileIdentity(profile.addr))
}))
)
return {
profiles,
@@ -6,13 +6,21 @@ describe('#ProfileQuery', () => {
let uut
let sandbox
let profilesDb
let namesDb
let profilePicsDb
beforeEach(() => {
sandbox = sinon.createSandbox()
profilesDb = {
iterator: sandbox.stub()
}
uut = new ProfileQuery({ profilesDb })
namesDb = {
get: sandbox.stub()
}
profilePicsDb = {
get: sandbox.stub()
}
uut = new ProfileQuery({ profilesDb, namesDb, profilePicsDb })
})
afterEach(() => sandbox.restore())
@@ -41,4 +49,59 @@ describe('#ProfileQuery', () => {
assert.equal(result[0].blockHeight, 0)
})
it('should join the display name and avatar for an address', async () => {
namesDb.get.withArgs('addr1').resolves({ name: 'Alice', txid: 'name-1', blockHeight: 600250 })
profilePicsDb.get.withArgs('addr1').resolves({ url: 'https://example.com/alice.png', txid: 'pic-1', blockHeight: 600260 })
const identity = await uut.getProfileIdentity('addr1')
assert.deepEqual(identity, {
name: 'Alice',
profilePicUrl: 'https://example.com/alice.png'
})
})
it('should report null identity fields when both records are missing', async () => {
const notFound = new Error('not found')
notFound.notFound = true
namesDb.get.rejects(notFound)
profilePicsDb.get.rejects(notFound)
const identity = await uut.getProfileIdentity('addr1')
assert.deepEqual(identity, { name: null, profilePicUrl: null })
})
it('should resolve each identity field independently', async () => {
namesDb.get.resolves({ name: 'Bob' })
const notFound = new Error('not found')
notFound.code = 'LEVEL_NOT_FOUND'
profilePicsDb.get.rejects(notFound)
const identity = await uut.getProfileIdentity('addr2')
assert.equal(identity.name, 'Bob')
assert.equal(identity.profilePicUrl, null)
})
it('should rethrow unexpected lookup errors', async () => {
namesDb.get.rejects(new Error('lookup boom'))
profilePicsDb.get.resolves(null)
try {
await uut.getProfileIdentity('addr1')
assert.fail('Expected error')
} catch (err) {
assert.equal(err.message, 'lookup boom')
}
})
it('should report a null identity when a store is not configured', async () => {
const uutWithoutStores = new ProfileQuery({ profilesDb })
const identity = await uutWithoutStores.getProfileIdentity('addr1')
assert.deepEqual(identity, { name: null, profilePicUrl: null })
})
})
@@ -17,7 +17,8 @@ describe('#ListRecentProfiles', () => {
uut = new ListRecentProfiles({
adapters: {
profileQuery: {
scanProfilesWithBlockHeight: sandbox.stub().resolves([...mockProfiles])
scanProfilesWithBlockHeight: sandbox.stub().resolves([...mockProfiles]),
getProfileIdentity: sandbox.stub().resolves({ name: null, profilePicUrl: null })
}
}
})
@@ -25,6 +26,38 @@ describe('#ListRecentProfiles', () => {
afterEach(() => sandbox.restore())
it('should join each profile display name and avatar', async () => {
uut.adapters.profileQuery.getProfileIdentity.callsFake(async (addr) => {
if (addr === 'addr-b') {
return { name: 'Bob', profilePicUrl: 'https://example.com/bob.jpg' }
}
return { name: null, profilePicUrl: null }
})
const result = await uut.execute({ limit: 10, offset: 0 })
const bob = result.profiles.find((p) => p.addr === 'addr-b')
assert.equal(bob.name, 'Bob')
assert.equal(bob.profilePicUrl, 'https://example.com/bob.jpg')
const alice = result.profiles.find((p) => p.addr === 'addr-a')
assert.equal(alice.name, null)
assert.equal(alice.profilePicUrl, null)
})
it('should only join identities for the requested page', async () => {
await uut.execute({ limit: 1, offset: 1 })
assert.equal(uut.adapters.profileQuery.getProfileIdentity.callCount, 1)
assert.equal(uut.adapters.profileQuery.getProfileIdentity.firstCall.args[0], 'addr-c')
})
it('should not change pagination metadata when joining identities', async () => {
const result = await uut.execute({ limit: 2, offset: 0 })
assert.equal(result.pagination.total, 3)
assert.equal(result.pagination.hasMore, true)
})
it('should return profiles sorted by block height descending', async () => {
const result = await uut.execute({ limit: 10, offset: 0 })