package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// The document under review: two paragraphs under one heading, one of which the
// proposal edits. Block 1 is "The first paragraph."; block 2 is the edited one.
const (
commentBase = "# Storage\n\nThe first paragraph.\n\nThe second paragraph.\n"
commentProposed = "# Storage\n\nThe first paragraph.\n\nThe second paragraph, revised.\n"
// It has no frontmatter, so its anchoring key is its path minus the
// extension — the archive's rule for a document with no well-formed id.
commentDocID = "specs/0007-storage"
)
func commentDocs() []service.ProposalDoc {
return []service.ProposalDoc{{
Path: "specs/0007-storage.md",
Base: []byte(commentBase),
Proposed: []byte(commentProposed),
}}
}
// seedThread hangs a thread off one block of the proposed document, anchored the
// way the service would anchor it — through service.AnchorOf, so the test cannot
// disagree with production about which block it named.
func seedThread(t *testing.T, r *fakeReader, proposalID, ordinal int, body string) *service.Thread {
t.Helper()
anchor, err := service.AnchorOf(commentDocID, []byte(commentProposed), ordinal, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
th := &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: body},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
}
r.threads[proposalID] = append(r.threads[proposalID], th)
return th
}
// postForm issues a form POST with a urlencoded body, as a browser would.
func postForm(t *testing.T, h http.Handler, target, user, origin string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if user != "" {
login(req, user)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// commentServer is a fake reader holding one open proposal with one changed
// document, plus the handler.
func commentServer(t *testing.T) (http.Handler, *fakeReader) {
t.Helper()
r := newFakeReader()
seedProposal(r, openProposal(), commentDocs())
h, reader, _ := testServerWith(t, r)
return h, reader
}
// TestDocIDForFollowsTheArchiveAddressingRule proves a comment anchors to the
// document's frontmatter id when it has a well-formed one — which is what lets
// the comment survive a rename — and to its path when it does not.
func TestDocIDForFollowsTheArchiveAddressingRule(t *testing.T) {
withID := []byte("---\nid: SPEC-0007\ntitle: Storage\n---\n\n# Storage\n")
if got := docIDFor("specs/0007-storage.md", withID); got != "SPEC-0007" {
t.Errorf("docIDFor = %q, want the frontmatter id", got)
}
if got := docIDFor("notes/plain.md", []byte("# Just a note\n")); got != "notes/plain" {
t.Errorf("docIDFor = %q, want the path without its extension", got)
}
}
// TestProposalPageDrawsComposeFormsForTheOwner proves the owner can start a
// thread on a block — including on one the proposal did not change, which is
// the whole point of rendering unchanged blocks.
func TestProposalPageDrawsComposeFormsForTheOwner(t *testing.T) {
h, _ := commentServer(t)
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "/p/7/comment") {
t.Fatalf("the owner has no compose form; body:\n%s", body)
}
// One form per rendered block: heading, unchanged paragraph, changed one.
if n := strings.Count(body, `action="/~bigbes/rfcs/p/7/comment"`); n != 3 {
t.Errorf("%d compose forms, want one per rendered block (3); body:\n%s", n, body)
}
if !strings.Contains(body, `name="side" value="new"`) {
t.Errorf("the compose form does not name the side it anchors to; body:\n%s", body)
}
}
// TestProposalPageHidesOwnerControlsFromAnAgent proves a principal who may read
// and reply is not offered the two controls that are the owner's: opening a
// thread and resolving one.
func TestProposalPageHidesOwnerControlsFromAnAgent(t *testing.T) {
h, r := commentServer(t)
seedThread(t, r, 7, 1, "please reword this")
body := getAgent(t, h, "/~bigbes/rfcs/p/7", agentTk).Body.String()
if !strings.Contains(body, "please reword this") {
t.Fatalf("an agent cannot see the comments on its own proposal; body:\n%s", body)
}
if strings.Contains(body, "/p/7/comment") {
t.Errorf("an agent was offered the compose form; body:\n%s", body)
}
if strings.Contains(body, "/p/7/resolve") {
t.Errorf("an agent was offered the resolve control; body:\n%s", body)
}
if !strings.Contains(body, "/p/7/reply") {
t.Errorf("an agent was not offered the reply form, which is its half of the loop; body:\n%s", body)
}
}
// TestProposalPageMarksAnEditedAnchor proves a comment whose block has changed
// since it was written is shown — the reviewer still needs it — and marked, so
// nobody reads a stale critique as a current one.
func TestProposalPageMarksAnEditedAnchor(t *testing.T) {
h, r := commentServer(t)
// Anchored to the second paragraph as it read before the revision, so the
// hash misses and heading-path + index carries it: edited, not lost.
anchor, err := service.AnchorOf(commentDocID, []byte(commentBase), 2, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "the tense is wrong"},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "the tense is wrong") {
t.Fatalf("an edited-anchor comment is not on the page; body:\n%s", body)
}
if !strings.Contains(body, "block edited since") {
t.Errorf("an edited anchor is shown as if it still fitted; body:\n%s", body)
}
if strings.Contains(body, "lost their anchor") {
t.Errorf("an edited anchor was demoted to lost; body:\n%s", body)
}
}
// TestProposalPageKeepsAnOutdatedComment proves a comment whose block is gone is
// still on the page, in the area that says so, and is not attached to whatever
// block happens to be nearby.
func TestProposalPageKeepsAnOutdatedComment(t *testing.T) {
h, r := commentServer(t)
// Anchored to a paragraph that exists in neither revision on the page.
const gone = commentBase + "\nA paragraph the revision dropped.\n"
anchor, err := service.AnchorOf(commentDocID, []byte(gone), 3, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "this claim is unsupported"},
DocPath: "specs/0007-storage.md",
Anchor: anchor,
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "this claim is unsupported") {
t.Fatalf("an outdated comment was dropped from the page; body:\n%s", body)
}
lost := strings.Index(body, "Comments that lost their anchor")
if lost < 0 {
t.Fatalf("no area for comments whose anchor is lost; body:\n%s", body)
}
// It belongs to that area and to no block: the diff ends before it.
if strings.Index(body, "this claim is unsupported") < lost {
t.Errorf("an outdated comment was rendered against a block; body:\n%s", body)
}
}
// TestProposalPageKeepsCommentsOnARevertedDocument proves a thread whose
// document the proposal no longer changes — so no diff renders it at all — is
// still shown rather than silently disappearing.
func TestProposalPageKeepsCommentsOnARevertedDocument(t *testing.T) {
h, r := commentServer(t)
r.nextThread++
r.threads[7] = append(r.threads[7], &service.Thread{
Root: service.Comment{ID: r.nextThread, Author: "bigbes", Body: "comment on a reverted file"},
DocPath: "specs/reverted.md",
Block: -1,
})
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if !strings.Contains(body, "comment on a reverted file") {
t.Fatalf("a comment on a document no longer changed was dropped; body:\n%s", body)
}
if !strings.Contains(body, "specs/reverted.md") {
t.Errorf("the lost comment does not say which document it came from; body:\n%s", body)
}
}
// TestCommentBodyIsEscaped proves a comment is text: markup in a body cannot
// become markup on the page.
func TestCommentBodyIsEscaped(t *testing.T) {
h, r := commentServer(t)
seedThread(t, r, 7, 1, `
`)
body := get(t, h, "/~bigbes/rfcs/p/7", "bigbes").Body.String()
if strings.Contains(body, ")