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

6.9 KB · 227 lines · Go Raw History
  1package main
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"mime/multipart"
  7	"strings"
  8	"testing"
  9)
 10
 11// headers runs the bytes through a real multipart round trip, since that is
 12// what the handler hands readFiles and a hand built FileHeader has no body.
 13func headers(t *testing.T, files map[string][]byte) []*multipart.FileHeader {
 14	t.Helper()
 15	var buf bytes.Buffer
 16	w := multipart.NewWriter(&buf)
 17	names := make([]string, 0, len(files))
 18	for name := range files {
 19		names = append(names, name)
 20	}
 21	for _, name := range names {
 22		p, err := w.CreateFormFile("files", name)
 23		if err != nil {
 24			t.Fatal(err)
 25		}
 26		if _, err := p.Write(files[name]); err != nil {
 27			t.Fatal(err)
 28		}
 29	}
 30	if err := w.Close(); err != nil {
 31		t.Fatal(err)
 32	}
 33	form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(1 << 20)
 34	if err != nil {
 35		t.Fatal(err)
 36	}
 37	t.Cleanup(func() { _ = form.RemoveAll() })
 38	return form.File["files"]
 39}
 40
 41func TestReadsATextFile(t *testing.T) {
 42	parts := readFiles(headers(t, map[string][]byte{"notes.md": []byte("# Title\r\nbody\r\n")}))
 43	if len(parts) != 1 {
 44		t.Fatalf("got %d parts", len(parts))
 45	}
 46	p := parts[0]
 47	if p.Err != "" {
 48		t.Fatalf("unexpected error: %s", p.Err)
 49	}
 50	if p.Kind != "md" {
 51		t.Errorf("kind = %q, want md", p.Kind)
 52	}
 53	if strings.Contains(p.Text, "\r") {
 54		t.Error("carriage returns survived")
 55	}
 56	if p.Text != "# Title\nbody\n" {
 57		t.Errorf("text = %q", p.Text)
 58	}
 59}
 60
 61// An unknown suffix is common on config and log files, and refusing by
 62// extension would drop exactly the files worth asking about.
 63func TestReadsAnUnknownExtension(t *testing.T) {
 64	parts := readFiles(headers(t, map[string][]byte{"hosts.conf.bak": []byte("listen 8000\n")}))
 65	if parts[0].Err != "" {
 66		t.Fatalf("unexpected error: %s", parts[0].Err)
 67	}
 68	if parts[0].Text != "listen 8000\n" {
 69		t.Errorf("text = %q", parts[0].Text)
 70	}
 71}
 72
 73func TestRefusesAnImageAndSaysWhy(t *testing.T) {
 74	png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00, 0x01}, 64)...)
 75	parts := readFiles(headers(t, map[string][]byte{"shot.png": png}))
 76	if parts[0].Err == "" {
 77		t.Fatal("an image was accepted")
 78	}
 79	if !strings.Contains(parts[0].Err, "text only") {
 80		t.Errorf("err = %q, want it to name the reason", parts[0].Err)
 81	}
 82	if parts[0].Kind != "png" {
 83		t.Errorf("kind = %q, want png", parts[0].Kind)
 84	}
 85}
 86
 87// A .txt holding a binary payload is still binary, so the sniff has to beat
 88// the extension in both directions.
 89func TestRefusesBinaryNamedAsText(t *testing.T) {
 90	parts := readFiles(headers(t, map[string][]byte{"payload.txt": {0x00, 0x01, 0x02, 0x03, 0x00}}))
 91	if parts[0].Err == "" {
 92		t.Fatal("a binary file was accepted")
 93	}
 94	if parts[0].Kind != "binary" {
 95		t.Errorf("kind = %q, want binary", parts[0].Kind)
 96	}
 97}
 98
 99// One bad file must not lose the good ones, since a drop of five files where
