mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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')
|
||||
})
|
||||
@@ -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>)$/,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user