repos
/ orchard main

orchard

mirror

Every 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

5.9 KB · 203 lines · Go Raw History
  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}