Artifact 888304da9c9507ff17dfb60992753a795855d57ea39a10d52be8a1eeab95de90:
- File
internal/harness/write_test.go
— part of check-in
[4b68c8aae4]
at
2026-08-16 10:21:17
on branch trunk
— write plane: web-authored forum and technotes (v2)
forum.post/forum.reply/technote.add Command ops on zit#76's verbs, lease-guarded with read-your-writes and activity triggers; new-thread, reply, new-technote and technote-edit forms, all gated. Two correctness points: technote.add widens an event-id prefix to the full id (a prefix would mint a NEW technote instead of editing), and forum.reply shares forum.get's {root} path parameter (differing names on one template is invalid OpenAPI — spec-lint caught it).
Fossil has the same surfaces (technoteedit, forumedit), so these are web faces of existing record types, not invented divergence. Wiki authoring already shipped earlier. Verified live in-browser: thread -> reply -> timeline, and technote edit superseding without duplicating. (user: dmestas, size: 7195) [annotate] [blame] [check-ins using]
package harness import ( "io" "net/http" "net/url" "strings" "testing" ) // TestWriteOps drives the wiki/ticket write path the way a browser // does: form POSTs through the web handler, gated by Basic auth, with // read-your-writes (the op syncs before answering). func TestWriteOps(t *testing.T) { stores := map[string]string{"alpha": buildStore(t, "writeseed")} dataDir := t.TempDir() srv := ingestAndServeAuth(t, stores, dataDir, func(repo, user, pass string) bool { return repo == "alpha" && user == "dan" && pass == "hunter2" }) client := srv.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } post := func(path string, form url.Values, auth bool) *http.Response { req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if auth { req.SetBasicAuth("dan", "hunter2") } resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp } get := func(path string) string { resp, err := client.Get(srv.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } // Unauthenticated write: challenged, nothing recorded. if resp := post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"nope"}}, false); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthenticated wiki save: status %d", resp.StatusCode) } // Authenticated wiki save: redirect, then the page serves the body // and the history records the author. if resp := post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"# Guide\n\nwritten from the web\n"}}, true); resp.StatusCode != http.StatusSeeOther { t.Fatalf("wiki save: status %d", resp.StatusCode) } if body := get("/r/alpha/wiki/Guide"); !strings.Contains(body, "written from the web") { t.Fatalf("saved wiki not served:\n%s", body) } if body := get("/r/alpha/wiki/Guide/history"); !strings.Contains(body, "dan") { t.Fatalf("author missing from history:\n%s", body) } // A second save supersedes the first: zit's millisecond D card // (zit#75) gives same-second writes real submission order, so // last-write-wins holds and the history records both revisions. post("/r/alpha/wiki/Guide/edit", url.Values{"body": {"revised from the web\n"}}, true) hist := get("/r/alpha/wiki/Guide/history") if n := strings.Count(hist, "<li>"); n < 2 { t.Fatalf("second revision not recorded (%d in history):\n%s", n, hist) } if body := get("/r/alpha/wiki/Guide"); !strings.Contains(body, "revised from the web") { t.Fatalf("latest save is not current:\n%s", body) } // Ticket: new, then set, both visible immediately. resp := post("/r/alpha/ticket/new", url.Values{ "title": {"web-born ticket"}, "comment": {"filed from the form"}, }, true) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("ticket new: status %d", resp.StatusCode) } loc := resp.Header.Get("Location") if body := get(loc); !strings.Contains(body, "web-born ticket") || !strings.Contains(body, "Open") { t.Fatalf("new ticket page:\n%s", body) } id := strings.TrimPrefix(loc, "/r/alpha/ticket/") if resp := post("/r/alpha/ticket/"+id[:10]+"/edit", url.Values{ "status": {"Closed"}, "resolution": {"Fixed"}, }, true); resp.StatusCode != http.StatusSeeOther { t.Fatalf("ticket set: status %d", resp.StatusCode) } if body := get(loc); !strings.Contains(body, "Closed") || !strings.Contains(body, "Fixed") { t.Fatalf("ticket change not folded:\n%s", body) } if body := get("/r/alpha/timeline"); !strings.Contains(body, "web-born ticket") && !strings.Contains(body, "Ticket") { t.Fatalf("ticket missing from timeline:\n%s", body) } } // TestForumTechnoteAuthoring drives the v2 write plane for the two // record types zit#76 unlocked: open a thread, reply to it, add a // technote, then edit that technote — all through the forms, gated. func TestForumTechnoteAuthoring(t *testing.T) { stores := map[string]string{"alpha": buildStore(t, "authorseed")} srv := ingestAndServeAuth(t, stores, t.TempDir(), func(repo, user, pass string) bool { return user == "dan" && pass == "pw" }) client := srv.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } post := func(path string, form url.Values, auth bool) *http.Response { req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if auth { req.SetBasicAuth("dan", "pw") } resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp } get := func(path string) string { resp, err := client.Get(srv.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } // Unauthenticated thread creation is challenged. if resp := post("/r/alpha/forum-new", url.Values{"title": {"nope"}, "body": {"x"}}, false); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthenticated forum post: status %d", resp.StatusCode) } // Open a thread; the redirect names it. resp := post("/r/alpha/forum-new", url.Values{ "title": {"Web-born thread"}, "body": {"opening from the form\n"}, }, true) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("forum-new: status %d", resp.StatusCode) } loc := resp.Header.Get("Location") if body := get(loc); !strings.Contains(body, "opening from the form") { t.Fatalf("thread page:\n%s", body) } if body := get("/r/alpha/forum"); !strings.Contains(body, "Web-born thread") { t.Fatalf("thread missing from list:\n%s", body) } // Reply in that thread: two posts, and the reply names its parent. if resp := post(loc+"/reply", url.Values{"body": {"answering from the form\n"}}, true); resp.StatusCode != http.StatusSeeOther { t.Fatalf("reply: status %d", resp.StatusCode) } body := get(loc) if !strings.Contains(body, "answering from the form") || !strings.Contains(body, "in reply to") { t.Fatalf("reply not threaded:\n%s", body) } if n := strings.Count(body, "<article"); n != 2 { t.Fatalf("thread has %d posts, want 2", n) } // Add a technote, then edit it: the edit supersedes, and the event // id is stable across the edit (one technote, not two). resp = post("/r/alpha/technote-new", url.Values{"body": {"# Note\n\nfirst version\n"}}, true) if resp.StatusCode != http.StatusSeeOther { t.Fatalf("technote-new: status %d", resp.StatusCode) } noteLoc := resp.Header.Get("Location") if b := get(noteLoc); !strings.Contains(b, "first version") { t.Fatalf("technote page:\n%s", b) } if resp := post(noteLoc+"/edit", url.Values{"body": {"# Note\n\nsecond version\n"}}, true); resp.StatusCode != http.StatusSeeOther { t.Fatalf("technote edit: status %d", resp.StatusCode) } if b := get(noteLoc); !strings.Contains(b, "second version") || strings.Contains(b, "first version") { t.Fatalf("edit did not supersede:\n%s", b) } if list := get("/r/alpha/technote"); strings.Count(list, "<li>") != 1 { t.Fatalf("edit created a second technote:\n%s", list) } }