package eventstream import ( "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strconv" "strings" "sync" "testing" "time" "github.com/gorilla/websocket" _ "github.com/mattn/go-sqlite3" "tangled.org/core/notifier" ) type memSource struct { mu sync.Mutex events []Event } func (s *memSource) add(ev Event) { s.mu.Lock() defer s.mu.Unlock() s.events = append(s.events, ev) } func (s *memSource) GetEvents(cursor int64, limit int) ([]Event, error) { s.mu.Lock() defer s.mu.Unlock() out := []Event{} for _, ev := range s.events { if ev.Created > cursor { out = append(out, ev) if len(out) == limit { break } } } return out, nil } func mkEvent(i int) Event { return Event{ Rkey: fmt.Sprintf("rk-%04d", i), Nsid: "sh.tangled.test", EventJson: json.RawMessage(fmt.Sprintf(`{"i":%d}`, i)), Created: int64(i + 1), } } func startServer(t *testing.T, src Backend, cfg StreamConfig) (string, *notifier.Notifier, <-chan error) { t.Helper() n := notifier.New() cfg.Backend = src cfg.Notifier = &n cfg.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { errCh <- Stream(w, r, cfg) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/events" return wsURL, &n, errCh } func dial(t *testing.T, wsURL string, cursor int64) *websocket.Conn { t.Helper() if cursor != 0 { wsURL += "?cursor=" + strconv.FormatInt(cursor, 10) } c, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { c.Close() }) return c } func readN(t *testing.T, c *websocket.Conn, n int) []Event { t.Helper() c.SetReadDeadline(time.Now().Add(2 * time.Second)) out := make([]Event, 0, n) for range n { _, msg, err := c.ReadMessage() if err != nil { t.Fatalf("read message at %d/%d: %v", len(out), n, err) } var ev Event if err := json.Unmarshal(msg, &ev); err != nil { t.Fatalf("unmarshal: %v", err) } out = append(out, ev) } return out } func TestStream_DrainStopsOnShortBatch(t *testing.T) { src := &memSource{} for i := range 7 { src.add(mkEvent(i)) } wsURL, _, errCh := startServer(t, src, StreamConfig{ BatchSize: 3, MaxBatchesPerDrain: 10, }) c := dial(t, wsURL, 0) got := readN(t, c, 7) for i, ev := range got { if ev.Created != int64(i+1) { t.Fatalf("event %d: got created=%d", i, ev.Created) } } c.Close() select { case err := <-errCh: if err != nil && !isCloseErr(err) { t.Fatalf("server error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("server did not exit") } } func TestStream_DrainHitsCap_ReturnsErrDrainCap(t *testing.T) { src := &memSource{} for i := range 5 { src.add(mkEvent(i)) } wsURL, _, errCh := startServer(t, src, StreamConfig{ BatchSize: 2, MaxBatchesPerDrain: 2, }) c := dial(t, wsURL, 0) got := readN(t, c, 4) if len(got) != 4 { t.Fatalf("want 4 events before cap, got %d", len(got)) } if got[3].Created != 4 { t.Fatalf("last delivered created = %d, want 4", got[3].Created) } select { case err := <-errCh: if !errors.Is(err, ErrDrainCap) { t.Fatalf("want ErrDrainCap, got %v", err) } case <-time.After(2 * time.Second): t.Fatal("server did not return cap error") } } func TestStream_CursorResume(t *testing.T) { src := &memSource{} for i := range 5 { src.add(mkEvent(i)) } wsURL, _, errCh := startServer(t, src, StreamConfig{ BatchSize: 10, MaxBatchesPerDrain: 10, }) c := dial(t, wsURL, 3) got := readN(t, c, 2) if got[0].Created != 4 || got[1].Created != 5 { t.Fatalf("resume from cursor: got %d,%d want 4,5", got[0].Created, got[1].Created) } c.Close() <-errCh } func TestStream_LiveDelivery(t *testing.T) { src := &memSource{} wsURL, n, errCh := startServer(t, src, StreamConfig{ BatchSize: 10, MaxBatchesPerDrain: 10, }) c := dial(t, wsURL, 0) src.add(mkEvent(42)) n.NotifyAll() got := readN(t, c, 1) if got[0].Created != 43 { t.Fatalf("live event created = %d, want 43", got[0].Created) } c.Close() <-errCh } func TestStream_LiveBurstExceedsBatchSize_DrainsAll(t *testing.T) { src := &memSource{} wsURL, n, errCh := startServer(t, src, StreamConfig{ BatchSize: 5, MaxBatchesPerDrain: 100, }) c := dial(t, wsURL, 0) const burst = 17 for i := range burst { src.add(mkEvent(i)) } n.NotifyAll() got := readN(t, c, burst) if len(got) != burst { t.Fatalf("got %d events, want %d", len(got), burst) } for i, ev := range got { if ev.Created != int64(i+1) { t.Fatalf("event %d: got created=%d want %d", i, ev.Created, i+1) } } c.Close() <-errCh } func TestInsert_MonotonicCreatedUnderConcurrency(t *testing.T) { db, err := sql.Open("sqlite3", t.TempDir()+"/events.db") if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { db.Close() }) if _, err := db.Exec(`create table events ( rkey text not null, nsid text not null, event text not null, created integer not null, primary key (rkey, nsid) )`); err != nil { t.Fatalf("schema: %v", err) } n := notifier.New() const total = 300 var wg sync.WaitGroup for i := range total { wg.Add(1) go func(i int) { defer wg.Done() if err := Insert(db, Event{ Rkey: fmt.Sprintf("rk-%d", i), Nsid: "sh.tangled.test", EventJson: json.RawMessage("{}"), }, &n); err != nil { t.Errorf("insert %d: %v", i, err) } }(i) } wg.Wait() rows, err := db.Query(`select created from events order by created asc`) if err != nil { t.Fatalf("read: %v", err) } defer rows.Close() var prev int64 count := 0 for rows.Next() { var c int64 if err := rows.Scan(&c); err != nil { t.Fatalf("scan: %v", err) } if count > 0 && c <= prev { t.Fatalf("created not strictly increasing: %d <= %d", c, prev) } prev = c count++ } if count != total { t.Fatalf("got %d rows, want %d", count, total) } } func isCloseErr(err error) bool { if err == nil { return false } if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { return true } return strings.Contains(err.Error(), "use of closed network connection") || strings.Contains(err.Error(), "websocket: close") || strings.Contains(err.Error(), "broken pipe") }