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, "