orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11func testHistory(t *testing.T) *History {
12 t.Helper()
13 h, _ := testHistoryDir(t)
14 return h
15}
16
17func testHistoryDir(t *testing.T) (*History, string) {
18 t.Helper()
19 dir, err := os.MkdirTemp("", "search-hist")
20 if err != nil {
21 t.Fatal(err)
22 }
23 t.Cleanup(func() { os.RemoveAll(dir) })
24 h, err := OpenHistory(dir)
25 if err != nil {
26 t.Fatal(err)
27 }
28 t.Cleanup(func() { h.Close() })
29 return h, dir
30}
31
32func sampleAnswer() *Answer {
33 return &Answer{
34 Query: "when is the next liverpool game", Standalone: "when is the next liverpool game",
35 Shape: ShapeUpcoming, Text: "They play Fulham on **12 September 2026** [1].",
36 Queries: []string{"liverpool fixtures september 2026"},
37 Sources: []Source{
38 {N: 1, URL: "https://sports-calendar.com/en/soccer/liverpool", Site: "sports-calendar.com"},
39 {N: 2, URL: "https://www.thisisanfield.com/2026/09/fixtures", Site: "This Is Anfield"},
40 },
41 Warnings: []string{"a source names 9 September"},
42 Support: 1, Elapsed: "10.2s",
43 Citations: []Citation{{Checked: true, Supported: true, PassageID: 1}},
44 }
45}
46
47func TestHistoryLogAndList(t *testing.T) {
48 h := testHistory(t)
49 id, err := h.Log(sampleAnswer(), Stamp{Model: "unsloth/Qwen3.5-4B-GGUF:Q4_K_M", Prompts: "abc123", Sampling: "s"})
50 if err != nil || id == 0 {
51 t.Fatalf("log: id=%d err=%v", id, err)
52 }
53 got, err := h.List(10, 0, "")
54 if err != nil || len(got) != 1 {
55 t.Fatalf("list: %d rows, err=%v", len(got), err)
56 }
57 e := got[0]
58 if e.Question != "when is the next liverpool game" || string(ShapeUpcoming) != e.Shape {
59 t.Errorf("round trip lost the question or shape: %+v", e)
60 }
61 if len(e.Sources) != 2 || len(e.Queries) != 1 || len(e.Warnings) != 1 {
62 t.Errorf("json columns did not round trip: %+v", e)
63 }
64 if e.Model == "" || e.Prompts == "" {
65 t.Error("an answer with no stamp cannot be read back next month")
66 }
67 if e.Rated() {
68 t.Error("a fresh answer is unrated")
69 }
70 if !strings.Contains(string(e.Body()), "<strong>") {
71 t.Errorf("the stored markdown should render, got %q", e.Body())
72 }
73}
74
75// A thumb moves the domains behind the answer, and changing your mind has to
76// move them back rather than counting both.
77func TestRateMovesDomainsAndReverses(t *testing.T) {
78 h := testHistory(t)
79 id, _ := h.Log(sampleAnswer(), Stamp{})
80
81 if err := h.Rate(id, 1, "", ""); err != nil {
82 t.Fatal(err)
83 }
84 rep := h.Reputation()
85 if len(rep) != 2 {
86 t.Fatalf("want both domains scored, got %v", rep)
87 }
88 // Laplace smoothing: one good out of one is 2/3, not 1.
89 if got := rep["sports-calendar.com"]; got < 0.66 || got > 0.67 {
90 t.Errorf("one good answer should score 2/3, got %.3f", got)
91 }
92
93 if err := h.Rate(id, -1, "sources", ""); err != nil {
94 t.Fatal(err)
95 }
96 if got := h.Reputation()["sports-calendar.com"]; got < 0.33 || got > 0.34 {
97 t.Errorf("changing the verdict should not leave both counted, got %.3f", got)
98 }
99
100 only, err := h.List(10, 0, "down")
101 if err != nil || len(only) != 1 || only[0].Reason != "sources" {
102 t.Fatalf("filtering to down thumbs: %d rows err=%v", len(only), err)
103 }
104 if !only[0].Bad() {
105 t.Error("that one is thumbed down")
106 }
107 if got, _ := h.List(10, 0, "up"); len(got) != 0 {
108 t.Errorf("it is no longer an up thumb, got %d", len(got))
109 }
110}
111
112// Deleting a question has to leave the reputation standing, since that holds no
113// question text and is what the site learned rather than what was asked.
114func TestDeleteKeepsWhatWasLearned(t *testing.T) {
115 h := testHistory(t)
116 id, _ := h.Log(sampleAnswer(), Stamp{})
117 h.Rate(id, 1, "", "")
118
119 if err := h.Delete(id); err != nil {
120 t.Fatal(err)
121 }
122 if got, _ := h.List(10, 0, ""); len(got) != 0 {
123 t.Fatalf("the question should be gone, got %d rows", len(got))
124 }
125 if total, rated := h.Count(); total != 0 || rated != 0 {
126 t.Errorf("counts should be zero, got %d and %d", total, rated)
127 }
128 if len(h.Reputation()) != 2 {
129 t.Error("deleting a question should not delete what it taught")
130 }
131
132 id2, _ := h.Log(sampleAnswer(), Stamp{})
133 h.Rate(id2, -1, "wrong", "")
134 if err := h.DeleteAll(); err != nil {
135 t.Fatal(err)
136 }
137 if got, _ := h.List(10, 0, ""); len(got) != 0 {
138 t.Errorf("delete everything should leave nothing, got %d", len(got))
139 }
140}
141
142// The version stamp has to move when a prompt does, or a month of thumbs
143// cannot be told apart from the month before it.
144func TestPromptVersionMovesWithTheContracts(t *testing.T) {
145 before := promptVersion()
146 if len(before) != 12 {
147 t.Fatalf("want a short hash, got %q", before)
148 }
149 c := contracts[ShapeUpcoming]
150 original := c.Instruction
151 c.Instruction += " One more rule."
152 contracts[ShapeUpcoming] = c
153 t.Cleanup(func() {
154 c.Instruction = original
155 contracts[ShapeUpcoming] = c
156 })
157 if promptVersion() == before {
158 t.Error("editing a contract has to change the prompt version")
159 }
160}
161
162// Deleting a row is not deleting the text. secure_delete zeroes it in place and
163// VACUUM rewrites the file, but both write through the WAL, so without the
164// checkpoint the question is still there in plain bytes beside the database.
165// The only honest test of that is to read the files.
166func TestDeleteLeavesNothingOnDisk(t *testing.T) {
167 h, dir := testHistoryDir(t)
168 a := sampleAnswer()
169 a.Query = "a question nobody else would ask zzqqxx"
170 a.Text = "an answer nobody else would write zzqqxx."
171 id, err := h.Log(a, Stamp{})
172 if err != nil {
173 t.Fatal(err)
174 }
175 if !onDisk(t, dir, "zzqqxx") {
176 t.Fatal("the question should be on disk before it is deleted, or this proves nothing")
177 }
178 if err := h.Delete(id); err != nil {
179 t.Fatal(err)
180 }
181 if onDisk(t, dir, "zzqqxx") {
182 t.Error("a deleted question is still readable in the database directory")
183 }
184}
185
186func onDisk(t *testing.T, dir, needle string) bool {
187 t.Helper()
188 entries, err := os.ReadDir(dir)
189 if err != nil {
190 t.Fatal(err)
191 }
192 for _, e := range entries {
193 b, err := os.ReadFile(filepath.Join(dir, e.Name()))
194 if err != nil {
195 continue
196 }
197 if bytes.Contains(b, []byte(needle)) {
198 return true
199 }
200 }
201 return false
202}