import test from 'brittle'
import { Feed } from '../lib/feed.js'
import { tmpdir } from 'os'
import { join } from 'path'
import { randomBytes } from 'crypto'
function tmpStorage () {
return join(tmpdir(), 'hyper-feed-test-' + randomBytes(8).toString('hex'))
}
test('Feed - ready initializes without error', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
t.pass('feed ready')
await feed.close()
})
test('Feed - add and get item', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const item = await feed.add({
source: 'irc',
author: 'alice',
authorId: 'alice@irc',
text: 'Hello from IRC!',
type: 'post',
tags: ['general']
})
t.ok(item.id)
t.is(item.source, 'irc')
t.is(item.author, 'alice')
t.is(item.authorId, 'alice@irc')
t.is(item.text, 'Hello from IRC!')
t.is(item.type, 'post')
t.alike(item.tags, ['general'])
t.ok(item.timestamp > 0)
const fetched = await feed.get(item.id)
t.is(fetched.text, 'Hello from IRC!')
t.is(fetched.id, item.id)
await feed.close()
})
test('Feed - add with defaults', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const item = await feed.add({ text: 'Minimal' })
t.is(item.source, 'keet')
t.is(item.author, 'unknown')
t.is(item.type, 'post')
t.alike(item.tags, [])
t.is(item.url, null)
await feed.close()
})
test('Feed - add with custom id', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const item = await feed.add({ id: 'custom123', text: 'Custom ID' })
t.is(item.id, 'custom123')
const fetched = await feed.get('custom123')
t.is(fetched.text, 'Custom ID')
await feed.close()
})
test('Feed - get nonexistent returns null', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const result = await feed.get('nonexistent')
t.is(result, null)
await feed.close()
})
test('Feed - remove item cleans all indexes', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const item = await feed.add({
source: 'mastodon',
author: 'bob',
authorId: 'bob@mastodon',
text: 'Remove me',
tags: ['test']
})
// Verify it exists in all views
t.ok(await feed.get(item.id))
const tl = await feed.timeline()
t.is(tl.length, 1)
const src = await feed.bySource('mastodon')
t.is(src.length, 1)
const auth = await feed.byAuthor('bob@mastodon')
t.is(auth.length, 1)
// Remove it
const ok = await feed.remove(item.id)
t.ok(ok)
// Verify cleaned from all indexes
t.is(await feed.get(item.id), null)
const tl2 = await feed.timeline()
t.is(tl2.length, 0)
const src2 = await feed.bySource('mastodon')
t.is(src2.length, 0)
const auth2 = await feed.byAuthor('bob@mastodon')
t.is(auth2.length, 0)
await feed.close()
})
test('Feed - remove nonexistent returns false', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const ok = await feed.remove('nonexistent')
t.is(ok, false)
await feed.close()
})
test('Feed - timeline returns items in order', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'First', timestamp: base })
await feed.add({ text: 'Second', timestamp: base + 1000 })
await feed.add({ text: 'Third', timestamp: base + 2000 })
const tl = await feed.timeline()
t.is(tl.length, 3)
t.is(tl[0].text, 'First')
t.is(tl[1].text, 'Second')
t.is(tl[2].text, 'Third')
await feed.close()
})
test('Feed - timeline with limit', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', timestamp: base })
await feed.add({ text: 'B', timestamp: base + 1000 })
await feed.add({ text: 'C', timestamp: base + 2000 })
const tl = await feed.timeline({ limit: 2 })
t.is(tl.length, 2)
t.is(tl[0].text, 'A')
t.is(tl[1].text, 'B')
await feed.close()
})
test('Feed - timeline with before', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', timestamp: base })
await feed.add({ text: 'B', timestamp: base + 1000 })
await feed.add({ text: 'C', timestamp: base + 2000 })
const tl = await feed.timeline({ before: base + 1500 })
t.is(tl.length, 2)
t.is(tl[0].text, 'A')
t.is(tl[1].text, 'B')
await feed.close()
})
test('Feed - timeline with after', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', timestamp: base })
await feed.add({ text: 'B', timestamp: base + 1000 })
await feed.add({ text: 'C', timestamp: base + 2000 })
const tl = await feed.timeline({ after: base + 500 })
t.is(tl.length, 2)
t.is(tl[0].text, 'B')
t.is(tl[1].text, 'C')
await feed.close()
})
test('Feed - timeline with before and after', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', timestamp: base })
await feed.add({ text: 'B', timestamp: base + 1000 })
await feed.add({ text: 'C', timestamp: base + 2000 })
await feed.add({ text: 'D', timestamp: base + 3000 })
const tl = await feed.timeline({ after: base + 500, before: base + 2500 })
t.is(tl.length, 2)
t.is(tl[0].text, 'B')
t.is(tl[1].text, 'C')
await feed.close()
})
test('Feed - timeline filtered by source', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'IRC msg', source: 'irc', timestamp: base })
await feed.add({ text: 'Keet msg', source: 'keet', timestamp: base + 1000 })
await feed.add({ text: 'IRC msg 2', source: 'irc', timestamp: base + 2000 })
const tl = await feed.timeline({ source: 'irc' })
t.is(tl.length, 2)
t.is(tl[0].text, 'IRC msg')
t.is(tl[1].text, 'IRC msg 2')
await feed.close()
})
test('Feed - timeline filtered by type', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'Post', type: 'post', timestamp: base })
await feed.add({ text: 'Reply', type: 'reply', timestamp: base + 1000 })
await feed.add({ text: 'Boost', type: 'boost', timestamp: base + 2000 })
const tl = await feed.timeline({ type: 'reply' })
t.is(tl.length, 1)
t.is(tl[0].text, 'Reply')
await feed.close()
})
test('Feed - bySource returns items from one source', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'IRC 1', source: 'irc', timestamp: base })
await feed.add({ text: 'Keet 1', source: 'keet', timestamp: base + 1000 })
await feed.add({ text: 'IRC 2', source: 'irc', timestamp: base + 2000 })
await feed.add({ text: 'Discord 1', source: 'discord', timestamp: base + 3000 })
const irc = await feed.bySource('irc')
t.is(irc.length, 2)
t.is(irc[0].text, 'IRC 1')
t.is(irc[1].text, 'IRC 2')
const discord = await feed.bySource('discord')
t.is(discord.length, 1)
t.is(discord[0].text, 'Discord 1')
await feed.close()
})
test('Feed - bySource with limit', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', source: 'irc', timestamp: base })
await feed.add({ text: 'B', source: 'irc', timestamp: base + 1000 })
await feed.add({ text: 'C', source: 'irc', timestamp: base + 2000 })
const items = await feed.bySource('irc', { limit: 2 })
t.is(items.length, 2)
t.is(items[0].text, 'A')
t.is(items[1].text, 'B')
await feed.close()
})
test('Feed - bySource with before', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', source: 'irc', timestamp: base })
await feed.add({ text: 'B', source: 'irc', timestamp: base + 1000 })
await feed.add({ text: 'C', source: 'irc', timestamp: base + 2000 })
const items = await feed.bySource('irc', { before: base + 1500 })
t.is(items.length, 2)
t.is(items[0].text, 'A')
t.is(items[1].text, 'B')
await feed.close()
})
test('Feed - byAuthor returns items from one author', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'Alice 1', author: 'Alice', authorId: 'alice@irc', timestamp: base })
await feed.add({ text: 'Bob 1', author: 'Bob', authorId: 'bob@keet', timestamp: base + 1000 })
await feed.add({ text: 'Alice 2', author: 'Alice', authorId: 'alice@irc', timestamp: base + 2000 })
const alice = await feed.byAuthor('alice@irc')
t.is(alice.length, 2)
t.is(alice[0].text, 'Alice 1')
t.is(alice[1].text, 'Alice 2')
const bob = await feed.byAuthor('bob@keet')
t.is(bob.length, 1)
t.is(bob[0].text, 'Bob 1')
await feed.close()
})
test('Feed - byAuthor with limit', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'A', authorId: 'alice', timestamp: base })
await feed.add({ text: 'B', authorId: 'alice', timestamp: base + 1000 })
await feed.add({ text: 'C', authorId: 'alice', timestamp: base + 2000 })
const items = await feed.byAuthor('alice', { limit: 1 })
t.is(items.length, 1)
t.is(items[0].text, 'A')
await feed.close()
})
test('Feed - search by text', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
await feed.add({ text: 'Hello world from IRC' })
await feed.add({ text: 'Goodbye moon from Keet' })
await feed.add({ text: 'Hello again from Mastodon' })
const results = await feed.search('hello')
t.is(results.length, 2)
await feed.close()
})
test('Feed - search by tags', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
await feed.add({ text: 'Post about cats', tags: ['animals', 'cats'] })
await feed.add({ text: 'Post about dogs', tags: ['animals', 'dogs'] })
await feed.add({ text: 'Post about code', tags: ['programming'] })
const results = await feed.search('cats')
t.is(results.length, 1) // "Post about cats" matches text, tag also matches but same item
const results2 = await feed.search('programming')
t.is(results2.length, 1)
await feed.close()
})
test('Feed - search case insensitive', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
await feed.add({ text: 'HELLO WORLD' })
await feed.add({ text: 'hello world' })
await feed.add({ text: 'Hello World' })
const results = await feed.search('hello')
t.is(results.length, 3)
await feed.close()
})
test('Feed - search no matches', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
await feed.add({ text: 'Something else' })
const results = await feed.search('nonexistent')
t.is(results.length, 0)
await feed.close()
})
test('Feed - stats counts by source and type', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
await feed.add({ text: 'A', source: 'irc', type: 'post' })
await feed.add({ text: 'B', source: 'irc', type: 'reply' })
await feed.add({ text: 'C', source: 'keet', type: 'post' })
await feed.add({ text: 'D', source: 'mastodon', type: 'boost' })
await feed.add({ text: 'E', source: 'irc', type: 'post' })
const stats = await feed.stats()
t.is(stats.total, 5)
t.is(stats.bySource.irc, 3)
t.is(stats.bySource.keet, 1)
t.is(stats.bySource.mastodon, 1)
t.is(stats.byType.post, 3)
t.is(stats.byType.reply, 1)
t.is(stats.byType.boost, 1)
await feed.close()
})
test('Feed - stats empty', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const stats = await feed.stats()
t.is(stats.total, 0)
t.alike(stats.bySource, {})
t.alike(stats.byType, {})
await feed.close()
})
test('Feed - multiple sources interleaved in timeline', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
await feed.add({ text: 'IRC first', source: 'irc', timestamp: base })
await feed.add({ text: 'Keet first', source: 'keet', timestamp: base + 100 })
await feed.add({ text: 'Discord first', source: 'discord', timestamp: base + 200 })
await feed.add({ text: 'IRC second', source: 'irc', timestamp: base + 300 })
await feed.add({ text: 'Mastodon first', source: 'mastodon', timestamp: base + 400 })
await feed.add({ text: 'Keet second', source: 'keet', timestamp: base + 500 })
await feed.add({ text: 'Email first', source: 'email', timestamp: base + 600 })
// Full timeline is interleaved across all sources
const tl = await feed.timeline()
t.is(tl.length, 7)
t.is(tl[0].text, 'IRC first')
t.is(tl[0].source, 'irc')
t.is(tl[1].source, 'keet')
t.is(tl[2].source, 'discord')
t.is(tl[3].source, 'irc')
t.is(tl[4].source, 'mastodon')
t.is(tl[5].source, 'keet')
t.is(tl[6].source, 'email')
// Filter just irc from the interleaved timeline
const irc = await feed.timeline({ source: 'irc' })
t.is(irc.length, 2)
t.is(irc[0].text, 'IRC first')
t.is(irc[1].text, 'IRC second')
await feed.close()
})
test('Feed - item with url and metadata', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const item = await feed.add({
source: 'mastodon',
author: 'user@instance.social',
authorId: 'user@instance.social',
text: 'Check out this link!',
url: 'https://instance.social/@user/123',
metadata: {
visibility: 'public',
reblogsCount: 42,
favouritesCount: 7
}
})
t.is(item.url, 'https://instance.social/@user/123')
t.is(item.metadata.visibility, 'public')
t.is(item.metadata.reblogsCount, 42)
const fetched = await feed.get(item.id)
t.is(fetched.url, 'https://instance.social/@user/123')
t.is(fetched.metadata.favouritesCount, 7)
await feed.close()
})
test('Feed - persistence across close/reopen', async (t) => {
const storage = tmpStorage()
// Create and populate
const f1 = new Feed(storage)
await f1.ready()
await f1.add({ text: 'Persistent item', source: 'irc', authorId: 'alice' })
await f1.close()
// Reopen and verify
const f2 = new Feed(storage)
await f2.ready()
const tl = await f2.timeline()
t.is(tl.length, 1)
t.is(tl[0].text, 'Persistent item')
const irc = await f2.bySource('irc')
t.is(irc.length, 1)
const alice = await f2.byAuthor('alice')
t.is(alice.length, 1)
await f2.close()
})
test('Feed - remove then re-add does not leave ghosts', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
const item = await feed.add({ text: 'Ghost?', source: 'keet', authorId: 'ghost', timestamp: base })
await feed.remove(item.id)
// Add a new item — timeline should only have the new one
await feed.add({ text: 'Not a ghost', source: 'keet', authorId: 'ghost', timestamp: base + 1000 })
const tl = await feed.timeline()
t.is(tl.length, 1)
t.is(tl[0].text, 'Not a ghost')
const src = await feed.bySource('keet')
t.is(src.length, 1)
const auth = await feed.byAuthor('ghost')
t.is(auth.length, 1)
await feed.close()
})
test('Feed - many items pagination', async (t) => {
const feed = new Feed(tmpStorage())
await feed.ready()
const base = Date.now()
// Add 20 items
for (let i = 0; i < 20; i++) {
await feed.add({ text: `Item ${i}`, timestamp: base + i * 100 })
}
// Page 1: first 5
const page1 = await feed.timeline({ limit: 5 })
t.is(page1.length, 5)
t.is(page1[0].text, 'Item 0')
t.is(page1[4].text, 'Item 4')
// Page 2: next 5 after page 1
const lastTs = page1[page1.length - 1].timestamp
const page2 = await feed.timeline({ limit: 5, after: lastTs })
t.is(page2.length, 5)
t.is(page2[0].text, 'Item 5')
t.is(page2[4].text, 'Item 9')
await feed.close()
})