228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
package reports
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func open(t *testing.T, maxRows int, maxBytes int64) *Store {
|
|
t.Helper()
|
|
s, err := Open(filepath.Join(t.TempDir(), "db", "reports.db"), maxRows, maxBytes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func envelope(t *testing.T, v map[string]any) map[string]json.RawMessage {
|
|
t.Helper()
|
|
b, _ := json.Marshal(v)
|
|
var out map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func file(t *testing.T, s *Store, v map[string]any) int64 {
|
|
t.Helper()
|
|
r, err := Normalize(envelope(t, v), time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, err := s.Insert(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestNormalizeRequiresApp(t *testing.T) {
|
|
if _, err := Normalize(envelope(t, map[string]any{"note": "x"}), time.Now()); err == nil {
|
|
t.Fatal("a report without app must be refused")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeClampsAndBounds(t *testing.T) {
|
|
log := make([]string, 250)
|
|
for i := range log {
|
|
log[i] = strings.Repeat("é", 700)
|
|
}
|
|
now := time.Now()
|
|
r, err := Normalize(envelope(t, map[string]any{
|
|
"app": "meta-read",
|
|
"note": strings.Repeat("n", 1500),
|
|
"title": strings.Repeat("t", 300),
|
|
"log": log,
|
|
"context": map[string]string{"big": strings.Repeat("x", MaxBlockBytes)},
|
|
"reader": map[string]int{"page": 4},
|
|
"device": nil,
|
|
"filedAt": now.Add(-time.Hour).UnixMilli(),
|
|
}), now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(r.Note) != maxNote || len(r.Title) != maxTitle {
|
|
t.Fatalf("note/title not clamped: %d %d", len(r.Note), len(r.Title))
|
|
}
|
|
if r.TS != now.Add(-time.Hour).UnixMilli() || r.Received != now.UnixMilli() {
|
|
t.Fatalf("filedAt should set ts, received stays now: %d %d", r.TS, r.Received)
|
|
}
|
|
var body map[string]json.RawMessage
|
|
_ = json.Unmarshal(r.Body, &body)
|
|
var lines []string
|
|
_ = json.Unmarshal(body["log"], &lines)
|
|
if len(lines) != MaxLogLines || r.LogLines != MaxLogLines {
|
|
t.Fatalf("log tail: %d lines (col %d)", len(lines), r.LogLines)
|
|
}
|
|
if n := len([]rune(lines[0])); n != MaxLogLineChars {
|
|
t.Fatalf("log line clamped by rune: %d", n)
|
|
}
|
|
if !strings.Contains(string(body["context"]), `"truncated":true`) {
|
|
t.Fatalf("oversized block not replaced by a marker: %.80s", body["context"])
|
|
}
|
|
if string(body["reader"]) != `{"page":4}` {
|
|
t.Fatalf("app block not kept verbatim: %s", body["reader"])
|
|
}
|
|
if _, ok := body["device"]; ok {
|
|
t.Fatal("null block should be dropped")
|
|
}
|
|
if _, ok := body["filedAt"]; ok {
|
|
t.Fatal("filedAt belongs in ts, not the body")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeIgnoresImplausibleFiledAt(t *testing.T) {
|
|
now := time.Now()
|
|
for _, at := range []time.Time{now.Add(-60 * 24 * time.Hour), now.Add(time.Hour)} {
|
|
r, _ := Normalize(envelope(t, map[string]any{"app": "a", "filedAt": at.UnixMilli()}), now)
|
|
if r.TS != now.UnixMilli() {
|
|
t.Fatalf("filedAt %v should be ignored", at)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInsertGetListFilters(t *testing.T) {
|
|
s := open(t, 0, 0)
|
|
a := file(t, s, map[string]any{"app": "meta-watch", "note": "stall at 3%", "cid": "bafyA"})
|
|
b := file(t, s, map[string]any{"app": "meta-read", "note": "blank page", "uid": "u1"})
|
|
c := file(t, s, map[string]any{"app": "meta-read", "note": "wrong cover 100%"})
|
|
|
|
got, err := s.Get(a)
|
|
if err != nil || got.App != "meta-watch" || got.CID != "bafyA" || got.Status != StatusOpen {
|
|
t.Fatalf("get: %+v %v", got.Summary, err)
|
|
}
|
|
if _, err := s.Get(999); err != ErrNotFound {
|
|
t.Fatalf("missing id: %v", err)
|
|
}
|
|
|
|
p, err := s.List(Filter{})
|
|
if err != nil || len(p.Reports) != 3 || p.Reports[0].ID != c {
|
|
t.Fatalf("list newest first: %+v %v", p.Reports, err)
|
|
}
|
|
if p.Apps["meta-read"] != 2 || p.Apps["meta-watch"] != 1 || p.Counts[StatusOpen] != 3 {
|
|
t.Fatalf("counts: %+v %+v", p.Apps, p.Counts)
|
|
}
|
|
|
|
p, _ = s.List(Filter{App: "meta-read"})
|
|
if len(p.Reports) != 2 || p.Counts[StatusOpen] != 2 || p.Apps["meta-watch"] != 1 {
|
|
t.Fatalf("app filter: %+v counts %+v apps %+v", p.Reports, p.Counts, p.Apps)
|
|
}
|
|
p, _ = s.List(Filter{Q: "100%"})
|
|
if len(p.Reports) != 1 || p.Reports[0].ID != c {
|
|
t.Fatalf("q is a literal substring (%% escaped): %+v", p.Reports)
|
|
}
|
|
p, _ = s.List(Filter{Q: "u1"})
|
|
if len(p.Reports) != 1 || p.Reports[0].ID != b {
|
|
t.Fatalf("q matches uid: %+v", p.Reports)
|
|
}
|
|
|
|
p, _ = s.List(Filter{Limit: 2})
|
|
if len(p.Reports) != 2 || !p.More {
|
|
t.Fatalf("page 1: %d more=%v", len(p.Reports), p.More)
|
|
}
|
|
p, _ = s.List(Filter{Limit: 2, Before: p.Reports[1].ID})
|
|
if len(p.Reports) != 1 || p.More || p.Reports[0].ID != a {
|
|
t.Fatalf("page 2: %+v more=%v", p.Reports, p.More)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAndDelete(t *testing.T) {
|
|
s := open(t, 0, 0)
|
|
id := file(t, s, map[string]any{"app": "a"})
|
|
st, note := StatusTriaged, " Deck #900 "
|
|
r, err := s.Update(id, &st, ¬e)
|
|
if err != nil || r.Status != StatusTriaged || r.TriageNote != "Deck #900" || r.Updated == 0 {
|
|
t.Fatalf("update: %+v %v", r.Summary, err)
|
|
}
|
|
bad := "done"
|
|
if _, err := s.Update(id, &bad, nil); err == nil {
|
|
t.Fatal("invalid status accepted")
|
|
}
|
|
if _, err := s.Update(42, &st, nil); err != ErrNotFound {
|
|
t.Fatalf("update missing: %v", err)
|
|
}
|
|
p, _ := s.List(Filter{Status: StatusTriaged})
|
|
if len(p.Reports) != 1 {
|
|
t.Fatalf("status filter: %+v", p.Reports)
|
|
}
|
|
if err := s.Delete(id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Delete(id); err != ErrNotFound {
|
|
t.Fatalf("second delete: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPruneEvictsClosedThenOldest(t *testing.T) {
|
|
s := open(t, 3, 0)
|
|
ids := []int64{
|
|
file(t, s, map[string]any{"app": "a", "note": "1"}),
|
|
file(t, s, map[string]any{"app": "a", "note": "2"}),
|
|
file(t, s, map[string]any{"app": "a", "note": "3"}),
|
|
}
|
|
closed := StatusClosed
|
|
if _, err := s.Update(ids[2], &closed, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Fourth report: the closed one (newest of the three) goes first.
|
|
file(t, s, map[string]any{"app": "a", "note": "4"})
|
|
if _, err := s.Get(ids[2]); err != ErrNotFound {
|
|
t.Fatal("closed report should be evicted before older open ones")
|
|
}
|
|
// Fifth: no closed left, so the oldest open goes.
|
|
file(t, s, map[string]any{"app": "a", "note": "5"})
|
|
if _, err := s.Get(ids[0]); err != ErrNotFound {
|
|
t.Fatal("oldest open report should be evicted next")
|
|
}
|
|
p, _ := s.List(Filter{})
|
|
if len(p.Reports) != 3 {
|
|
t.Fatalf("store over its row bound: %d", len(p.Reports))
|
|
}
|
|
}
|
|
|
|
func TestPruneByBytes(t *testing.T) {
|
|
s := open(t, 0, 3000)
|
|
for i := 0; i < 5; i++ {
|
|
file(t, s, map[string]any{"app": "a", "note": strings.Repeat("x", 900)})
|
|
}
|
|
p, _ := s.List(Filter{})
|
|
var total int64
|
|
for _, r := range p.Reports {
|
|
total += r.Bytes
|
|
}
|
|
if total > 3000 || len(p.Reports) == 0 {
|
|
t.Fatalf("byte bound not held: %d bytes in %d reports", total, len(p.Reports))
|
|
}
|
|
}
|
|
|
|
func TestClampKeepsRunes(t *testing.T) {
|
|
if got := clamp("héllo", 2); got != "hé" {
|
|
t.Fatalf("clamp: %q", got)
|
|
}
|
|
}
|