mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
import { assert } from 'chai'
|
|
import sinon from 'sinon'
|
|
import TopicQuery from '../../../src/adapters/topic-query.js'
|
|
|
|
function makeRoomsDb (records = {}) {
|
|
const store = new Map(Object.entries(records))
|
|
return {
|
|
async get (key) {
|
|
if (!store.has(key)) {
|
|
const err = new Error('not found')
|
|
err.notFound = true
|
|
throw err
|
|
}
|
|
return store.get(key)
|
|
},
|
|
iterator (opts = {}) {
|
|
const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
|
const { gte, lte } = opts
|
|
const filtered = entries.filter(([key]) => {
|
|
if (gte && key < gte) return false
|
|
if (lte && key > lte) return false
|
|
return true
|
|
})
|
|
let i = 0
|
|
return {
|
|
[Symbol.asyncIterator] () {
|
|
return this
|
|
},
|
|
async next () {
|
|
if (i >= filtered.length) return { value: undefined, done: true }
|
|
const entry = filtered[i++]
|
|
return { value: entry, done: false }
|
|
},
|
|
async close () {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('#TopicQuery', () => {
|
|
let uut
|
|
let sandbox
|
|
let roomsDb
|
|
let postsDb
|
|
|
|
beforeEach(() => {
|
|
sandbox = sinon.createSandbox()
|
|
roomsDb = {
|
|
iterator: sandbox.stub(),
|
|
get: sandbox.stub()
|
|
}
|
|
postsDb = {
|
|
get: sandbox.stub()
|
|
}
|
|
|
|
uut = new TopicQuery({ roomsDb, postsDb })
|
|
})
|
|
|
|
afterEach(() => sandbox.restore())
|
|
|
|
it('should throw when roomsDb is missing', () => {
|
|
try {
|
|
// eslint-disable-next-line no-new
|
|
new TopicQuery({ postsDb })
|
|
assert.fail('Expected error')
|
|
} catch (err) {
|
|
assert.include(err.message, 'roomsDb required')
|
|
}
|
|
})
|
|
|
|
it('should throw when postsDb is missing', () => {
|
|
try {
|
|
// eslint-disable-next-line no-new
|
|
new TopicQuery({ roomsDb })
|
|
assert.fail('Expected error')
|
|
} catch (err) {
|
|
assert.include(err.message, 'postsDb required')
|
|
}
|
|
})
|
|
|
|
describe('#roomFromKey', () => {
|
|
it('should return the room from the value when present', () => {
|
|
assert.equal(uut.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin')
|
|
})
|
|
|
|
it('should fall back to the first segment of the key', () => {
|
|
assert.equal(uut.roomFromKey('cash:post-1', {}), 'cash')
|
|
})
|
|
})
|
|
|
|
describe('#txidFromKey', () => {
|
|
it('should return the last segment of the key', () => {
|
|
assert.equal(uut.txidFromKey('bitcoin:post-300'), 'post-300')
|
|
})
|
|
})
|
|
|
|
describe('#listTopics', () => {
|
|
it('should return distinct topics with post counts', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
|
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
|
yield ['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 }]
|
|
yield ['dev:post-100', { room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }]
|
|
yield ['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }]
|
|
}
|
|
roomsDb.iterator.returns(mockRooms())
|
|
|
|
const result = await uut.listTopics()
|
|
|
|
assert.deepEqual(result, [
|
|
{ room: 'bitcoin', postCount: 2 },
|
|
{ room: 'cash', postCount: 1 },
|
|
{ room: 'dev', postCount: 1 },
|
|
{ room: 'lone', postCount: 0 }
|
|
])
|
|
})
|
|
|
|
it('should sort topics by most recent post descending', async () => {
|
|
async function * mockRooms () {
|
|
yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
|
yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 2 }]
|
|
}
|
|
roomsDb.iterator.returns(mockRooms())
|
|
|
|
const result = await uut.listTopics()
|
|
|
|
assert.deepEqual(result.map((t) => t.room), ['alpha', 'zoo'])
|
|
})
|
|
|
|
it('should treat a post at block height 0 as the least recent', async () => {
|
|
async function * mockRooms () {
|
|
yield ['a:post-1', { room: 'a', txid: 'post-1', type: 'post', blockHeight: 0 }]
|
|
yield ['b:post-1', { room: 'b', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
|
}
|
|
roomsDb.iterator.returns(mockRooms())
|
|
|
|
const result = await uut.listTopics()
|
|
|
|
assert.deepEqual(result.map((t) => t.room), ['b', 'a'])
|
|
})
|
|
|
|
it('should treat a post with no block height as height 0', async () => {
|
|
async function * mockRooms () {
|
|
yield ['a:post-1', { room: 'a', txid: 'post-1', type: 'post' }]
|
|
yield ['b:post-1', { room: 'b', txid: 'post-1', type: 'post', blockHeight: 1 }]
|
|
}
|
|
roomsDb.iterator.returns(mockRooms())
|
|
|
|
const result = await uut.listTopics()
|
|
|
|
assert.deepEqual(result.map((t) => t.room), ['b', 'a'])
|
|
})
|
|
})
|
|
|
|
describe('#getTopicPostTxids', () => {
|
|
it('should return txids for a topic sorted by block height descending', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
|
yield ['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 })
|
|
|
|
assert.deepEqual(result.txids, ['post-300', 'post-200'])
|
|
assert.equal(result.total, 2)
|
|
})
|
|
|
|
it('should paginate topic posts', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
|
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 1, offset: 0 })
|
|
|
|
assert.deepEqual(result.txids, ['post-300'])
|
|
assert.equal(result.total, 2)
|
|
})
|
|
|
|
it('should apply offset', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
|
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 1 })
|
|
|
|
assert.deepEqual(result.txids, ['post-200'])
|
|
assert.equal(result.total, 2)
|
|
})
|
|
|
|
it('should return empty result for a topic with no posts', async () => {
|
|
async function * empty () {}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'lone:', lte: 'lone:\uffff' }))
|
|
.returns(empty())
|
|
|
|
const result = await uut.getTopicPostTxids('lone', { limit: 100, offset: 0 })
|
|
|
|
assert.deepEqual(result.txids, [])
|
|
assert.equal(result.total, 0)
|
|
})
|
|
|
|
it('should ignore entries that are not posts', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 })
|
|
|
|
assert.deepEqual(result.txids, [])
|
|
assert.equal(result.total, 0)
|
|
})
|
|
|
|
it('should fall back to the key txid when the value has no string txid', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', type: 'post', blockHeight: 300 }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 })
|
|
|
|
assert.deepEqual(result.txids, ['post-300'])
|
|
assert.equal(result.total, 1)
|
|
})
|
|
|
|
it('should exclude posts from muted addresses when a viewer is provided', async () => {
|
|
async function * mockRooms () {
|
|
yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }]
|
|
yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }]
|
|
}
|
|
roomsDb.iterator
|
|
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
|
|
.returns(mockRooms())
|
|
postsDb.get.callsFake(async (txid) => {
|
|
if (txid === 'post-300') return { addr: 'muted-addr', text: 'x', blockHeight: 300 }
|
|
if (txid === 'post-200') return { addr: 'other-addr', text: 'x', blockHeight: 200 }
|
|
const err = new Error('not found')
|
|
err.notFound = true
|
|
throw err
|
|
})
|
|
|
|
const muteQuery = {
|
|
listMuted: sandbox.stub().resolves(['muted-addr'])
|
|
}
|
|
uut = new TopicQuery({ roomsDb, postsDb, muteQuery })
|
|
|
|
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0, viewerAddr: 'viewer-addr' })
|
|
|
|
assert.deepEqual(result.txids, ['post-200'])
|
|
assert.equal(result.total, 1)
|
|
assert.isTrue(muteQuery.listMuted.calledOnceWith('viewer-addr'))
|
|
})
|
|
})
|
|
|
|
describe('#isFollowingRoom', () => {
|
|
it('should return false when no follow record exists', async () => {
|
|
const err = new Error('not found')
|
|
err.notFound = true
|
|
roomsDb.get.withArgs('bitcoin:addr-x').rejects(err)
|
|
|
|
const result = await uut.isFollowingRoom('addr-x', 'bitcoin')
|
|
assert.equal(result, false)
|
|
})
|
|
|
|
it('should return true for an active follow record', async () => {
|
|
roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false })
|
|
|
|
const result = await uut.isFollowingRoom('addr-a', 'bitcoin')
|
|
assert.equal(result, true)
|
|
})
|
|
|
|
it('should return false for an unfollow record', async () => {
|
|
roomsDb.get.withArgs('bitcoin:addr-c').resolves({ room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true })
|
|
|
|
const result = await uut.isFollowingRoom('addr-c', 'bitcoin')
|
|
assert.equal(result, false)
|
|
})
|
|
|
|
it('should return false for a non-follow record', async () => {
|
|
roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', txid: 'post-1', type: 'post' })
|
|
|
|
const result = await uut.isFollowingRoom('addr-a', 'bitcoin')
|
|
assert.equal(result, false)
|
|
})
|
|
|
|
it('should rethrow non-not-found errors', async () => {
|
|
roomsDb.get.withArgs('bitcoin:addr-a').rejects(new Error('db down'))
|
|
|
|
try {
|
|
await uut.isFollowingRoom('addr-a', 'bitcoin')
|
|
assert.fail('Expected error')
|
|
} catch (err) {
|
|
assert.include(err.message, 'db down')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('#listRoomFollowers', () => {
|
|
it('should return active followers for a room', async () => {
|
|
const query = new TopicQuery({
|
|
roomsDb: makeRoomsDb({
|
|
'bitcoin:addr-a': { room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false },
|
|
'bitcoin:addr-b': { room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: false },
|
|
'bitcoin:addr-c': { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true },
|
|
'cash:addr-a': { room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false }
|
|
}),
|
|
postsDb
|
|
})
|
|
|
|
const result = await query.listRoomFollowers('bitcoin')
|
|
assert.deepEqual(result, ['addr-a', 'addr-b'])
|
|
})
|
|
|
|
it('should fall back to the key address when the value has no addr', async () => {
|
|
const query = new TopicQuery({
|
|
roomsDb: makeRoomsDb({
|
|
'bitcoin:addr-a': { room: 'bitcoin', type: 'follow', unfollow: false }
|
|
}),
|
|
postsDb
|
|
})
|
|
|
|
const result = await query.listRoomFollowers('bitcoin')
|
|
assert.deepEqual(result, ['addr-a'])
|
|
})
|
|
|
|
it('should return an empty array for a room with no followers', async () => {
|
|
const query = new TopicQuery({
|
|
roomsDb: makeRoomsDb({}),
|
|
postsDb
|
|
})
|
|
|
|
const result = await query.listRoomFollowers('lone')
|
|
assert.deepEqual(result, [])
|
|
})
|
|
})
|
|
|
|
describe('#followAddrFromValue', () => {
|
|
it('should return the addr from the value when present', () => {
|
|
const result = uut.followAddrFromValue({ addr: 'addr-a' }, 'bitcoin:addr-a')
|
|
assert.equal(result, 'addr-a')
|
|
})
|
|
|
|
it('should fall back to the last key segment when the value has no addr', () => {
|
|
const result = uut.followAddrFromValue({ room: 'bitcoin', type: 'follow' }, 'bitcoin:addr-a')
|
|
assert.equal(result, 'addr-a')
|
|
})
|
|
|
|
it('should return null when the key has no address segment', () => {
|
|
const result = uut.followAddrFromValue({ room: 'lone', type: 'follow' }, 'lone')
|
|
assert.equal(result, null)
|
|
})
|
|
})
|
|
})
|