Report inbox: /api/reports in its own bounded reports.db, dashboard Reports page (list, filter, triage, copy as text)
Build and Push Docker Image / build (push) Failing after 1m20s
Build and Push Docker Image / build (push) Failing after 1m20s
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
// Package reports is meta-core's bug-report / feedback inbox.
|
||||
//
|
||||
// Client apps (meta-watch, meta-read, …) build a report in the browser — the
|
||||
// line the person typed, the page they were on, the build, the device, the
|
||||
// tail of the client log, and whatever app-specific state explains the moment
|
||||
// (a player pipeline, a reader position) — and their server forwards it here,
|
||||
// server-to-server, to the box's own core. meta-core stores it, lists it and
|
||||
// shows it on the dashboard's Reports page, so triage reads one inbox instead
|
||||
// of one per app.
|
||||
//
|
||||
// # Why a third SQLite file
|
||||
//
|
||||
// Reports are diagnostics, not metadata. They do not belong in the truth
|
||||
// database (signed claims only) nor in the cache (unsynchronised, never backed
|
||||
// up, deleted as the recovery procedure). They live in their own file,
|
||||
// reports.db, so neither a cache rebuild nor POST /api/metadata/clear touches
|
||||
// them, and a full report table can never slow a record write.
|
||||
//
|
||||
// # What meta-core knows about a report
|
||||
//
|
||||
// Almost nothing, on purpose. A handful of top-level fields are lifted into
|
||||
// columns so the inbox can filter and headline them (app, build, uid, page,
|
||||
// route, cid, title, note); everything else is kept verbatim in body as JSON
|
||||
// and rendered generically by the dashboard. An app can add a new block of
|
||||
// state without a meta-core release.
|
||||
//
|
||||
// ⚠ A report is a piece of viewing/reading history (cids, titles, a uid, a
|
||||
// timestamp). It stays on the box, behind the same perimeter as the rest of
|
||||
// meta-core's API; nothing here is federated.
|
||||
package reports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/metazla/meta-core/internal/kv"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS report(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL, -- when the report was filed (ms)
|
||||
received INTEGER NOT NULL, -- when meta-core stored it (ms)
|
||||
app TEXT NOT NULL,
|
||||
build TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
page TEXT NOT NULL DEFAULT '',
|
||||
route TEXT NOT NULL DEFAULT '',
|
||||
cid TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
triage_note TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
log_lines INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL,
|
||||
body TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS report_app_status ON report(app, status, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS report_status ON report(status, id DESC);
|
||||
`
|
||||
|
||||
// Statuses a report moves through. "open" is where every report starts.
|
||||
const (
|
||||
StatusOpen = "open"
|
||||
StatusTriaged = "triaged"
|
||||
StatusClosed = "closed"
|
||||
)
|
||||
|
||||
// ValidStatus reports whether s is a status a report can be set to.
|
||||
func ValidStatus(s string) bool {
|
||||
return s == StatusOpen || s == StatusTriaged || s == StatusClosed
|
||||
}
|
||||
|
||||
// Bounds on what one report may carry. The string caps match the ones
|
||||
// meta-watch applied when it kept reports itself, so moving the inbox loses
|
||||
// nothing a report used to hold.
|
||||
const (
|
||||
maxApp = 32
|
||||
maxBuild = 4000
|
||||
maxUID = 128
|
||||
maxPage = 32
|
||||
maxRoute = 512
|
||||
maxCID = 128
|
||||
maxTitle = 200
|
||||
maxNote = 1000
|
||||
maxTriageNote = 2000
|
||||
// MaxLogLines / MaxLogLineChars bound the client log tail.
|
||||
MaxLogLines = 200
|
||||
MaxLogLineChars = 600
|
||||
// MaxBlockBytes bounds every other top-level block (context, device,
|
||||
// app-specific state). A larger block is replaced by a marker naming its
|
||||
// size, never dropped silently.
|
||||
MaxBlockBytes = 32 * 1024
|
||||
// maxBlocks bounds the number of top-level keys.
|
||||
maxBlocks = 64
|
||||
// filed-at plausibility window: a report spooled by an app while the core
|
||||
// was down arrives late but keeps the time it was filed.
|
||||
maxFiledAge = 30 * 24 * time.Hour
|
||||
maxFiledFuture = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ErrNotFound is returned for an id that does not exist.
|
||||
var ErrNotFound = errors.New("report not found")
|
||||
|
||||
// Summary is a report without its body: what the inbox list shows.
|
||||
type Summary struct {
|
||||
ID int64 `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
Received int64 `json:"received"`
|
||||
App string `json:"app"`
|
||||
Build string `json:"build,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Status string `json:"status"`
|
||||
TriageNote string `json:"triage_note,omitempty"`
|
||||
Updated int64 `json:"updated,omitempty"`
|
||||
LogLines int `json:"log_lines"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// Report is a stored report with its full body.
|
||||
type Report struct {
|
||||
Summary
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// Filter selects reports for List.
|
||||
type Filter struct {
|
||||
App string
|
||||
Status string
|
||||
Q string // substring of note, title or cid
|
||||
Before int64 // only ids below this (paging); 0 = from the newest
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Page is one page of List.
|
||||
type Page struct {
|
||||
Reports []Summary `json:"reports"`
|
||||
Apps map[string]int64 `json:"apps"` // every app with a report, and how many
|
||||
Counts map[string]int64 `json:"counts"` // per status, within the App filter
|
||||
More bool `json:"more"`
|
||||
}
|
||||
|
||||
// Store is the open reports database.
|
||||
type Store struct {
|
||||
db *kv.Store
|
||||
maxRows int
|
||||
maxBytes int64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the reports database at path. maxRows and
|
||||
// maxBytes bound what Prune keeps; <= 0 disables that bound.
|
||||
func Open(path string, maxRows int, maxBytes int64) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("reports dir: %w", err)
|
||||
}
|
||||
db, err := kv.Open(path, kv.Options{
|
||||
NoKVTable: true,
|
||||
Schema: schema,
|
||||
WriterCacheMB: 4,
|
||||
ReaderCacheMB: 2,
|
||||
Readers: 2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open reports %s: %w", path, err)
|
||||
}
|
||||
return &Store{db: db, maxRows: maxRows, maxBytes: maxBytes, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Path is the database file.
|
||||
func (s *Store) Path() string { return s.db.Path() }
|
||||
|
||||
func opCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 30*time.Second)
|
||||
}
|
||||
|
||||
// ── Normalisation ─────────────────────────────────────────────────────────
|
||||
|
||||
// Normalize turns a posted envelope into a storable report: the indexed
|
||||
// fields lifted and clamped, the log tail bounded, every other block size-
|
||||
// checked. It never fails on content an app might plausibly send; the only
|
||||
// error is a report that names no app.
|
||||
func Normalize(in map[string]json.RawMessage, now time.Time) (Report, error) {
|
||||
var r Report
|
||||
str := func(key string, n int) string {
|
||||
raw, ok := in[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var v string
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return ""
|
||||
}
|
||||
return clamp(strings.TrimSpace(v), n)
|
||||
}
|
||||
|
||||
r.App = str("app", maxApp)
|
||||
if r.App == "" {
|
||||
return r, errors.New("app is required")
|
||||
}
|
||||
r.Build = str("build", maxBuild)
|
||||
r.UID = str("uid", maxUID)
|
||||
r.Page = str("page", maxPage)
|
||||
r.Route = str("route", maxRoute)
|
||||
r.CID = str("cid", maxCID)
|
||||
r.Title = str("title", maxTitle)
|
||||
r.Note = str("note", maxNote)
|
||||
r.Status = StatusOpen
|
||||
r.Received = now.UnixMilli()
|
||||
r.TS = r.Received
|
||||
if raw, ok := in["filedAt"]; ok {
|
||||
var f float64
|
||||
if json.Unmarshal(raw, &f) == nil {
|
||||
t := time.UnixMilli(int64(f))
|
||||
if t.After(now.Add(-maxFiledAge)) && t.Before(now.Add(maxFiledFuture)) {
|
||||
r.TS = t.UnixMilli()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The body repeats the lifted fields (clamped), so a report exported as
|
||||
// JSON is complete on its own.
|
||||
body := make(map[string]any, len(in))
|
||||
for k, v := range map[string]string{
|
||||
"app": r.App, "build": r.Build, "uid": r.UID, "page": r.Page,
|
||||
"route": r.Route, "cid": r.CID, "title": r.Title, "note": r.Note,
|
||||
} {
|
||||
if v != "" {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
if raw, ok := in["log"]; ok {
|
||||
var lines []string
|
||||
if json.Unmarshal(raw, &lines) == nil {
|
||||
if len(lines) > MaxLogLines {
|
||||
lines = lines[len(lines)-MaxLogLines:]
|
||||
}
|
||||
for i, l := range lines {
|
||||
lines[i] = clamp(l, MaxLogLineChars)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
body["log"] = lines
|
||||
r.LogLines = len(lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remaining blocks in key order, so which ones survive maxBlocks does not
|
||||
// depend on map iteration.
|
||||
keys := make([]string, 0, len(in))
|
||||
for k := range in {
|
||||
if k != "log" && k != "filedAt" && !isLifted(k) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
blocks := 0
|
||||
for _, k := range keys {
|
||||
raw := in[k]
|
||||
if string(raw) == "null" {
|
||||
continue
|
||||
}
|
||||
if blocks >= maxBlocks {
|
||||
break
|
||||
}
|
||||
blocks++
|
||||
key := clamp(k, 64)
|
||||
if len(raw) > MaxBlockBytes {
|
||||
body[key] = map[string]any{"truncated": true, "bytes": len(raw)}
|
||||
continue
|
||||
}
|
||||
body[key] = raw
|
||||
}
|
||||
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("encode body: %w", err)
|
||||
}
|
||||
r.Body = b
|
||||
r.Bytes = int64(len(b))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func isLifted(k string) bool {
|
||||
switch k {
|
||||
case "app", "build", "uid", "page", "route", "cid", "title", "note":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clamp keeps at most n characters (runes) of s, never splitting one.
|
||||
func clamp(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
i := 0
|
||||
for j := range s {
|
||||
if i == n {
|
||||
return s[:j]
|
||||
}
|
||||
i++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Insert stores a normalised report and returns its id. The store is pruned
|
||||
// to its bounds in the same transaction, so it never sits over them.
|
||||
func (s *Store) Insert(r Report) (int64, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var id int64
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
res, err := tx.Exec(`INSERT INTO report(ts, received, app, build, uid, page, route, cid, title, note,
|
||||
status, log_lines, bytes, body) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.TS, r.Received, r.App, r.Build, r.UID, r.Page, r.Route, r.CID, r.Title, r.Note,
|
||||
StatusOpen, r.LogLines, r.Bytes, string(r.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err = res.LastInsertId(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.prune(tx)
|
||||
return err
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
// Update sets a report's status and/or triage note. A nil argument leaves
|
||||
// that field as it is.
|
||||
func (s *Store) Update(id int64, status, triageNote *string) (Report, error) {
|
||||
if status != nil && !ValidStatus(*status) {
|
||||
return Report{}, fmt.Errorf("invalid status %q", *status)
|
||||
}
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
sets, args := []string{"updated = ?"}, []any{s.now().UnixMilli()}
|
||||
if status != nil {
|
||||
sets, args = append(sets, "status = ?"), append(args, *status)
|
||||
}
|
||||
if triageNote != nil {
|
||||
sets, args = append(sets, "triage_note = ?"), append(args, clamp(strings.TrimSpace(*triageNote), maxTriageNote))
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE report SET `+strings.Join(sets, ", ")+` WHERE id = ?`, append(args, id)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
// Delete removes a report.
|
||||
func (s *Store) Delete(id int64) error {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
return s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
res, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Prune brings the store back within its bounds and returns how many reports
|
||||
// it removed. Insert already prunes; this is for a bound lowered by config.
|
||||
func (s *Store) Prune() (int, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var n int
|
||||
err := s.db.Update(ctx, func(tx *kv.Tx) error {
|
||||
var err error
|
||||
n, err = s.prune(tx)
|
||||
return err
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
// prune evicts closed reports first, oldest first, then any report, oldest
|
||||
// first, until both the row and the byte bound hold. An open report is the
|
||||
// last thing to go: it is the one nobody has read yet.
|
||||
func (s *Store) prune(tx *kv.Tx) (int, error) {
|
||||
if s.maxRows <= 0 && s.maxBytes <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var rows, bytes int64
|
||||
if err := tx.QueryRow(`SELECT count(*), coalesce(sum(bytes), 0) FROM report`).Scan(&rows, &bytes); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
over := func() bool {
|
||||
return (s.maxRows > 0 && rows > int64(s.maxRows)) || (s.maxBytes > 0 && bytes > s.maxBytes)
|
||||
}
|
||||
if !over() {
|
||||
return 0, nil
|
||||
}
|
||||
q, err := tx.Query(`SELECT id, bytes FROM report ORDER BY (status = 'closed') DESC, id ASC`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var victims []int64
|
||||
for q.Next() && over() {
|
||||
var id, b int64
|
||||
if err := q.Scan(&id, &b); err != nil {
|
||||
q.Close()
|
||||
return 0, err
|
||||
}
|
||||
victims = append(victims, id)
|
||||
rows--
|
||||
bytes -= b
|
||||
}
|
||||
q.Close()
|
||||
for _, id := range victims {
|
||||
if _, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(victims), nil
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const summaryCols = `id, ts, received, app, build, uid, page, route, cid, title, note, status, triage_note, updated, log_lines, bytes`
|
||||
|
||||
func scanSummary(sc interface{ Scan(...any) error }, extra ...any) (Summary, error) {
|
||||
var r Summary
|
||||
dst := append([]any{&r.ID, &r.TS, &r.Received, &r.App, &r.Build, &r.UID, &r.Page, &r.Route,
|
||||
&r.CID, &r.Title, &r.Note, &r.Status, &r.TriageNote, &r.Updated, &r.LogLines, &r.Bytes}, extra...)
|
||||
return r, sc.Scan(dst...)
|
||||
}
|
||||
|
||||
// Get returns one report with its body.
|
||||
func (s *Store) Get(id int64) (Report, error) {
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
var body string
|
||||
row := s.db.DB().QueryRowContext(ctx, `SELECT `+summaryCols+`, body FROM report WHERE id = ?`, id)
|
||||
sum, err := scanSummary(row, &body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Report{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
return Report{Summary: sum, Body: json.RawMessage(body)}, nil
|
||||
}
|
||||
|
||||
// List returns one page of summaries, newest first, with the counts the
|
||||
// inbox's filter bar needs.
|
||||
func (s *Store) List(f Filter) (Page, error) {
|
||||
if f.Limit <= 0 || f.Limit > 200 {
|
||||
f.Limit = 50
|
||||
}
|
||||
ctx, cancel := opCtx()
|
||||
defer cancel()
|
||||
db := s.db.DB()
|
||||
|
||||
var where []string
|
||||
var args []any
|
||||
if f.App != "" {
|
||||
where, args = append(where, "app = ?"), append(args, f.App)
|
||||
}
|
||||
appWhere, appArgs := append([]string(nil), where...), append([]any(nil), args...)
|
||||
if f.Status != "" {
|
||||
where, args = append(where, "status = ?"), append(args, f.Status)
|
||||
}
|
||||
if q := strings.TrimSpace(f.Q); q != "" {
|
||||
like := "%" + escapeLike(q) + "%"
|
||||
where = append(where, `(note LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\' OR cid LIKE ? ESCAPE '\' OR uid LIKE ? ESCAPE '\')`)
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if f.Before > 0 {
|
||||
where, args = append(where, "id < ?"), append(args, f.Before)
|
||||
}
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
page := Page{Reports: []Summary{}, Apps: map[string]int64{}, Counts: map[string]int64{}}
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+summaryCols+` FROM report`+clause+` ORDER BY id DESC LIMIT ?`,
|
||||
append(args, f.Limit+1)...)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
for rows.Next() {
|
||||
sum, err := scanSummary(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return page, err
|
||||
}
|
||||
page.Reports = append(page.Reports, sum)
|
||||
}
|
||||
rows.Close()
|
||||
if len(page.Reports) > f.Limit {
|
||||
page.Reports, page.More = page.Reports[:f.Limit], true
|
||||
}
|
||||
|
||||
if err := groupCount(ctx, db, `SELECT app, count(*) FROM report GROUP BY app`, nil, page.Apps); err != nil {
|
||||
return page, err
|
||||
}
|
||||
statusQ := `SELECT status, count(*) FROM report`
|
||||
if len(appWhere) > 0 {
|
||||
statusQ += " WHERE " + strings.Join(appWhere, " AND ")
|
||||
}
|
||||
if err := groupCount(ctx, db, statusQ+` GROUP BY status`, appArgs, page.Counts); err != nil {
|
||||
return page, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func groupCount(ctx context.Context, db *sql.DB, q string, args []any, into map[string]int64) error {
|
||||
rows, err := db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int64
|
||||
if err := rows.Scan(&k, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
into[k] = n
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user