Node flags (cmd/node/main.go):
--max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
--feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
--chain-disk-limit-mb — advisory watcher (can't reject blocks without
breaking consensus; logs WARN every minute)
Client — Saved Messages (self-chat):
- Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
- Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
- Empty state rendered outside inverted FlatList — fixes the mirrored
"say hi…" on Android RTL-aware layout builds
- PostCard shows "You" for own posts instead of the self-contact alias
Client — user walls:
- New route /(app)/feed/author/[pub] with infinite-scroll via
`created_at` cursor and pull-to-refresh
- Profile screen gains "View posts" button (universal) next to
"Open chat" (contact-only)
Feed pipeline:
- Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
- ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
preserved in the HTTP layer
- FeedMailbox quota + DiskUsage surface — supports new CLI flag
README:
- Step-by-step "first node / joiner" section on the landing page,
full flag tables incl. the new resource-cap group, minimal
checklists for open/private/low-end deployments
200 lines
5.3 KiB
Go
200 lines
5.3 KiB
Go
package relay
|
|
|
|
import (
|
|
"errors"
|
|
"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, 0)
|
|
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); !errors.Is(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])
|
|
}
|
|
}
|