mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement Notifications read-only feature
Add psf-memo-db notifications query, use case, route, and tests. Add psf-memo-client NotificationsPage service, React page, nav link, route, and tests. Wire acceptance handlers for notifications. By coder.
This commit is contained in:
@@ -41,6 +41,7 @@ const ThreadPage = require('../../src/services/thread-page')
|
||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||
const TopicFeedPage = require('../../src/services/topic-feed-page')
|
||||
const SearchPage = require('../../src/services/search-page')
|
||||
const NotificationsPage = require('../../src/services/notifications-page')
|
||||
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
|
||||
const MemoTopicPost = require('../../src/services/memo-topic-post')
|
||||
const TopicPostPage = require('../../src/services/topic-post-page')
|
||||
@@ -190,6 +191,9 @@ function makeMemoDb () {
|
||||
const followState = {}
|
||||
const muteState = {}
|
||||
const replyTxids = new Set()
|
||||
const replies = []
|
||||
const likes = []
|
||||
const followers = new Map()
|
||||
const topics = []
|
||||
const topicPosts = {}
|
||||
const topicCounts = new Map()
|
||||
@@ -210,7 +214,20 @@ function makeMemoDb () {
|
||||
},
|
||||
addReply (reply) {
|
||||
replyTxids.add(reply.txid)
|
||||
posts.push(reply)
|
||||
replies.push({
|
||||
txid: reply.txid,
|
||||
parentTxid: reply.parentTxid,
|
||||
text: reply.text,
|
||||
addr: reply.addr || 'bitcoincash:reply-author',
|
||||
blockHeight: reply.blockHeight ?? 100
|
||||
})
|
||||
posts.push({
|
||||
txid: reply.txid,
|
||||
addr: reply.addr || 'bitcoincash:reply-author',
|
||||
text: reply.text,
|
||||
blockHeight: reply.blockHeight ?? 100,
|
||||
seen: reply.seen ?? 0
|
||||
})
|
||||
},
|
||||
addSearchPost (post) {
|
||||
searchPosts.push(post)
|
||||
@@ -218,6 +235,24 @@ function makeMemoDb () {
|
||||
addSearchProfile (profile) {
|
||||
searchProfiles.push(profile)
|
||||
},
|
||||
addLike (like) {
|
||||
likes.push({
|
||||
txid: like.txid,
|
||||
postTxid: like.postTxid,
|
||||
addr: like.addr,
|
||||
blockHeight: like.blockHeight ?? 100
|
||||
})
|
||||
},
|
||||
addFollower (followerAddr, followeeAddr, opts = {}) {
|
||||
const list = followers.get(followeeAddr) || []
|
||||
list.push({
|
||||
followerAddr,
|
||||
followeeAddr,
|
||||
txid: opts.txid || require('crypto').createHash('sha256').update(`${followerAddr}:${followeeAddr}`).digest('hex'),
|
||||
blockHeight: opts.blockHeight ?? 100
|
||||
})
|
||||
followers.set(followeeAddr, list)
|
||||
},
|
||||
addTopic (room, postCount) {
|
||||
topicCounts.set(room, postCount)
|
||||
topicPosts[room] = []
|
||||
@@ -309,6 +344,52 @@ function makeMemoDb () {
|
||||
const page = all.slice(offset, offset + limit)
|
||||
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
|
||||
},
|
||||
async getNotifications (addr, { limit = 100, offset = 0 } = {}) {
|
||||
const notifications = []
|
||||
|
||||
for (const reply of replies) {
|
||||
const parent = posts.find((p) => p.txid === reply.parentTxid)
|
||||
if (!parent || parent.addr !== addr) continue
|
||||
if (reply.addr === addr) continue
|
||||
notifications.push({
|
||||
type: 'reply',
|
||||
txid: reply.txid,
|
||||
addr: reply.addr,
|
||||
postTxid: reply.parentTxid,
|
||||
text: reply.text,
|
||||
blockHeight: reply.blockHeight ?? parent.blockHeight ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
for (const like of likes) {
|
||||
const post = posts.find((p) => p.txid === like.postTxid)
|
||||
if (!post || post.addr !== addr) continue
|
||||
if (like.addr === addr) continue
|
||||
notifications.push({
|
||||
type: 'like',
|
||||
txid: like.txid,
|
||||
addr: like.addr,
|
||||
postTxid: like.postTxid,
|
||||
blockHeight: like.blockHeight ?? post.blockHeight ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
for (const follow of (followers.get(addr) || [])) {
|
||||
if (follow.followerAddr === addr) continue
|
||||
notifications.push({
|
||||
type: 'follow',
|
||||
txid: follow.txid,
|
||||
addr: follow.followerAddr,
|
||||
blockHeight: follow.blockHeight ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
notifications.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0))
|
||||
|
||||
const total = notifications.length
|
||||
const page = notifications.slice(offset, offset + limit)
|
||||
return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } }
|
||||
},
|
||||
async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) {
|
||||
const followees = new Set()
|
||||
for (const [key, following] of Object.entries(followState)) {
|
||||
@@ -362,6 +443,7 @@ function createWorld () {
|
||||
// Read-only page controllers backed by the fake psf-memo-db API.
|
||||
world.recentFeedPage = new RecentFeedPage({ memoDb })
|
||||
world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet })
|
||||
world.notificationsPage = new NotificationsPage({ memoDb, wallet })
|
||||
world.profilePage = new ProfilePage({ memoDb })
|
||||
world.threadPage = new ThreadPage({ memoDb })
|
||||
world.topicDiscoveryPage = new TopicDiscoveryPage({
|
||||
@@ -2276,7 +2358,7 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'API serves reply with txid parent and text',
|
||||
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/,
|
||||
pattern: /^the psf-memo-db API serves a reply with txid ([0-9a-fA-F]{64}) to the post with txid ([0-9a-fA-F]{64}) with text (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const parentTxid = resolveParam(m[2], example)
|
||||
@@ -2392,6 +2474,159 @@ const handlers = [
|
||||
throw new Error('Expected following feed to show the not-following-anyone message.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves reply to my post by address with text',
|
||||
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by the address (.+) with text (.+?)(?: at block height (\d+))?$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const parentTxid = resolveParam(m[2], example)
|
||||
const addr = resolveParam(m[3], example)
|
||||
const text = resolveText(m[4], example)
|
||||
const blockHeight = m[5] ? parseInt(m[5], 10) : 100
|
||||
world.memoDb.addReply({ txid, parentTxid, text, addr, blockHeight })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves reply to my post by me with text',
|
||||
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by my wallet address with text (.+?)(?: at block height (\d+))?$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const parentTxid = resolveParam(m[2], example)
|
||||
const text = resolveText(m[3], example)
|
||||
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.memoDb.addReply({ txid, parentTxid, text, addr: myAddr, blockHeight })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves like on my post by address',
|
||||
pattern: /^the psf-memo-db API serves a like with txid (.+) on the post with txid (.+) by the address (.+?)(?: at block height (\d+))?$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const postTxid = resolveParam(m[2], example)
|
||||
const addr = resolveParam(m[3], example)
|
||||
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
|
||||
world.memoDb.addLike({ txid, postTxid, addr, blockHeight })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API records address follows me',
|
||||
pattern: /^the psf-memo-db API records that the address (.+) follows my wallet address$/,
|
||||
run (m, example, world) {
|
||||
const followerAddr = resolveParam(m[1], example)
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.memoDb.addFollower(followerAddr, myAddr)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open notifications page',
|
||||
pattern: /^I open the Notifications page$/,
|
||||
async run (m, example, world) {
|
||||
await world.notificationsPage.load()
|
||||
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open notifications page with page size',
|
||||
pattern: /^I open the Notifications page with page size (\d+)$/,
|
||||
async run (m, example, world) {
|
||||
const limit = parseInt(m[1], 10)
|
||||
await world.notificationsPage.load({ limit })
|
||||
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications include reply notification',
|
||||
pattern: /^the notifications include a reply notification from the address (.+) with text (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expectedAddr = resolveParam(m[1], example)
|
||||
const expectedText = resolveText(m[2], example)
|
||||
const found = world.notificationsPage.notifications.find((n) =>
|
||||
n.type === 'reply' && n.addr === expectedAddr && n.text === expectedText
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Notifications do not include a reply from ${expectedAddr} with text "${expectedText}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications include like notification',
|
||||
pattern: /^the notifications include a like notification from the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expectedAddr = resolveParam(m[1], example)
|
||||
const found = world.notificationsPage.notifications.find((n) =>
|
||||
n.type === 'like' && n.addr === expectedAddr
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Notifications do not include a like from ${expectedAddr}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications include follow notification',
|
||||
pattern: /^the notifications include a follow notification from the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expectedAddr = resolveParam(m[1], example)
|
||||
const found = world.notificationsPage.notifications.find((n) =>
|
||||
n.type === 'follow' && n.addr === expectedAddr
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Notifications do not include a follow from ${expectedAddr}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications show like before reply',
|
||||
pattern: /^the notifications show the like notification before the reply notification$/,
|
||||
run (m, example, world) {
|
||||
const notifications = world.notificationsPage.notifications
|
||||
const likeIndex = notifications.findIndex((n) => n.type === 'like')
|
||||
const replyIndex = notifications.findIndex((n) => n.type === 'reply')
|
||||
if (likeIndex === -1) throw new Error('Notifications do not include a like notification.')
|
||||
if (replyIndex === -1) throw new Error('Notifications do not include a reply notification.')
|
||||
if (likeIndex >= replyIndex) {
|
||||
throw new Error('Expected like notification to appear before reply notification.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications show N notifications',
|
||||
pattern: /^the notifications show (\d+) notification$/,
|
||||
run (m, example, world) {
|
||||
const expected = parseInt(m[1], 10)
|
||||
const actual = world.notificationsPage.notifications.length
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} notifications, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications can load more',
|
||||
pattern: /^the notifications can load more$/,
|
||||
run (m, example, world) {
|
||||
if (!world.notificationsPage.canLoadMore()) {
|
||||
throw new Error('Expected notifications to have more pages, but pagination says there are none.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications include no notifications',
|
||||
pattern: /^the notifications include no notifications$/,
|
||||
run (m, example, world) {
|
||||
if (world.notificationsPage.notifications.length !== 0) {
|
||||
throw new Error(`Expected no notifications, got ${world.notificationsPage.notifications.length}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'notifications show no notifications message',
|
||||
pattern: /^the notifications show a message that I have no notifications$/,
|
||||
run (m, example, world) {
|
||||
if (!world.notificationsPage.empty) {
|
||||
throw new Error('Expected notifications page to show the no-notifications message.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import Topics from './topics'
|
||||
import TopicFeed from './topic-feed'
|
||||
import Search from './search'
|
||||
import FollowingFeed from './following-feed'
|
||||
import Notifications from './notifications'
|
||||
|
||||
function AppBody (props) {
|
||||
// Dependency injection through props
|
||||
@@ -56,6 +57,7 @@ function AppBody (props) {
|
||||
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
|
||||
<Route path='/search' element={<Search />} />
|
||||
<Route path='/posts/following' element={<FollowingFeed appData={appData} />} />
|
||||
<Route path='/notifications' element={<Notifications appData={appData} />} />
|
||||
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
|
||||
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
|
||||
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
Display the Notifications page: replies to my posts, likes on my posts,
|
||||
and new follows, newest first.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import NotificationsPage from '../../../services/notifications-page'
|
||||
import '../../../App.css'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
function notificationText (n) {
|
||||
if (n.type === 'reply') {
|
||||
return `replied to your post: ${n.text || ''}`
|
||||
}
|
||||
if (n.type === 'like') {
|
||||
return 'liked your post'
|
||||
}
|
||||
if (n.type === 'follow') {
|
||||
return 'followed you'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Notifications (props) {
|
||||
const { appData } = props
|
||||
const wallet = appData?.wallet
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [notifications, setNotifications] = useState([])
|
||||
const [pagination, setPagination] = useState(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const loadNotifications = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const memoDb = new MemoDb()
|
||||
const page = new NotificationsPage({ memoDb, wallet })
|
||||
const data = await page.load({ limit: PAGE_SIZE, offset })
|
||||
|
||||
setNotifications(data.notifications || [])
|
||||
setPagination(data.pagination || null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load notifications')
|
||||
setNotifications([])
|
||||
setPagination(null)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadNotifications()
|
||||
}, [offset, wallet])
|
||||
|
||||
const canGoBack = offset > 0
|
||||
const canGoNext = pagination?.hasMore ?? false
|
||||
|
||||
const handlePrevious = () => {
|
||||
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setOffset((prev) => prev + PAGE_SIZE)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='notifications-page'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8} md={10} xs={12}>
|
||||
<header className='notifications-heading'>
|
||||
<h1>Notifications</h1>
|
||||
<p>Replies, likes, and follows involving you.</p>
|
||||
|
||||
{pagination && notifications.length > 0 && (
|
||||
<span className='notifications-count'>
|
||||
Showing {pagination.offset + 1}–
|
||||
{pagination.offset + notifications.length} of {pagination.total}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<p className='notifications-error'>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className='text-center my-5'>
|
||||
<Spinner animation='border' role='status'>
|
||||
<span className='visually-hidden'>
|
||||
Loading...
|
||||
</span>
|
||||
</Spinner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && notifications.length === 0 && (
|
||||
<p className='notifications-empty'>You have no notifications.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && notifications.length > 0 && (
|
||||
<div className='notifications-list'>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.txid} className='notification-item' style={{ marginBottom: '1rem', padding: '0.75rem', border: '1px solid #dee2e6', borderRadius: '0.375rem' }}>
|
||||
<p className='text-muted' style={{ fontFamily: 'monospace', marginBottom: '0.25rem' }}>
|
||||
{n.addr}
|
||||
</p>
|
||||
<p style={{ marginBottom: 0 }}>{notificationText(n)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (pagination || offset > 0) && (
|
||||
<div className='notifications-pagination'>
|
||||
<Button
|
||||
variant='outline-dark'
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline-dark'
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notifications
|
||||
@@ -94,6 +94,14 @@ function NavMenu (props) {
|
||||
Following
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/notifications' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/notifications'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Notifications
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/posts/new'
|
||||
|
||||
@@ -157,6 +157,10 @@ class MemoDb {
|
||||
async getFollowingFeed (addr, opts = {}) {
|
||||
return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts)
|
||||
}
|
||||
|
||||
async getNotifications (addr, opts = {}) {
|
||||
return this.getPage(`/posts/notifications/${encodeURIComponent(addr)}`, 'getNotifications', opts)
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoDb
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Notifications Page behavior: load and display replies, likes, and follows
|
||||
that involve the viewer.
|
||||
|
||||
This is the testable controller behind the React Notifications page. It
|
||||
wraps the MemoDb client, identifies the viewer from the injected wallet, and
|
||||
exposes the loaded notifications so the view can render them.
|
||||
*/
|
||||
|
||||
const NOTIFICATIONS_PATH = '/notifications'
|
||||
|
||||
class NotificationsPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.wallet = deps.wallet || null
|
||||
this.notifications = []
|
||||
this.pagination = null
|
||||
this.empty = false
|
||||
}
|
||||
|
||||
getMyAddress () {
|
||||
return this.wallet?.walletInfo?.cashAddress || null
|
||||
}
|
||||
|
||||
async load ({ limit = 100, offset = 0 } = {}) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Notifications page requires a memo db client.')
|
||||
}
|
||||
|
||||
const myAddr = this.getMyAddress()
|
||||
if (!myAddr) {
|
||||
throw new Error('Notifications page requires an authenticated wallet.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getNotifications(myAddr, { limit, offset })
|
||||
this.notifications = data.notifications || []
|
||||
this.pagination = data.pagination || null
|
||||
this.empty = this.notifications.length === 0 && offset === 0
|
||||
|
||||
return {
|
||||
notifications: this.notifications,
|
||||
pagination: this.pagination,
|
||||
empty: this.empty
|
||||
}
|
||||
}
|
||||
|
||||
canLoadMore () {
|
||||
return this.pagination?.hasMore ?? false
|
||||
}
|
||||
|
||||
getNotification (txid) {
|
||||
return this.notifications.find((n) => n.txid === txid) || null
|
||||
}
|
||||
}
|
||||
|
||||
NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
|
||||
|
||||
module.exports = NotificationsPage
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
Unit tests for the notifications page controller.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const NotificationsPage = require('../../src/services/notifications-page')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
function makeWallet () {
|
||||
return {
|
||||
walletInfo: { cashAddress: MY_ADDRESS }
|
||||
}
|
||||
}
|
||||
|
||||
function makeMemoDb (notifications, pagination) {
|
||||
return {
|
||||
async getNotifications (addr, { limit, offset }) {
|
||||
return { notifications, pagination }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('load returns notifications', async () => {
|
||||
const notifications = [
|
||||
{ type: 'reply', txid: 'a'.repeat(64), addr: 'bitcoincash:other', text: 'hi' },
|
||||
{ type: 'like', txid: 'b'.repeat(64), addr: 'bitcoincash:other2' }
|
||||
]
|
||||
const page = new NotificationsPage({
|
||||
memoDb: makeMemoDb(notifications, { total: 2 }),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
|
||||
const result = await page.load()
|
||||
|
||||
assert.deepEqual(result.notifications, notifications)
|
||||
assert.equal(result.pagination.total, 2)
|
||||
assert.equal(result.empty, false)
|
||||
})
|
||||
|
||||
test('load marks empty notifications at offset zero', async () => {
|
||||
const page = new NotificationsPage({
|
||||
memoDb: makeMemoDb([], { total: 0 }),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
|
||||
const result = await page.load()
|
||||
|
||||
assert.deepEqual(result.notifications, [])
|
||||
assert.equal(result.empty, true)
|
||||
})
|
||||
|
||||
test('load does not mark empty paginated page as empty', async () => {
|
||||
const page = new NotificationsPage({
|
||||
memoDb: makeMemoDb([], { total: 2 }),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
|
||||
const result = await page.load({ offset: 100 })
|
||||
|
||||
assert.equal(result.empty, false)
|
||||
})
|
||||
|
||||
test('load forwards limit and offset to the memo db client', async () => {
|
||||
const calls = []
|
||||
const memoDb = {
|
||||
async getNotifications (addr, params) {
|
||||
calls.push({ addr, params })
|
||||
return { notifications: [], pagination: {} }
|
||||
}
|
||||
}
|
||||
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.load({ limit: 10, offset: 20 })
|
||||
|
||||
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }])
|
||||
})
|
||||
|
||||
test('load defaults limit to 100 and offset to 0', async () => {
|
||||
const calls = []
|
||||
const memoDb = {
|
||||
async getNotifications (addr, params) {
|
||||
calls.push({ addr, params })
|
||||
return { notifications: [], pagination: {} }
|
||||
}
|
||||
}
|
||||
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }])
|
||||
})
|
||||
|
||||
test('load throws when no memo db client is provided', async () => {
|
||||
const page = new NotificationsPage({ wallet: makeWallet() })
|
||||
|
||||
await assert.rejects(
|
||||
() => page.load(),
|
||||
/requires a memo db client/
|
||||
)
|
||||
})
|
||||
|
||||
test('load throws when no wallet is provided', async () => {
|
||||
const page = new NotificationsPage({ memoDb: makeMemoDb([], {}) })
|
||||
|
||||
await assert.rejects(
|
||||
() => page.load(),
|
||||
/requires an authenticated wallet/
|
||||
)
|
||||
})
|
||||
|
||||
test('canLoadMore reflects pagination.hasMore', async () => {
|
||||
const pageMore = new NotificationsPage({
|
||||
memoDb: makeMemoDb([], { hasMore: true }),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
await pageMore.load()
|
||||
assert.equal(pageMore.canLoadMore(), true)
|
||||
|
||||
const pageDone = new NotificationsPage({
|
||||
memoDb: makeMemoDb([], { hasMore: false }),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
await pageDone.load()
|
||||
assert.equal(pageDone.canLoadMore(), false)
|
||||
})
|
||||
|
||||
test('getNotification returns a loaded notification by txid', async () => {
|
||||
const notifications = [{ type: 'follow', txid: 'a'.repeat(64), addr: 'bitcoincash:other' }]
|
||||
const page = new NotificationsPage({
|
||||
memoDb: makeMemoDb(notifications, {}),
|
||||
wallet: makeWallet()
|
||||
})
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getNotification('a'.repeat(64)).type, 'follow')
|
||||
})
|
||||
|
||||
test('exposes the notifications path', () => {
|
||||
assert.equal(NotificationsPage.NOTIFICATIONS_PATH, '/notifications')
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import MuteQuery from './mute-query.js'
|
||||
import TopicQuery from './topic-query.js'
|
||||
import PollQuery from './poll-query.js'
|
||||
import SearchQuery from './search-query.js'
|
||||
import NotificationsQuery from './notifications-query.js'
|
||||
|
||||
class Adapters {
|
||||
constructor () {
|
||||
@@ -57,6 +58,14 @@ class Adapters {
|
||||
namesDb: level.namesDb,
|
||||
profilesDb: level.profilesDb
|
||||
})
|
||||
this.notificationsQuery = new NotificationsQuery({
|
||||
postsDb: level.postsDb,
|
||||
postParentsDb: level.postParentsDb,
|
||||
postChildrenDb: level.postChildrenDb,
|
||||
likesDb: level.likesDb,
|
||||
postLikesDb: level.postLikesDb,
|
||||
followsDb: level.followsDb
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Adapter for aggregating the viewer's notifications.
|
||||
|
||||
Notifications are read-only: the DB collects replies to the viewer's posts,
|
||||
likes on the viewer's posts, and new follows of the viewer, then returns them
|
||||
sorted newest-first with limit/offset pagination.
|
||||
*/
|
||||
|
||||
import BCHJS from '@psf/bch-js'
|
||||
|
||||
class NotificationsQuery {
|
||||
constructor (localConfig = {}) {
|
||||
const {
|
||||
postsDb,
|
||||
postParentsDb,
|
||||
postChildrenDb,
|
||||
likesDb,
|
||||
postLikesDb,
|
||||
followsDb,
|
||||
bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' })
|
||||
} = localConfig
|
||||
|
||||
if (!postsDb) {
|
||||
throw new Error('postsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postParentsDb) {
|
||||
throw new Error('postParentsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postChildrenDb) {
|
||||
throw new Error('postChildrenDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!likesDb) {
|
||||
throw new Error('likesDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!postLikesDb) {
|
||||
throw new Error('postLikesDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
if (!followsDb) {
|
||||
throw new Error('followsDb required when instantiating NotificationsQuery adapter.')
|
||||
}
|
||||
|
||||
this.postsDb = postsDb
|
||||
this.postParentsDb = postParentsDb
|
||||
this.postChildrenDb = postChildrenDb
|
||||
this.likesDb = likesDb
|
||||
this.postLikesDb = postLikesDb
|
||||
this.followsDb = followsDb
|
||||
this.bchjs = bchjs
|
||||
|
||||
this.listNotifications = this.listNotifications.bind(this)
|
||||
this._getPostOrNull = this._getPostOrNull.bind(this)
|
||||
this._collectFollowNotifications = this._collectFollowNotifications.bind(this)
|
||||
this._collectLikeNotifications = this._collectLikeNotifications.bind(this)
|
||||
this._collectReplyNotifications = this._collectReplyNotifications.bind(this)
|
||||
this._sortNotifications = this._sortNotifications.bind(this)
|
||||
}
|
||||
|
||||
async _getPostOrNull (txid) {
|
||||
try {
|
||||
return await this.postsDb.get(txid)
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Collect active follows where this address is the followee.
|
||||
async _collectFollowNotifications (addr) {
|
||||
const myHash160 = this.bchjs.Address.toHash160(addr)
|
||||
const notifications = []
|
||||
|
||||
for await (const [key, record] of this.followsDb.iterator()) {
|
||||
if (record.unfollow === true) continue
|
||||
if (record.followeePkHash !== myHash160) continue
|
||||
|
||||
const followerAddr = record.followerAddr || key.split(':')[0]
|
||||
if (followerAddr === addr) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'follow',
|
||||
txid: record.txid,
|
||||
addr: followerAddr,
|
||||
blockHeight: record.blockHeight ?? 0,
|
||||
seen: record.seen ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
// Collect likes on posts authored by this address, excluding self-likes.
|
||||
async _collectLikeNotifications (addr) {
|
||||
const notifications = []
|
||||
|
||||
for await (const [likeTxid, like] of this.likesDb.iterator()) {
|
||||
if (!like || like.addr === addr) continue
|
||||
|
||||
const post = await this._getPostOrNull(like.postTxid)
|
||||
if (!post || post.addr !== addr) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'like',
|
||||
txid: likeTxid,
|
||||
addr: like.addr,
|
||||
postTxid: like.postTxid,
|
||||
blockHeight: like.blockHeight ?? 0,
|
||||
seen: like.seen ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
// Collect replies to posts authored by this address, excluding own replies.
|
||||
async _collectReplyNotifications (addr) {
|
||||
const notifications = []
|
||||
|
||||
for await (const [, child] of this.postChildrenDb.iterator()) {
|
||||
const parentTxid = child?.parentTxid
|
||||
const childTxid = child?.childTxid
|
||||
if (!parentTxid || !childTxid) continue
|
||||
|
||||
const parent = await this._getPostOrNull(parentTxid)
|
||||
if (!parent || parent.addr !== addr) continue
|
||||
|
||||
const childPost = await this._getPostOrNull(childTxid)
|
||||
if (!childPost || childPost.addr === addr) continue
|
||||
|
||||
notifications.push({
|
||||
type: 'reply',
|
||||
txid: childTxid,
|
||||
addr: childPost.addr,
|
||||
postTxid: parentTxid,
|
||||
text: childPost.text,
|
||||
blockHeight: child.blockHeight ?? childPost.blockHeight ?? 0,
|
||||
seen: childPost.seen ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
_sortNotifications (notifications) {
|
||||
return notifications.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight
|
||||
return (b.seen ?? 0) - (a.seen ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
// Return paginated notifications for addr, sorted newest-first.
|
||||
async listNotifications (addr, { limit, offset } = {}) {
|
||||
const [follows, likes, replies] = await Promise.all([
|
||||
this._collectFollowNotifications(addr),
|
||||
this._collectLikeNotifications(addr),
|
||||
this._collectReplyNotifications(addr)
|
||||
])
|
||||
|
||||
const all = this._sortNotifications(follows.concat(likes).concat(replies))
|
||||
const total = all.length
|
||||
const page = all.slice(offset, offset + limit)
|
||||
|
||||
return { notifications: page, total }
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationsQuery
|
||||
@@ -18,6 +18,7 @@ class PostsRESTControllerLib {
|
||||
this.getRecentPosts = this.getRecentPosts.bind(this)
|
||||
this.getPostsByAddr = this.getPostsByAddr.bind(this)
|
||||
this.getFollowingFeed = this.getFollowingFeed.bind(this)
|
||||
this.getNotifications = this.getNotifications.bind(this)
|
||||
this.getPostThread = this.getPostThread.bind(this)
|
||||
this.runUseCase = this.runUseCase.bind(this)
|
||||
this.listPostsForAddr = this.listPostsForAddr.bind(this)
|
||||
@@ -145,6 +146,34 @@ class PostsRESTControllerLib {
|
||||
await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed)
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} /posts/notifications/:addr List notifications for an address
|
||||
* @apiPermission public
|
||||
* @apiName GetNotifications
|
||||
* @apiGroup REST Posts
|
||||
*
|
||||
* @apiDescription Returns replies to the viewer's posts, likes on the viewer's posts, and new follows of the viewer, sorted by block height (newest first).
|
||||
*
|
||||
* @apiParam {String} addr Viewer cash address
|
||||
* @apiQuery {Number} [limit=100] Page size (max 100)
|
||||
* @apiQuery {Number} [offset=0] Number of notifications to skip after sorting
|
||||
*
|
||||
* @apiExample Example usage:
|
||||
* curl -X GET "localhost:5021/posts/notifications/bitcoincash:q...?limit=50&offset=0"
|
||||
*
|
||||
* @apiSuccess {Object[]} notifications Array of notification objects
|
||||
* @apiSuccess {String} notifications.type One of reply, like, follow
|
||||
* @apiSuccess {String} notifications.txid Action transaction id
|
||||
* @apiSuccess {String} notifications.addr Actor cash address
|
||||
* @apiSuccess {String} [notifications.postTxid] Liked/replied post txid
|
||||
* @apiSuccess {String} [notifications.text] Reply text
|
||||
* @apiSuccess {Number} notifications.blockHeight Block height when indexed
|
||||
* @apiSuccess {Object} pagination Pagination metadata
|
||||
*/
|
||||
async getNotifications (ctx) {
|
||||
await this.listPostsForAddr(ctx, this.useCases.listNotifications)
|
||||
}
|
||||
|
||||
async getPostThread (ctx) {
|
||||
const { txid } = ctx.params
|
||||
await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({
|
||||
|
||||
@@ -27,6 +27,7 @@ class PostsRouter {
|
||||
this.router.get('/recent', this.postsRESTController.getRecentPosts)
|
||||
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
|
||||
this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed)
|
||||
this.router.get('/notifications/:addr', this.postsRESTController.getNotifications)
|
||||
this.router.get('/:txid/thread', this.postsRESTController.getPostThread)
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
|
||||
@@ -20,6 +20,7 @@ import GetPoll from './get-poll.js'
|
||||
import GetPollOptions from './get-poll-options.js'
|
||||
import GetPollVotes from './get-poll-votes.js'
|
||||
import SearchAll from './search-all.js'
|
||||
import ListNotifications from './list-notifications.js'
|
||||
|
||||
class UseCases {
|
||||
constructor (localConfig = {}) {
|
||||
@@ -49,6 +50,7 @@ class UseCases {
|
||||
this.getPollOptions = null
|
||||
this.getPollVotes = null
|
||||
this.searchAll = null
|
||||
this.listNotifications = null
|
||||
}
|
||||
|
||||
async start () {
|
||||
@@ -124,6 +126,10 @@ class UseCases {
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.listNotifications = new ListNotifications({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
console.log('Use cases initialized.')
|
||||
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Use case: list notifications for a viewer, newest first.
|
||||
|
||||
Aggregates replies to the viewer's posts, likes on the viewer's posts, and
|
||||
follows of the viewer, then paginates the combined result.
|
||||
*/
|
||||
|
||||
import { parseLimit, parseOffset, parseRequiredString } from './lib/pagination.js'
|
||||
import { ListUseCase } from './lib/use-case.js'
|
||||
|
||||
class ListNotifications extends ListUseCase {
|
||||
constructor (localConfig = {}) {
|
||||
super(localConfig, { useCaseName: 'ListNotifications', adapterName: 'notificationsQuery' })
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const addr = parseRequiredString(inObj.addr, 'addr')
|
||||
const limit = parseLimit(inObj.limit)
|
||||
const offset = parseOffset(inObj.offset)
|
||||
|
||||
const { notifications, total } = await this.adapters.notificationsQuery.listNotifications(addr, { limit, offset })
|
||||
|
||||
return {
|
||||
notifications,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + notifications.length < total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListNotifications
|
||||
@@ -0,0 +1,251 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import NotificationsQuery from '../../../src/adapters/notifications-query.js'
|
||||
|
||||
describe('#NotificationsQuery', () => {
|
||||
let sandbox
|
||||
let postsDb
|
||||
let postChildrenDb
|
||||
let likesDb
|
||||
let followsDb
|
||||
let bchjs
|
||||
let uut
|
||||
|
||||
const MY_ADDR = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
|
||||
const THEIR_ADDR = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const MY_HASH160 = 'myhash160'
|
||||
const THEIR_HASH160 = 'theirhash160'
|
||||
|
||||
function makeIterator (items) {
|
||||
return (async function * () {
|
||||
for (const item of items) yield item
|
||||
}())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
postsDb = { get: sandbox.stub() }
|
||||
postChildrenDb = { iterator: sandbox.stub() }
|
||||
likesDb = { iterator: sandbox.stub() }
|
||||
followsDb = { iterator: sandbox.stub() }
|
||||
|
||||
bchjs = {
|
||||
Address: {
|
||||
toHash160: sandbox.stub()
|
||||
}
|
||||
}
|
||||
|
||||
bchjs.Address.toHash160.withArgs(MY_ADDR).returns(MY_HASH160)
|
||||
bchjs.Address.toHash160.withArgs(THEIR_ADDR).returns(THEIR_HASH160)
|
||||
|
||||
uut = new NotificationsQuery({
|
||||
postsDb,
|
||||
postParentsDb: {},
|
||||
postChildrenDb,
|
||||
likesDb,
|
||||
postLikesDb: {},
|
||||
followsDb,
|
||||
bchjs
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should throw when postsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postChildrenDb, likesDb, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postsDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when postChildrenDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, likesDb, postLikesDb: {}, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postChildrenDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when likesDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, postLikesDb: {}, followsDb, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'likesDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when followsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, postLikesDb: {}, bchjs })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'followsDb required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should include a reply to my post', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'nice post', blockHeight: 200 })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.notifications.length, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'reply')
|
||||
assert.equal(n.txid, replyTxid)
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
assert.equal(n.postTxid, myPostTxid)
|
||||
assert.equal(n.text, 'nice post')
|
||||
})
|
||||
|
||||
it('should include a like on my post', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const likeTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'like')
|
||||
assert.equal(n.txid, likeTxid)
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
assert.equal(n.postTxid, myPostTxid)
|
||||
})
|
||||
|
||||
it('should include a follow of me', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
const n = result.notifications[0]
|
||||
assert.equal(n.type, 'follow')
|
||||
assert.equal(n.txid, 'c'.repeat(64))
|
||||
assert.equal(n.addr, THEIR_ADDR)
|
||||
})
|
||||
|
||||
it('should exclude my own replies', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: MY_ADDR, text: 'my own reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude replies to posts by other people', async () => {
|
||||
const theirPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${theirPostTxid}:${replyTxid}`, { parentTxid: theirPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(theirPostTxid).resolves({ addr: THEIR_ADDR, text: 'alice post' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: 'bitcoincash:other', text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should exclude unfollows', async () => {
|
||||
postChildrenDb.iterator.returns(makeIterator([]))
|
||||
likesDb.iterator.returns(makeIterator([]))
|
||||
followsDb.iterator.returns(makeIterator([
|
||||
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: true, txid: 'c'.repeat(64), blockHeight: 150 }]
|
||||
]))
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 0)
|
||||
})
|
||||
|
||||
it('should sort notifications by block height descending', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(result.notifications[0].type, 'like')
|
||||
assert.equal(result.notifications[1].type, 'reply')
|
||||
})
|
||||
|
||||
it('should paginate notifications', async () => {
|
||||
const myPostTxid = 'a'.repeat(64)
|
||||
const replyTxid = 'b'.repeat(64)
|
||||
const likeTxid = 'c'.repeat(64)
|
||||
|
||||
postChildrenDb.iterator.returns(makeIterator([
|
||||
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
|
||||
]))
|
||||
likesDb.iterator.returns(makeIterator([
|
||||
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
|
||||
]))
|
||||
followsDb.iterator.returns(makeIterator([]))
|
||||
|
||||
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
|
||||
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
|
||||
|
||||
const result = await uut.listNotifications(MY_ADDR, { limit: 1, offset: 0 })
|
||||
|
||||
assert.equal(result.total, 2)
|
||||
assert.equal(result.notifications.length, 1)
|
||||
assert.equal(result.notifications[0].type, 'like')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,12 @@ describe('#PostsRESTController', () => {
|
||||
posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }],
|
||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
||||
})
|
||||
},
|
||||
listNotifications: {
|
||||
execute: sandbox.stub().resolves({
|
||||
notifications: [{ type: 'like', txid: 'tx5', addr: 'addr-c', blockHeight: 600300 }],
|
||||
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -120,6 +126,25 @@ describe('#PostsRESTController', () => {
|
||||
assert.include(ctx.throw.firstCall.args[1], 'boom')
|
||||
})
|
||||
|
||||
it('should return notifications from use case', async () => {
|
||||
const ctx = {
|
||||
params: { addr: 'addr-c' },
|
||||
query: { limit: '25', offset: '0' },
|
||||
body: null,
|
||||
throw: sandbox.stub()
|
||||
}
|
||||
await uut.getNotifications(ctx)
|
||||
|
||||
assert.equal(uut.useCases.listNotifications.execute.callCount, 1)
|
||||
assert.deepEqual(uut.useCases.listNotifications.execute.firstCall.args[0], {
|
||||
addr: 'addr-c',
|
||||
limit: '25',
|
||||
offset: '0'
|
||||
})
|
||||
assert.equal(ctx.body.notifications.length, 1)
|
||||
assert.equal(ctx.body.notifications[0].type, 'like')
|
||||
})
|
||||
|
||||
it('should return a post thread from use case', async () => {
|
||||
const ctx = {
|
||||
params: { txid: 'tx4' },
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('#UseCases', () => {
|
||||
},
|
||||
topicQuery: {},
|
||||
searchQuery: {},
|
||||
notificationsQuery: {},
|
||||
pollQuery: {}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +56,7 @@ describe('#UseCases', () => {
|
||||
assert.isNotNull(uut.getPollOptions)
|
||||
assert.isNotNull(uut.getPollVotes)
|
||||
assert.isNotNull(uut.searchAll)
|
||||
assert.isNotNull(uut.listNotifications)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import ListNotifications from '../../../src/use-cases/list-notifications.js'
|
||||
|
||||
describe('#ListNotifications', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
let notificationsQuery
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
notificationsQuery = {
|
||||
listNotifications: sandbox.stub().resolves({
|
||||
notifications: [{ type: 'follow', txid: 'tx1', addr: 'addr-a' }],
|
||||
total: 1
|
||||
})
|
||||
}
|
||||
uut = new ListNotifications({ adapters: { notificationsQuery } })
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should throw when adapters are missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new ListNotifications({})
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'Adapters required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when notificationsQuery adapter is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new ListNotifications({ adapters: {} })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'notificationsQuery adapter required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a missing addr', async () => {
|
||||
try {
|
||||
await uut.execute({})
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'addr is required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass addr, limit, and offset to the query adapter', async () => {
|
||||
await uut.execute({ addr: 'addr-a', limit: '10', offset: '5' })
|
||||
|
||||
assert.equal(notificationsQuery.listNotifications.callCount, 1)
|
||||
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[0], 'addr-a')
|
||||
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 10, offset: 5 })
|
||||
})
|
||||
|
||||
it('should default limit and offset', async () => {
|
||||
await uut.execute({ addr: 'addr-a' })
|
||||
|
||||
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 100, offset: 0 })
|
||||
})
|
||||
|
||||
it('should reject limit over 100', async () => {
|
||||
try {
|
||||
await uut.execute({ addr: 'addr-a', limit: '101' })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit cannot exceed')
|
||||
}
|
||||
})
|
||||
|
||||
it('should attach pagination metadata', async () => {
|
||||
notificationsQuery.listNotifications.resolves({
|
||||
notifications: [{ type: 'like', txid: 'tx1', addr: 'addr-a' }],
|
||||
total: 2
|
||||
})
|
||||
|
||||
const result = await uut.execute({ addr: 'addr-a', limit: '1', offset: '0' })
|
||||
|
||||
assert.equal(result.notifications.length, 1)
|
||||
assert.equal(result.pagination.limit, 1)
|
||||
assert.equal(result.pagination.offset, 0)
|
||||
assert.equal(result.pagination.total, 2)
|
||||
assert.equal(result.pagination.hasMore, true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user