EVENTS.md contract first: ask_owner kind row, approval_phrase and computed resolved payload fields, owner-facing service roles (Quill/Sentry) may post ask_owner, crow ask_owner headers drop to the default tag, 10-minute fail-safe window shared with list approvals. Tests fail: Message has no ApprovalPhrase, Server has no askTimeout.
142 lines
4.6 KiB
Go
142 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// askNest posts a standard ask_owner from Quill and returns the nest id and
|
|
// the stored record. Quill is an owner-facing service role, so this is the
|
|
// shape the spec (CHATROOM_SPEC section 4/6) calls for.
|
|
func askNest(t *testing.T, ts *httptest.Server) (string, map[string]any) {
|
|
t.Helper()
|
|
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
|
|
code, ask := postMsg(t, ts, id, `{"author":"Quill","kind":"ask_owner","to":"owner",`+
|
|
`"text":"Soot wrote fiction on a retrieval job and is quarantined. Reply with the phrase to reinstate the crow.",`+
|
|
`"approval_phrase":"reinstate Hugin"}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("ask_owner post: want 201, got %d %v", code, ask)
|
|
}
|
|
return id, ask
|
|
}
|
|
|
|
// askResolved reads the nest log and returns the ask's computed resolved field.
|
|
func askResolved(t *testing.T, ts *httptest.Server, nestID, askID string) string {
|
|
t.Helper()
|
|
for _, m := range nestMessages(t, ts, nestID) {
|
|
if m["msg_id"] == askID {
|
|
r, ok := m["resolved"].(string)
|
|
if !ok {
|
|
t.Fatalf("ask record has no string resolved field: %v", m)
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("ask %s not in the nest log", askID)
|
|
return ""
|
|
}
|
|
|
|
func TestAskOwnerPostedAndSurfaced(t *testing.T) {
|
|
ts, _ := newTestNestServer(t)
|
|
id, ask := askNest(t, ts)
|
|
if ask["kind"] != "ask_owner" || ask["author"] != "Quill" || ask["to"] != "owner" {
|
|
t.Fatalf("bad ask record: %v", ask)
|
|
}
|
|
if ask["approval_phrase"] != "reinstate Hugin" {
|
|
t.Fatalf("POST record must carry approval_phrase, got %v", ask["approval_phrase"])
|
|
}
|
|
if r := ask["resolved"]; r != "" {
|
|
t.Fatalf("a fresh ask must read unresolved, got %v", r)
|
|
}
|
|
for _, m := range nestMessages(t, ts, id) {
|
|
if m["msg_id"] == ask["msg_id"] {
|
|
if m["approval_phrase"] != "reinstate Hugin" {
|
|
t.Fatalf("GET must surface approval_phrase, got %v", m)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("ask not in GET log")
|
|
}
|
|
|
|
func TestAskOwnerOnlyOwnerResolves(t *testing.T) {
|
|
ts, _ := newTestNestServer(t)
|
|
id, ask := askNest(t, ts)
|
|
askID := ask["msg_id"].(string)
|
|
|
|
code, reply := postMsg(t, ts, id, `{"author":"Hugin","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("crow reply: %d %v", code, reply)
|
|
}
|
|
if r := askResolved(t, ts, id, askID); r == "approved" {
|
|
t.Fatalf("a crow's reply must never approve an ask_owner, got resolved=%v", r)
|
|
}
|
|
|
|
code, yes := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"Reinstate Hugin"}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("owner yes: %d %v", code, yes)
|
|
}
|
|
if r := askResolved(t, ts, id, askID); r != "approved" {
|
|
t.Fatalf("the owner's exact phrase must approve (case-insensitive), got resolved=%v", r)
|
|
}
|
|
found := false
|
|
for _, m := range nestMessages(t, ts, id) {
|
|
if m["author"] == "owner" && m["refers_to"] == askID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("the owner's reply message must be in the log")
|
|
}
|
|
}
|
|
|
|
func TestAskOwnerWrongPhraseStaysOpen(t *testing.T) {
|
|
ts, _ := newTestNestServer(t)
|
|
id, ask := askNest(t, ts)
|
|
askID := ask["msg_id"].(string)
|
|
|
|
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"sounds good"}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("owner reply: %d %v", code, out)
|
|
}
|
|
if r := askResolved(t, ts, id, askID); r != "" {
|
|
t.Fatalf("anything but the exact phrase is not a yes, got resolved=%v", r)
|
|
}
|
|
}
|
|
|
|
func TestAskOwnerTimeoutFailSafe(t *testing.T) {
|
|
ts, s := newTestNestServer(t)
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
stale, err := s.msgs.Append(Message{
|
|
NestID: id,
|
|
Kind: "ask_owner",
|
|
Author: "Sentry",
|
|
Text: "Inky keeps drifting off task. Retire the crow?",
|
|
To: "owner",
|
|
ApprovalPhrase: "retire inky",
|
|
TaggedBy: "header",
|
|
Timestamp: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.askTimeout = time.Minute
|
|
if r := askResolved(t, ts, id, stale.MsgID()); r != "rejected" {
|
|
t.Fatalf("an ask unanswered past the window is not a yes, got resolved=%v", r)
|
|
}
|
|
}
|
|
|
|
func TestAskOwnerCrowHeaderRetagged(t *testing.T) {
|
|
ts, _ := newTestNestServer(t)
|
|
id := newNest(t, ts, "Lab", `"Hugin"`)
|
|
code, out := postMsg(t, ts, id, `{"author":"Hugin","kind":"ask_owner","to":"owner","text":"may I retire the old flyer template?"}`)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("crow ask_owner header: want 201 with a retag, got %d %v", code, out)
|
|
}
|
|
if out["kind"] != "say" {
|
|
t.Fatalf("crows may not post ask_owner: the header must drop to the default tag, got kind=%v", out["kind"])
|
|
}
|
|
}
|