clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyclient / chat_activity_test.go
chat_activity_test.go 9.6K
1package tinyclient23import (4 "context"5 "strings"6 "testing"78 "github.com/FelineStateMachine/tinyrelay/internal/policy"9)1011func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {12 actor := strings.Repeat("a", 64)13 other := strings.Repeat("b", 64)14 jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "state": "open", "status": "running", "content": "Indexing files", "progress": map[string]any{"id": strings.Repeat("9", 64), "provider": actor, "content": "Halfway through"}}}}15 approvals := map[string]any{"items": []any{16 map[string]any{"id": strings.Repeat("2", 64), "asker": other, "asked": []any{actor}, "subject": "Publish result", "state": "open", "kind": "4550"},17 map[string]any{"id": strings.Repeat("3", 64), "asker": other, "asked": []any{other}, "subject": "Private request", "state": "open", "kind": "4550"},18 }}19 items := chatActivityView(jobs, approvals, actor, "/chat/activity", "general")20 if len(items) != 2 {21 t.Fatalf("got %d activity items, want job and addressed approval", len(items))22 }23 if items[0].Type != "job" || items[0].Status != "running" || items[0].Title != "Indexing files" || items[0].Author != other || items[0].Progress != "Halfway through" {24 t.Fatalf("unexpected job card: %#v", items[0])25 }26 if items[1].Type != "approval" || !items[1].CanDecide || items[1].ID != strings.Repeat("2", 64) {27 t.Fatalf("unexpected approval card: %#v", items[1])28 }29}3031func TestChatActivityViewDisablesAnsweredApproval(t *testing.T) {32 actor := strings.Repeat("a", 64)33 approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []any{actor}, "content": "Done", "state": "answered"}}}34 items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")35 if len(items) != 1 || items[0].CanDecide {36 t.Fatalf("answered approval remained actionable: %#v", items)37 }38}3940func TestChatActivityAskerCannotApproveOwnRequest(t *testing.T) {41 actor := strings.Repeat("a", 64)42 approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []string{actor, strings.Repeat("b", 64)}, "state": "open"}}}43 items := chatActivityView(nil, approvals, actor, "/chat/activity", "main")44 if len(items) != 1 || items[0].CanDecide {45 t.Fatalf("asker allowed to decide: %#v", items)46 }47}4849func TestChatActivityPreservesAnsweredQuestion(t *testing.T) {50 actor := strings.Repeat("a", 64)51 other := strings.Repeat("b", 64)52 approvals := map[string]any{"items": []any{map[string]any{53 "id": strings.Repeat("4", 64), "asker": other, "asked": []string{actor},54 "type": "question", "subject": "Which preview?", "state": "answered",55 "answer": map[string]any{"decision": "replied", "content": "Use the dark preview."},56 }}}57 items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")58 if len(items) != 1 || items[0].Answer != "Use the dark preview." || items[0].CanDecide {59 t.Fatalf("answered question = %#v", items)60 }61}6263func TestChatActivityViewCarriesNativeOptions(t *testing.T) {64 actor := strings.Repeat("a", 64)65 other := strings.Repeat("b", 64)66 approvals := map[string]any{"items": []any{map[string]any{67 "id": strings.Repeat("5", 64), "asker": other, "asked": []string{actor}, "kind": 9,68 "type": "question", "state": "open", "selection": "multiple",69 "options": []any{map[string]any{"id": "continue", "label": "Continue playing"}, map[string]any{"id": "stop", "label": "Stop here"}},70 }}}71 items := chatActivityView(nil, approvals, actor, "/chat/activity", "main")72 if len(items) != 1 || !items[0].Native || !items[0].Multiple || len(items[0].Options) != 2 || items[0].Options[1].Label != "Stop here" {73 t.Fatalf("native options = %#v", items)74 }75}7677func TestChatActivityShowsChoiceLabelsAndExplanation(t *testing.T) {78 actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)79 for _, tt := range []struct{ name, content, want string }{80 {"choice", "c1", "No, something looks wrong"},81 {"choices", `["c0","c1"]`, "Yes, it looks right\nNo, something looks wrong"},82 {"explanation", `{"choices":["c1"],"text":"Keep it in this thread."}`, "No, something looks wrong\n\nKeep it in this thread."},83 {"custom", `{"text":"A different answer."}`, "A different answer."},84 {"unknown", `{"choices":["unknown"],"text":"Details."}`, `{"choices":["unknown"],"text":"Details."}`},85 } {86 t.Run(tt.name, func(t *testing.T) {87 approvals := map[string]any{"items": []any{map[string]any{88 "id": strings.Repeat("5", 64), "asker": other, "asked": []string{actor}, "kind": 9,89 "type": "question", "interaction": "question", "state": "answered", "selection": "single",90 "options": []any{map[string]any{"id": "c0", "label": "Yes, it looks right"}, map[string]any{"id": "c1", "label": "No, something looks wrong"}},91 "answer": map[string]any{"decision": "replied", "content": tt.content},92 }}}93 items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")94 if len(items) != 1 || items[0].Answer != tt.want || items[0].CanDecide {95 t.Fatalf("answered question = %#v; want %q", items, tt.want)96 }97 })98 }99}100101func TestChatActivityJobCardsCarryResultsArtifactsAndErrors(t *testing.T) {102 actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)103 done := map[string]any{"id": strings.Repeat("1", 64), "requester": actor, "assignees": []any{other}, "subject": "Build the preview", "content": "Build it with the dark palette.", "state": "done", "status": "done", "result": map[string]any{"id": strings.Repeat("2", 64), "provider": other, "content": "Preview is up.", "artifacts": []any{104 map[string]any{"type": "e", "value": strings.Repeat("3", 64)},105 map[string]any{"type": "a", "value": "30818:" + other + ":release notes"},106 map[string]any{"type": "r", "value": "https://preview.example/site"},107 map[string]any{"type": "r", "value": "javascript:alert(1)"},108 map[string]any{"type": "e", "value": "short"},109 }}}110 failed := map[string]any{"id": strings.Repeat("4", 64), "requester": actor, "content": "Transcribe the call.", "state": "failed", "status": "failed", "error": map[string]any{"id": strings.Repeat("5", 64), "provider": other, "content": "no audio track"}}111 queued := map[string]any{"id": strings.Repeat("6", 64), "requester": actor, "subject": "Later"}112 items := chatActivityView(map[string]any{"items": []any{done, failed, queued}}, nil, actor, "/chat/activity", "main")113 if len(items) != 3 {114 t.Fatalf("got %d cards", len(items))115 }116 card := items[0]117 if card.Title != "Build the preview" || card.State != "done" || card.Result == nil || card.Result.Content != "Preview is up." || len(card.Result.Artifacts) != 3 {118 t.Fatalf("done card = %#v", card)119 }120 links := card.Result.Artifacts121 if links[0].Href != "/e/"+strings.Repeat("3", 64) || links[1].Href != "/a/30818:"+other+":release%20notes" || links[1].Label != "release notes" || links[2].Href != "https://preview.example/site" {122 t.Fatalf("artifact links = %#v", links)123 }124 if card := items[1]; card.State != "failed" || card.Error == nil || card.Error.Content != "no audio track" || card.Result != nil {125 t.Fatalf("failed card = %#v", card)126 }127 if card := items[2]; card.State != "open" || card.Status != "queued" || card.Title != "Later" {128 t.Fatalf("queued card = %#v", card)129 }130}131132func TestChatActivityOnlyTheRequesterMayCancelAnOpenTask(t *testing.T) {133 actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)134 jobs := map[string]any{"items": []any{135 map[string]any{"id": strings.Repeat("1", 64), "requester": actor, "assignees": []any{other, "short", strings.Repeat("c", 64)}, "subject": "Mine, open", "created_at": 1},136 map[string]any{"id": strings.Repeat("2", 64), "requester": actor, "assignees": []any{other}, "subject": "Mine, done", "state": "done", "status": "done", "created_at": 2},137 map[string]any{"id": strings.Repeat("3", 64), "requester": other, "assignees": []any{actor}, "subject": "Theirs, running", "state": "open", "status": "running", "created_at": 3},138 }}139 items := chatActivityView(jobs, nil, actor, "/chat/activity", "general")140 if len(items) != 3 {141 t.Fatalf("got %d cards", len(items))142 }143 if card := items[0]; !card.CanCancel || len(card.Assignees) != 2 || card.Assignees[0] != other || card.Assignees[1] != strings.Repeat("c", 64) {144 t.Fatalf("open own task = %#v", card)145 }146 if items[1].CanCancel || items[2].CanCancel {147 t.Fatalf("done or foreign task cancellable: %#v %#v", items[1], items[2])148 }149 if anonymous := chatActivityView(jobs, nil, "", "/chat/activity", "general"); anonymous[0].CanCancel {150 t.Fatal("signed-out viewer may cancel")151 }152}153154func TestChatActivityListsRoomAgentsForTheRequestForm(t *testing.T) {155 b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}156 app, err := New(b, Options{})157 if err != nil {158 t.Fatal(err)159 }160 page := app.chatActivity(context.Background(), roomOwner, "general", roomThread)161 if !page.Ask || page.Root != roomThread || len(page.Agents) != 1 || page.Agents[0] != (chatActivityAgent{PubKey: roomAgent, Operator: roomOwner}) {162 t.Fatalf("request form data = %#v", page)163 }164 if guest := app.chatActivity(context.Background(), "", "general", ""); guest.Ask || guest.Agents != nil {165 t.Fatalf("signed-out viewer offered the form: %#v", guest)166 }167 if refresh := app.chatActivityCards(context.Background(), roomOwner, "general", ""); refresh.Ask || refresh.Agents != nil {168 t.Fatalf("refresh carried form data: %#v", refresh)169 }170 plain, err := New(&roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}, Options{})171 if err != nil {172 t.Fatal(err)173 }174 if page := plain.chatActivity(context.Background(), roomOwner, "general", ""); page.Ask {175 t.Fatalf("form offered without a task backend: %#v", page)176 }177}