feat(feed): relay body storage + HTTP endpoints (Phase B of v2.0.0)
Phase A (the previous commit) added the on-chain foundations. Phase B
is the off-chain layer: post bodies live in a BadgerDB-backed feed
mailbox, and a full HTTP surface makes the feed usable from clients.
New components
relay/feed_mailbox.go (+ tests)
- FeedPost: body + content-type + attachment + hashtags + thread refs
- Store / Get / Delete with TTL-bounded eviction (30 days default)
- View counter (IncrementView / ViewCount) — off-chain because one
tx per view would be nonsense
- Hashtag inverted index: auto-extracts #tokens from content on
Store, lowercased + deduped + capped at 8/post
- Author chrono index: PostsByAuthor returns newest-first IDs
- RecentPostIDs: scan-by-age helper used by trending/foryou
node/api_feed.go
POST /feed/publish — author-signed body upload, returns
post_id + content_hash + size +
hashtags + estimated fee for the
follow-up on-chain CREATE_POST tx
GET /feed/post/{id} — fetch body (respects on-chain soft
delete, returns 410 when deleted)
GET /feed/post/{id}/stats — {views, likes, liked_by_me?}
POST /feed/post/{id}/view — bump the counter
GET /feed/author/{pub} — chain-authoritative post list
enriched with body + stats
GET /feed/timeline — merged feed from people the user
follows (reads chain.Following,
fetches each author's recent posts)
GET /feed/trending — top-scored posts in last 24h
(score = likes × 3 + views)
GET /feed/foryou — simple recommendations: recent posts
minus authors the user already
follows, already-liked posts, and
own posts; ranked by engagement
GET /feed/hashtag/{tag} — posts tagged with the given #tag
cmd/node/main.go wiring
- --feed-db flag (DCHAIN_FEED_DB) + --feed-ttl-days (DCHAIN_FEED_TTL_DAYS)
- Opens FeedMailbox + registers FeedRoutes alongside RelayRoutes
- Threads chain.Post / LikeCount / HasLiked / PostsByAuthor / Following
into FeedConfig so HTTP handlers can merge on-chain metadata with
off-chain body+stats.
Auth & safety
- POST /feed/publish: Ed25519 signature over "publish:<post_id>:
<content_sha256_hex>:<ts>"; ±5-minute skew window for anti-replay.
- content_hash binds body to the on-chain tx — you can't publish
body-A off-chain and commit hash-of-body-B on-chain.
- Writes wrapped in withSubmitTxGuards (rate-limit + size cap), reads
in withReadLimit — same guards as /relay.
Trending / recommendations
- V1 heuristic (likes × 3 + views) + time window. Documented as
v2.2.0 "Feed algorithm" candidate for a proper ranking layer
(half-life decay, follow-of-follow boost, hashtag collaborative).
Tests
- Store round-trip, size enforcement, hashtag indexing (case-insensitive
+ dedup), view counter increments, author chrono order, delete
cleans all indices, RecentPostIDs time-window filter.
- Full go test ./... is green (blockchain + consensus + identity +
relay + vm all pass).
Next (Phase C): client Feed tab — composer, timeline, post detail,
profile follow, For You + Trending screens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
relay/feed_mailbox_test.go
Normal file
198
relay/feed_mailbox_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestFeedMailbox(t *testing.T) *FeedMailbox {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "dchain-feedtest-*")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp: %v", err)
|
||||
}
|
||||
fm, err := OpenFeedMailbox(dir, 24*time.Hour)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
t.Fatalf("OpenFeedMailbox: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = fm.Close()
|
||||
for i := 0; i < 20; i++ {
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
return fm
|
||||
}
|
||||
|
||||
// TestFeedMailboxStoreAndGet: store round-trips content + metadata.
|
||||
func TestFeedMailboxStoreAndGet(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
post := &FeedPost{
|
||||
PostID: "p1",
|
||||
Author: "authorhex",
|
||||
Content: "Hello #world from #dchain",
|
||||
}
|
||||
tags, err := fm.Store(post, 12345)
|
||||
if err != nil {
|
||||
t.Fatalf("Store: %v", err)
|
||||
}
|
||||
wantTags := []string{"world", "dchain"}
|
||||
if len(tags) != len(wantTags) {
|
||||
t.Fatalf("Store returned %v, want %v", tags, wantTags)
|
||||
}
|
||||
for i := range wantTags {
|
||||
if tags[i] != wantTags[i] {
|
||||
t.Errorf("Store tag[%d]: got %q, want %q", i, tags[i], wantTags[i])
|
||||
}
|
||||
}
|
||||
|
||||
got, err := fm.Get("p1")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("Get: got %v err=%v", got, err)
|
||||
}
|
||||
if got.Content != post.Content {
|
||||
t.Errorf("content: got %q, want %q", got.Content, post.Content)
|
||||
}
|
||||
if got.CreatedAt != 12345 {
|
||||
t.Errorf("created_at: got %d, want 12345", got.CreatedAt)
|
||||
}
|
||||
if len(got.Hashtags) != 2 {
|
||||
t.Errorf("hashtags: got %v, want [world dchain]", got.Hashtags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxTooLarge: rejects over-quota content.
|
||||
func TestFeedMailboxTooLarge(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
big := make([]byte, MaxPostBodySize+1)
|
||||
post := &FeedPost{
|
||||
PostID: "big1",
|
||||
Author: "a",
|
||||
Attachment: big,
|
||||
}
|
||||
if _, err := fm.Store(post, 0); err != ErrPostTooLarge {
|
||||
t.Fatalf("Store huge post: got %v, want ErrPostTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxHashtagIndex: hashtags are searchable + dedup + case-normalised.
|
||||
func TestFeedMailboxHashtagIndex(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
|
||||
p1 := &FeedPost{PostID: "p1", Author: "a", Content: "post about #Go"}
|
||||
p2 := &FeedPost{PostID: "p2", Author: "b", Content: "Also #go programming"}
|
||||
p3 := &FeedPost{PostID: "p3", Author: "a", Content: "#Rust too"}
|
||||
|
||||
if _, err := fm.Store(p1, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fm.Store(p2, 2000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fm.Store(p3, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// #go is case-insensitive, should return both posts newest-first.
|
||||
ids, err := fm.PostsByHashtag("#Go", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != "p2" || ids[1] != "p1" {
|
||||
t.Errorf("PostsByHashtag(Go): got %v, want [p2 p1]", ids)
|
||||
}
|
||||
|
||||
ids, _ = fm.PostsByHashtag("rust", 10)
|
||||
if len(ids) != 1 || ids[0] != "p3" {
|
||||
t.Errorf("PostsByHashtag(rust): got %v, want [p3]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxViewCounter: increments + reads.
|
||||
func TestFeedMailboxViewCounter(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
fm.Store(&FeedPost{PostID: "p", Author: "a", Content: "hi"}, 10)
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
n, err := fm.IncrementView("p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != uint64(i) {
|
||||
t.Errorf("IncrementView #%d: got %d, want %d", i, n, i)
|
||||
}
|
||||
}
|
||||
if n, _ := fm.ViewCount("p"); n != 5 {
|
||||
t.Errorf("ViewCount: got %d, want 5", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxByAuthor: author chrono index returns newest first.
|
||||
func TestFeedMailboxByAuthor(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
fm.Store(&FeedPost{PostID: "old", Author: "a", Content: "one"}, 100)
|
||||
fm.Store(&FeedPost{PostID: "new", Author: "a", Content: "two"}, 500)
|
||||
fm.Store(&FeedPost{PostID: "mid", Author: "a", Content: "three"}, 300)
|
||||
fm.Store(&FeedPost{PostID: "other", Author: "b", Content: "four"}, 400)
|
||||
|
||||
ids, err := fm.PostsByAuthor("a", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"new", "mid", "old"}
|
||||
if len(ids) != len(want) {
|
||||
t.Fatalf("len: got %d, want %d (%v)", len(ids), len(want), ids)
|
||||
}
|
||||
for i := range want {
|
||||
if ids[i] != want[i] {
|
||||
t.Errorf("pos %d: got %q want %q", i, ids[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxDelete: removes body + indices.
|
||||
func TestFeedMailboxDelete(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
fm.Store(&FeedPost{PostID: "x", Author: "a", Content: "doomed #go"}, 100)
|
||||
|
||||
if err := fm.Delete("x"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if got, _ := fm.Get("x"); got != nil {
|
||||
t.Errorf("Get after delete: got %v, want nil", got)
|
||||
}
|
||||
if ids, _ := fm.PostsByHashtag("go", 10); len(ids) != 0 {
|
||||
t.Errorf("hashtag index: got %v, want []", ids)
|
||||
}
|
||||
if ids, _ := fm.PostsByAuthor("a", 10); len(ids) != 0 {
|
||||
t.Errorf("author index: got %v, want []", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedMailboxRecentIDs: filters by window, sorts newest first.
|
||||
func TestFeedMailboxRecentIDs(t *testing.T) {
|
||||
fm := newTestFeedMailbox(t)
|
||||
now := time.Now().Unix()
|
||||
// p1 1 hour old, p2 5 hours old, p3 50 hours old.
|
||||
fm.Store(&FeedPost{PostID: "p1", Author: "a", Content: "a"}, now-3600)
|
||||
fm.Store(&FeedPost{PostID: "p2", Author: "b", Content: "b"}, now-5*3600)
|
||||
fm.Store(&FeedPost{PostID: "p3", Author: "c", Content: "c"}, now-50*3600)
|
||||
|
||||
// 6-hour window: p1 and p2 only.
|
||||
ids, err := fm.RecentPostIDs(6*3600, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("RecentPostIDs(6h): got %v, want 2 posts", ids)
|
||||
}
|
||||
// Newest first.
|
||||
if ids[0] != "p1" {
|
||||
t.Errorf("first post: got %s, want p1", ids[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user