diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 7bab3af..31fe662 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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 (.+)$/, diff --git a/psf-memo-client/acceptance/lib/render-recent-profile-account.js b/psf-memo-client/acceptance/lib/render-recent-profile-account.js new file mode 100644 index 0000000..0949bfa --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-recent-profile-account.js @@ -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 } diff --git a/psf-memo-client/src/components/app-body/recent-profiles/index.js b/psf-memo-client/src/components/app-body/recent-profiles/index.js index de7c5c2..1a770d2 100644 --- a/psf-memo-client/src/components/app-body/recent-profiles/index.js +++ b/psf-memo-client/src/components/app-body/recent-profiles/index.js @@ -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 () { - - - - - + {RECENT_PROFILES_TABLE_HEADERS.map((header) => ( + + ))} {profiles.map((profile) => ( +
AddressBioBlockSeenTXID{header}
+ + { + 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 diff --git a/psf-memo-client/src/services/recent-profiles-table.js b/psf-memo-client/src/services/recent-profiles-table.js new file mode 100644 index 0000000..323ab1d --- /dev/null +++ b/psf-memo-client/src/services/recent-profiles-table.js @@ -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 +} diff --git a/psf-memo-client/test/unit/recent-profile-account-component.test.js b/psf-memo-client/test/unit/recent-profile-account-component.test.js new file mode 100644 index 0000000..d7394e4 --- /dev/null +++ b/psf-memo-client/test/unit/recent-profile-account-component.test.js @@ -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, /]+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, / { + 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) +}) diff --git a/psf-memo-client/test/unit/recent-profiles-table.test.js b/psf-memo-client/test/unit/recent-profiles-table.test.js new file mode 100644 index 0000000..e02c1e7 --- /dev/null +++ b/psf-memo-client/test/unit/recent-profiles-table.test.js @@ -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') +}) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index e3f2b96..4c40efc 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -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 () and offset ()$/, diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index abb235a..757a685 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -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 diff --git a/psf-memo-db/src/adapters/profile-query.js b/psf-memo-db/src/adapters/profile-query.js index 647ce53..ef16501 100644 --- a/psf-memo-db/src/adapters/profile-query.js +++ b/psf-memo-db/src/adapters/profile-query.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/profile/controller.js b/psf-memo-db/src/controllers/rest-api/profile/controller.js index 6d7bf88..4f09697 100644 --- a/psf-memo-db/src/controllers/rest-api/profile/controller.js +++ b/psf-memo-db/src/controllers/rest-api/profile/controller.js @@ -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 diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index 294936f..6668d24 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -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, diff --git a/psf-memo-db/test/unit/adapters/profile-query.unit.js b/psf-memo-db/test/unit/adapters/profile-query.unit.js index 121e8ee..e03aab5 100644 --- a/psf-memo-db/test/unit/adapters/profile-query.unit.js +++ b/psf-memo-db/test/unit/adapters/profile-query.unit.js @@ -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 }) + }) }) diff --git a/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js b/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js index 3ec74bc..4b19ae1 100644 --- a/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js +++ b/psf-memo-db/test/unit/use-cases/list-recent-profiles.unit.js @@ -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 })