Skip to content
quarry Artifact [a13e9501ae]
Login

Artifact [a13e9501ae]

Artifact a13e9501ae9976851e8d7b1373c4c642c38ed9a6f6222a69a0d795bef126438a:


package api

import (
	"strings"
	"testing"
)

// The author is told what became of their held submission.
//
// This is the piece jva called the sharpest thing in their report: the
// rejection reason exists, is addressed to everyone through the public
// modlog, and is addressed to nobody in particular. A rejected forum
// post never entered the DAG and a rejected attachment has been
// removed, so there is no page to find and no absence to notice — the
// notification is the only way the person learns (b32aeba12bc6).
func TestAModerationDecisionAddressesItsAuthor(t *testing.T) {
	e, ok := moderationEvent("alpha", "jan", "jva", "post", "rejected",
		"How do I clone this?", "off topic — use the chat", "", "staged:7")
	if !ok {
		t.Fatal("FAIL: no event raised — the author was not told")
	}
	if e.To != "jan" {
		t.Errorf("FAIL: addressed to %q, want the author jan", e.To)
	}
	if e.Workflow != "quarry-moderation" {
		t.Errorf("FAIL: workflow %q", e.Workflow)
	}
	title, _ := e.Payload["title"].(string)
	text, _ := e.Payload["text"].(string)
	// WHICH submission, and WHY. A person with several held things
	// learns nothing from "a post was rejected".
	if !strings.Contains(text, "How do I clone this?") {
		t.Errorf("FAIL: the message does not name the submission: %q", text)
	}
	if !strings.Contains(text, "off topic") {
		t.Errorf("FAIL: the message does not carry the reason: %q", text)
	}
	if !strings.Contains(title, "rejected") || !strings.Contains(title, "alpha") {
		t.Errorf("FAIL: the title says neither what happened nor where: %q", title)
	}
	t.Logf("ok: %q / %q", title, text)
}

// A moderator resolving their OWN submission is not told about
// themselves, and an anonymous submission has nobody to tell.
func TestAModerationDecisionIsSilentWhenThereIsNobodyToTell(t *testing.T) {
	for _, c := range []struct{ what, author, moderator string }{
		{"the moderator is the author", "jva", "jva"},
		{"anonymous submission", "", "jva"},
	} {
		if _, ok := moderationEvent("alpha", c.author, c.moderator, "post", "rejected",
			"something", "a reason", "", "staged:1"); ok {
			t.Errorf("FAIL: %s raised an event, want none", c.what)
			continue
		}
		t.Logf("ok: %s is silent", c.what)
	}
}

// A decision with no reason still tells the author. Losing the message
// because the reason was empty would restore the silence this fixes.
func TestAReasonlessDecisionStillReachesTheAuthor(t *testing.T) {
	e, ok := moderationEvent("alpha", "jan", "jva", "attachment", "rejected",
		"crash.png", "", "", "attachment:abc")
	if !ok {
		t.Fatal("FAIL: no event, want one")
	}
	text, _ := e.Payload["text"].(string)
	if !strings.Contains(text, "crash.png") {
		t.Errorf("FAIL: the message does not name the file: %q", text)
	}
	t.Logf("ok: %q", text)
}

// Approval addresses the author too, and it is the half with no other
// teller: a rejected submission changes state in the author's own held
// list, while an approved one LEAVES that list and becomes an ordinary
// post — so nothing is marked for them to come back to.
func TestAnApprovalTellsTheAuthorWhereItWent(t *testing.T) {
	e, ok := moderationEvent("alpha", "jan", "jva", "post", "approved",
		"How do I clone this?", "", "https://forge/r/alpha/forum/abc123", "staged:7")
	if !ok {
		t.Fatal("FAIL: an approval told nobody")
	}
	title, _ := e.Payload["title"].(string)
	link, _ := e.Payload["link"].(string)
	if !strings.Contains(title, "approved") {
		t.Errorf("FAIL: title %q does not say what happened", title)
	}
	if link == "" {
		t.Error("FAIL: an approval with no link — the post is live and the author cannot reach it")
	}
	t.Logf("ok: %q -> %s", title, link)
}

// The idempotency key is derived from the submission and the decision,
// never from the clock, so a retried approval does not arrive twice.
func TestTheModerationKeyIsStableAcrossRetries(t *testing.T) {
	a, _ := moderationEvent("alpha", "jan", "jva", "post", "approved", "x", "", "l", "staged:7")
	b, _ := moderationEvent("alpha", "jan", "jva", "post", "approved", "x", "", "l", "staged:7")
	if a.TxnID != b.TxnID || a.TxnID == "" {
		t.Fatalf("FAIL: keys %q and %q — a retry would notify twice", a.TxnID, b.TxnID)
	}
	// And a DIFFERENT decision on the same submission is a different
	// event: approving after a reopen must not be deduped away.
	c, _ := moderationEvent("alpha", "jan", "jva", "post", "rejected", "x", "", "l", "staged:7")
	if c.TxnID == a.TxnID {
		t.Fatalf("FAIL: approve and reject share the key %q", a.TxnID)
	}
	t.Logf("ok: %s is stable, and distinct from the rejection's", a.TxnID)
}

// WHAT THE READER SEES, which is not what the payload carries.
//
// The first version of this feature passed its own tests and told
// nobody anything. They asserted that Payload["text"] held the reason —
// true, and useless, because no author-facing surface renders that key.
// attentionOf builds the summary from "summary", else "title", else the
// text; with a title and no summary it took the title, and the inbox
// row shows the summary. So the row read "Your post in X was rejected"
// and stopped there, while the reason sat in the public modlog
// addressed to everyone — the exact sentence this work set out to kill
// (jva, G-136).
//
// This test goes through attentionOf rather than reading the payload,
// because that is the boundary the earlier tests stopped short of.
func TestTheRejectionAnAuthorActuallySeesCarriesTheReason(t *testing.T) {
	e, ok := moderationEvent("alpha", "jan", "jva", "post", "rejected",
		"How do I clone this?", "off topic — please use the chat",
		"https://forge/r/alpha/forum/modlog", "staged:5")
	if !ok {
		t.Fatal("FAIL: no event")
	}
	who, ae, personal := attentionOf(e)
	if !personal || who != "jan" {
		t.Fatalf("FAIL: personal=%v to=%q", personal, who)
	}
	// The summary is the line the inbox row renders.
	for _, want := range []string{"How do I clone this?", "off topic"} {
		if !strings.Contains(ae.Summary, want) {
			t.Errorf("FAIL: the row an author reads never says %q: %q", want, ae.Summary)
		}
	}
	// And a rejection must be reachable: there is no post to open, so
	// without the log link the reason is unfindable.
	if ae.Link == "" {
		t.Error("FAIL: a rejection with no link — the submission is gone and the reason " +
			"lives in a log the author is never pointed at")
	}
	t.Logf("ok: %q -> %s", ae.Summary, ae.Link)
}

// The approval an author sees names the post and links to it.
func TestTheApprovalAnAuthorActuallySeesReachesThePost(t *testing.T) {
	e, _ := moderationEvent("alpha", "jan", "jva", "post", "approved",
		"How do I clone this?", "", "https://forge/r/alpha/forum/abc123", "staged:5")
	_, ae, _ := attentionOf(e)
	if !strings.Contains(ae.Summary, "How do I clone this?") {
		t.Errorf("FAIL: the row does not name the post: %q", ae.Summary)
	}
	if ae.Link == "" {
		t.Error("FAIL: approved with nowhere to go")
	}
	t.Logf("ok: %q -> %s", ae.Summary, ae.Link)
}