package docs
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"gitea.parodia.dev/drwily/petal/internal/db"
)
// chained builds a valid hash-chained snapshot run from (minutes-offset, words)
// pairs, so tests can describe a writing history in terms a reader recognises
// and get correct hashes for free.
func chained(docID string, base time.Time, points ...[2]int) []db.DocumentVersion {
var (
out []db.DocumentVersion
prev string
)
for i, p := range points {
at := base.Add(time.Duration(p[0]) * time.Minute)
text := strings.Repeat("word ", p[1])
v := db.DocumentVersion{
ID: fmt.Sprintf("v%d", i),
DocID: docID,
ContentText: text,
WordCount: p[1],
Kind: db.VersionKindAuto,
CreatedAt: at,
PrevHash: prev,
}
v.ContentHash = chainHash(prev, docID, at, p[1], text)
prev = v.ContentHash
out = append(out, v)
}
return out
}
func TestBuildPassportSessions(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1", Title: "Essay"}
// Two sittings: 09:00–09:30, then a three-hour break, then 12:30–13:00.
vs := chained("d1", base,
[2]int{0, 40}, [2]int{15, 120}, [2]int{30, 210},
[2]int{210, 260}, [2]int{240, 330},
)
d := buildPassport(doc, vs)
if len(d.Sessions) != 2 {
t.Fatalf("sessions = %d, want 2", len(d.Sessions))
}
if got := d.Sessions[0].Duration(); got != 30*time.Minute {
t.Errorf("session 1 duration = %v, want 30m", got)
}
if got := d.Sessions[1].Snapshots; got != 2 {
t.Errorf("session 2 snapshots = %d, want 2", got)
}
if got := d.Span; got != 4*time.Hour {
t.Errorf("span = %v, want 4h", got)
}
// Active time counts only time inside sessions, never the break.
if got := d.ActiveTime; got != 60*time.Minute {
t.Errorf("active time = %v, want 60m", got)
}
// Session 2 measures from session 1's final count (210 → 330).
if got := d.Sessions[1].WordsAdded; got != 120 {
t.Errorf("session 2 words = %d, want 120", got)
}
if d.ChainStatus != chainVerified {
t.Errorf("chain = %q, want %q", d.ChainStatus, chainVerified)
}
}
func TestBuildPassportFlagsLargeJump(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
doc := db.Document{ID: "d1"}
t.Run("steady growth is not flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 100}, [2]int{5, 200}, [2]int{10, 300}, [2]int{15, 400})
if d := buildPassport(doc, vs); d.NoteJump {
t.Errorf("even growth flagged a jump of %d", d.LargestJump)
}
})
t.Run("a paste-shaped jump is flagged", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 20}, [2]int{5, 40}, [2]int{10, 900})
d := buildPassport(doc, vs)
if !d.NoteJump {
t.Fatal("large jump not flagged")
}
if d.LargestJump != 860 {
t.Errorf("largest jump = %d, want 860", d.LargestJump)
}
if !d.LargestJumpAt.Equal(base.Add(10 * time.Minute)) {
t.Errorf("jump at %v, want +10m", d.LargestJumpAt)
}
})
}
func TestVerifyChain(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
good := func() []db.DocumentVersion {
return chained("d1", base, [2]int{0, 50}, [2]int{5, 90}, [2]int{10, 160})
}
t.Run("intact chain verifies", func(t *testing.T) {
got, _ := verifyChain(db.Document{ID: "d1"}, good())
if got != chainVerified {
t.Errorf("got %q, want %q", got, chainVerified)
}
})
t.Run("edited content breaks it", func(t *testing.T) {
vs := good()
vs[1].ContentText = "something else entirely"
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("backdating breaks it", func(t *testing.T) {
vs := good()
vs[2].CreatedAt = base.Add(-time.Hour)
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("a removed snapshot reads as a gap when pruning is allowed", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]} // middle snapshot gone
got, _ := verifyChain(db.Document{ID: "d1"}, pruned)
if got != chainGaps {
t.Errorf("got %q, want %q", got, chainGaps)
}
})
t.Run("a removed snapshot is tampering when history is preserved", func(t *testing.T) {
vs := good()
pruned := []db.DocumentVersion{vs[0], vs[2]}
got, _ := verifyChain(db.Document{ID: "d1", PreserveHistory: true}, pruned)
if got != chainBroken {
t.Errorf("got %q, want %q", got, chainBroken)
}
})
t.Run("pre-chain snapshots are partial, not failures", func(t *testing.T) {
vs := good()
vs[0].ContentHash, vs[0].PrevHash = "", ""
got, unhashed := verifyChain(db.Document{ID: "d1"}, vs)
if got != chainPartial {
t.Errorf("got %q, want %q", got, chainPartial)
}
if unhashed != 1 {
t.Errorf("unhashed = %d, want 1", unhashed)
}
})
t.Run("no hashes at all is unverifiable", func(t *testing.T) {
vs := good()
for i := range vs {
vs[i].ContentHash, vs[i].PrevHash = "", ""
}
if got, _ := verifyChain(db.Document{ID: "d1"}, vs); got != chainUnverifiable {
t.Errorf("got %q, want %q", got, chainUnverifiable)
}
})
}
// The report must survive the degenerate histories — one snapshot, an empty
// document — rather than dividing by a zero span or a zero maximum.
func TestRenderPassportEdgeCases(t *testing.T) {
base := time.Date(2026, 3, 2, 9, 0, 0, 0, time.UTC)
t.Run("no history", func(t *testing.T) {
out := string(renderPassport(buildPassport(db.Document{Title: "Empty"}, nil)))
if !strings.Contains(out, "no saved history") {
t.Errorf("missing empty-state copy:\n%s", out)
}
})
t.Run("single snapshot", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 12})
out := string(renderPassport(buildPassport(db.Document{ID: "d1", Title: "One"}, vs)))
if strings.Contains(out, "NaN") || strings.Contains(out, "+Inf") {
t.Errorf("degenerate geometry leaked into output:\n%s", out)
}
})
t.Run("empty document", func(t *testing.T) {
vs := chained("d1", base, [2]int{0, 0}, [2]int{5, 0})
out := string(renderPassport(buildPassport(db.Document{ID: "d1"}, vs)))
if strings.Contains(out, "NaN") {
t.Errorf("zero word count produced NaN:\n%s", out)
}
})
t.Run("title is escaped", func(t *testing.T) {
doc := db.Document{ID: "d1", Title: ``}
out := string(renderPassport(buildPassport(doc, chained("d1", base, [2]int{0, 5}))))
if strings.Contains(out, "