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)
}
// EVERY path that resolves a held thing tells its author.
//
// Written after counting the call sites rather than the cases I
// remembered: three places resolve a hold, and only two of them told
// anybody. A forum post being approved reached its author while an
// attachment being approved reached nobody — the same asymmetry jva
// found for rejection, pointing the other way, and produced the same
// way: by fixing the path in front of me.
//
// This is the table to extend when a fourth kind of held thing appears,
// rather than a test per surface.
func TestEveryKindOfDecisionAddressesItsAuthor(t *testing.T) {
for _, c := range []struct{ noun, state, what, link string }{
{"post", "approved", "How do I clone this?", "https://forge/r/a/forum/abc"},
{"post", "rejected", "How do I clone this?", "https://forge/r/a/forum/modlog"},
{"attachment", "approved", "crash.png", "https://forge/r/a/attachment/def/get"},
{"attachment", "rejected", "spam.gif", "https://forge/r/a/forum/modlog"},
} {
e, ok := moderationEvent("a", "jan", "jva", c.noun, c.state, c.what, "because", c.link, "id1")
if !ok {
t.Errorf("FAIL: %s %s told nobody", c.noun, c.state)
continue
}
_, ae, personal := attentionOf(e)
if !personal {
t.Errorf("FAIL: %s %s is not person-addressed", c.noun, c.state)
continue
}
if !strings.Contains(ae.Summary, c.what) {
t.Errorf("FAIL: %s %s does not name the thing: %q", c.noun, c.state, ae.Summary)
}
if ae.Link == "" {
t.Errorf("FAIL: %s %s has nowhere to go", c.noun, c.state)
}
t.Logf("ok: %s %s -> %q", c.noun, c.state, ae.Summary)
}
}
// The line a client renders says BOTH what happened and to what.
//
// Two over-corrections in a row produced this test. The first version
// put the verb in the title and the substance in the summary, and the
// inbox renders the summary — so the row said "was rejected" and never
// which submission. The fix moved the substance in and left the verb
// out, so the row named the post and not its fate: a client showing
// only `summary` displayed a bare title (jva, probing for the case
// where an approval and a rejection read alike).
//
// They cannot collide, because a rejection must carry a reason and an
// approval has none — but a reader should not have to infer an outcome
// from the presence of a dash.
func TestTheRenderedLineStatesTheOutcome(t *testing.T) {
for _, state := range []string{"approved", "rejected"} {
e, _ := moderationEvent("alpha", "jan", "jva", "post", state,
"probe", "a reason", "https://forge/l", "staged:1")
_, ae, _ := attentionOf(e)
if !strings.Contains(strings.ToLower(ae.Summary), state) {
t.Errorf("FAIL: the rendered line never says %q: %q", state, ae.Summary)
}
if !strings.Contains(ae.Summary, "probe") {
t.Errorf("FAIL: the rendered line does not name the submission: %q", ae.Summary)
}
t.Logf("ok: %q", ae.Summary)
}
}