100// one is a screenshot is the ordinary case.
101func TestOneBadFileDoesNotLoseTheRest(t *testing.T) {
102	parts := readFiles(headers(t, map[string][]byte{
103		"good.txt": []byte("hello"),
104		"bad.png":  append([]byte("\x89PNG\r\n\x1a\n"), 0x00, 0x01),
105	}))
106	if len(parts) != 2 {
107		t.Fatalf("got %d parts", len(parts))
108	}
109	var ok, bad int
110	for _, p := range parts {
111		if p.Err == "" {
112			ok++
113		} else {
114			bad++
115		}
116	}
117	if ok != 1 || bad != 1 {
118		t.Errorf("ok = %d, bad = %d, want one of each", ok, bad)
119	}
120}
121
122func TestTruncatesAtTheFileCeiling(t *testing.T) {
123	big := bytes.Repeat([]byte("a"), maxFileChars+5000)
124	parts := readFiles(headers(t, map[string][]byte{"big.txt": big}))
125	if parts[0].Err != "" {
126		t.Fatalf("unexpected error: %s", parts[0].Err)
127	}
128	if len(parts[0].Text) > maxFileChars+200 {
129		t.Errorf("text is %d characters, want it capped near %d", len(parts[0].Text), maxFileChars)
130	}
131	if !strings.Contains(parts[0].Text, "truncated") {
132		t.Error("truncation was silent")
133	}
134}
135
136// The message goes last because that is where a small model reads most
137// carefully, and a file after the question got answered as if it were one.
138func TestComposeEndsOnTheMessage(t *testing.T) {
139	got := composeTurn("what is wrong with this", []filePart{
140		{Attachment: Attachment{Name: "a.go", Kind: "go", Size: 12}, Text: "package main"},
141	})
142	if !strings.HasSuffix(got, "what is wrong with this") {
143		t.Errorf("does not end on the message:\n%s", got)
144	}
145	if strings.Index(got, "package main") > strings.Index(got, "what is wrong with this") {
146		t.Error("the file came after the message")
147	}
148	if !strings.Contains(got, "a.go") {
149		t.Error("the file was not named")
150	}
151}
152
153// Files with no message is a real turn, and it needs an instruction or the
154// model is handed a wall of text and no question.
155func TestComposeSuppliesAMessageWhenThereIsNone(t *testing.T) {
156	got := composeTurn("", []filePart{
157		{Attachment: Attachment{Name: "a.txt", Kind: "txt", Size: 3}, Text: "abc"},
158	})
159	if !strings.Contains(got, "say what they are") {
160		t.Errorf("no default instruction:\n%s", got)
161	}
162}
163
164func TestComposeNamesAFileItCouldNotRead(t *testing.T) {
165	got := composeTurn("read this", []filePart{
166		{Attachment: Attachment{Name: "shot.png", Kind: "png", Size: 900, Err: "reads text only"}},
167	})
168	if !strings.Contains(got, "shot.png") || !strings.Contains(got, "not readable") {
169		t.Errorf("the unreadable file was not reported:\n%s", got)
170	}
171}
172
173func TestComposeLeavesAPlainTurnAlone(t *testing.T) {
174	if got := composeTurn("hello", nil); got != "hello" {
175		t.Errorf("got %q, want it untouched", got)
176	}
177}
178
179// The title is generated from this, so a file's contents reaching it is how a
180// conversation ends up named after line one of a csv.
181func TestTitleSeedFallsBackToTheNames(t *testing.T) {
182	seed := titleSeed("", []filePart{
183		{Attachment: Attachment{Name: "budget.csv"}, Text: "date,amount\n2026-01-01,12"},
184	})
185	if !strings.Contains(seed, "budget.csv") {
186		t.Errorf("seed = %q", seed)
187	}
188	if strings.Contains(seed, "2026-01-01") {
189		t.Error("the file's contents reached the title")
190	}
191	if got := titleSeed("what is this", nil); got != "what is this" {
192		t.Errorf("got %q, want the message", got)
193	}
194}
195
196func TestHumanSize(t *testing.T) {
197	for _, c := range []struct {
198		in   int64
199		want string
200	}{{512, "512 B"}, {2048, "2.0 KB"}, {5 << 20, "5.0 MB"}} {
201		if got := humanSize(c.in); got != c.want {
202			t.Errorf("humanSize(%d) = %q, want %q", c.in, got, c.want)
203		}
204	}
205}
206
207// The failure that lost a whole turn: a tool call cut off by the token budget
208// arrives as unparseable JSON and llama.cpp refuses the request outright.
209func TestATruncatedToolCallIsRecognised(t *testing.T) {
210	real := fmt.Errorf("the model refused this turn: server_error: Failed to parse tool call arguments as JSON: " +
211		"[json.exception.parse_error.101] parse error at line 1, column 868: syntax error while parsing value - " +
212		"invalid string: missing closing quote")
213	if !isTruncatedToolCall(real) {
214		t.Error("the real refusal was not recognised")
215	}
216	for _, other := range []error{
217		nil,
218		fmt.Errorf("the model is not answering: connection refused"),
219		fmt.Errorf("the model answered 503 with no reason"),
220		fmt.Errorf("failed to parse the response body"),
221	} {
222		if isTruncatedToolCall(other) {
223			t.Errorf("wrongly matched: %v", other)
224		}
225	}
226}