clone

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

feat/standalone-relay
tinyrelay / internal / daemon / jobs_test.go
jobs_test.go 19.6K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strconv"9 "strings"10 "testing"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14 "github.com/FelineStateMachine/tinyrelay/internal/mcp"15)1617// jobTenant returns a tenant with a member who requests jobs and an agent18// granted to serve them.19func jobTenant(t *testing.T) (*App, *Tenant, string, string) {20 t.Helper()21 app, tenant := testTenant(t)22 ctx := context.Background()23 owner := tenant.Policy().Owner24 member, _ := event.PublicKey(testMemberSecret)25 agent, _ := event.PublicKey(testAgentSecret)26 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {27 t.Fatal(err)28 }29 now := time.Now().Unix()30 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"jobs", "serve"})); err != nil {31 t.Fatalf("grant rejected: %v", err)32 }33 return app, tenant, member, agent34}3536func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {37 app, _, member, _ := jobTenant(t)38 now := time.Now().Unix()39 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {40 t.Helper()41 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")42 if w.Code != http.StatusOK {43 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())44 }45 return mcpToolResult(t, response)46 }47 structured := func(result map[string]any) map[string]any {48 value, _ := result["structuredContent"].(map[string]any)49 return value50 }51 text := func(result map[string]any) string {52 content, _ := result["content"].([]any)53 if len(content) == 0 {54 return ""55 }56 return content[0].(map[string]any)["text"].(string)57 }58 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")59 names := map[string]bool{}60 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {61 names[tool.(map[string]any)["name"].(string)] = true62 }63 for _, want := range []string{"list_jobs", "read_job", "request_job", "job_feedback", "job_result"} {64 if !names[want] {65 t.Fatalf("missing tool %s", want)66 }67 }6869 // request_job: the template carries the NIP-90 tags in order, refuses70 // bad inputs and kinds, and the signed request publishes.71 source := strings.Repeat("d", 64)72 expires := strconv.FormatInt(now+3600, 10)73 result, isError := call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "text"}, map[string]any{"data": source, "type": "event", "relay": "wss://relay.example", "marker": "context"}}, "output": "text/plain", "params": map[string]any{"model": "small", "lang": "es"}, "bid": 1000, "relays": []any{"wss://a.example", " "}, "expiration": now + 3600}, testMemberSecret)74 unsigned, _ := structured(result)["unsigned"].(map[string]any)75 tags, _ := json.Marshal(unsigned["tags"])76 if isError || unsigned["kind"] != float64(5001) || unsigned["content"] != "" || string(tags) != `[["i","hello","text"],["i","`+source+`","event","wss://relay.example","context"],["output","text/plain"],["param","lang","es"],["param","model","small"],["bid","1000"],["relays","wss://a.example"],["expiration","`+expires+`"]]` {77 t.Fatalf("request_job template: %v %s", isError, text(result))78 }79 if result, isError = call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "voice"}}}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job") {80 t.Fatalf("request_job bad input: %s", text(result))81 }82 if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError {83 t.Fatalf("request_job bad kind: %s", text(result))84 }85 if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5127 or 5129 to 5999") {86 t.Fatalf("request_job malformed signed: %s", text(result))87 }88 request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")89 result, isError = call("request_job", map[string]any{"event": request}, testMemberSecret)90 requestID, _ := structured(result)["event_id"].(string)91 if isError || structured(result)["accepted"] != true || requestID == "" {92 t.Fatalf("request_job publish: %s", text(result))93 }9495 // job_feedback: the serving agent's template and signed publish, and96 // the status vocabulary.97 result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "processing", "info": "halfway", "amount": 500, "invoice": "lnbc1"}, testAgentSecret)98 unsigned, _ = structured(result)["unsigned"].(map[string]any)99 tags, _ = json.Marshal(unsigned["tags"])100 if isError || unsigned["kind"] != float64(7000) || string(tags) != `[["status","processing","halfway"],["e","`+requestID+`"],["p","`+member+`"],["amount","500","lnbc1"]]` {101 t.Fatalf("job_feedback template: %v %s", isError, text(result))102 }103 if result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "done"}, testAgentSecret); !isError || !strings.Contains(text(result), "status") {104 t.Fatalf("job_feedback bad status: %s", text(result))105 }106 if result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"e", requestID}, {"p", member}}, "")}, testAgentSecret); !isError || !strings.Contains(text(result), "payment-required, processing, error, success or partial") {107 t.Fatalf("job_feedback malformed signed: %s", text(result))108 }109 result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"status", "processing", "halfway"}, {"e", requestID}, {"p", member}}, "")}, testAgentSecret)110 if isError || structured(result)["accepted"] != true {111 t.Fatalf("job_feedback publish: %s", text(result))112 }113114 // job_result: the template derives the kind, the request tag and the115 // inputs from the request; the relay checks the answer against the116 // request it holds.117 result, isError = call("job_result", map[string]any{"request": request, "content": "hola", "amount": 700}, testAgentSecret)118 unsigned, _ = structured(result)["unsigned"].(map[string]any)119 resultTags, _ := unsigned["tags"].([]any)120 if isError || unsigned["kind"] != float64(6001) || unsigned["content"] != "hola" || len(resultTags) != 5 {121 t.Fatalf("job_result template: %v %s", isError, text(result))122 }123 requestTag := resultTags[0].([]any)124 var embedded event.Event125 if requestTag[0] != "request" || json.Unmarshal([]byte(requestTag[1].(string)), &embedded) != nil || embedded.ID != requestID {126 t.Fatalf("job_result request tag %v", requestTag)127 }128 tags, _ = json.Marshal(resultTags[1:])129 if string(tags) != `[["e","`+requestID+`"],["i","hello","text"],["p","`+member+`"],["amount","700"]]` {130 t.Fatalf("job_result tags %s", tags)131 }132 if result, isError = call("job_result", map[string]any{"request": request, "kind": 6002, "content": "hola"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") {133 t.Fatalf("job_result mismatched kind: %s", text(result))134 }135 if result, isError = call("job_result", map[string]any{"kind": 6001, "e": requestID, "p": member}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {136 t.Fatalf("job_result without content: %s", text(result))137 }138 if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6002, [][]string{{"e", requestID}, {"p", member}}, "hola")}, testAgentSecret); !isError || !strings.Contains(text(result), "does not answer a kind 5001 request") {139 t.Fatalf("job_result wrong kind rejected by relay: %s", text(result))140 }141 signedTags := [][]string{}142 for _, tag := range resultTags {143 values := []string{}144 for _, value := range tag.([]any) {145 values = append(values, value.(string))146 }147 signedTags = append(signedTags, values)148 }149 result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6001, signedTags, "hola")}, testAgentSecret)150 resultID, _ := structured(result)["event_id"].(string)151 if isError || structured(result)["accepted"] != true || resultID == "" {152 t.Fatalf("job_result publish: %s", text(result))153 }154155 // The reads show the settled request.156 result, isError = call("list_jobs", map[string]any{"state": "done", "mine": true}, testMemberSecret)157 items, _ := structured(result)["items"].([]any)158 if isError || len(items) != 1 || items[0].(map[string]any)["id"] != requestID || items[0].(map[string]any)["status"] != "processing" || items[0].(map[string]any)["result"].(map[string]any)["id"] != resultID {159 t.Fatalf("list_jobs: %s", text(result))160 }161 result, isError = call("read_job", map[string]any{"id": requestID}, testMemberSecret)162 if isError || len(structured(result)["feedback"].([]any)) != 1 || len(structured(result)["results"].([]any)) != 1 {163 t.Fatalf("read_job: %s", text(result))164 }165 r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil)166 w := httptest.NewRecorder()167 app.ServeHTTP(w, r)168 if !strings.Contains(w.Body.String(), "request_job, job_feedback and job_result") || !strings.Contains(w.Body.String(), "list_jobs and read_job") {169 t.Fatalf("llms.txt: %s", w.Body.String())170 }171}172173func TestMCPJobBuildersKeepRoomScope(t *testing.T) {174 room := "private-work"175 request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{176 "kind": float64(5001), "room": room,177 "inputs": []any{map[string]any{"data": "hello", "type": "text"}},178 }})179 if err != nil {180 t.Fatalf("request builder: %v", err)181 }182 if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {183 t.Fatalf("request room = %q, want %q", got, room)184 }185186 feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{187 "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64),188 "room": room, "status": "processing",189 }})190 if err != nil {191 t.Fatalf("feedback builder: %v", err)192 }193 if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room {194 t.Fatalf("feedback room = %q, want %q", got, room)195 }196197 requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags}198 result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{199 "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""},200 "content": "done",201 }})202 if err != nil {203 t.Fatalf("result builder: %v", err)204 }205 if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room {206 t.Fatalf("result room = %q, want %q", got, room)207 }208}209210func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) {211 _, tenant, member, agent := jobTenant(t)212 ctx := context.Background()213 now := time.Now().Unix()214 request := wikiPublish(t, tenant, testMemberSecret, 5001, now-100, "", []string{"i", "hello", "text"})215 result := event.Event{Kind: 6001, PubKey: agent, CreatedAt: now, Tags: [][]string{{"e", request.ID}, {"p", member}}, Content: "hola"}216 notices := tenant.pushNotices(ctx, result)217 if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != member || notices[0].body != "job 5001 result" || !strings.HasSuffix(notices[0].url, "/e/"+result.ID) || len(notices[0].actions) != 0 {218 t.Fatalf("result notices %+v", notices)219 }220 for status, want := range map[string]int{"error": 1, "payment-required": 1, "processing": 0, "success": 0, "partial": 0} {221 feedback := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", status}, {"e", request.ID}, {"p", member}}}222 notices := tenant.pushNotices(ctx, feedback)223 if len(notices) != want {224 t.Fatalf("%s feedback notices %+v", status, notices)225 }226 if want == 1 && (notices[0].category != pushMentions || notices[0].body != "job 5001 "+status) {227 t.Fatalf("%s feedback notice %+v", status, notices[0])228 }229 }230 // Feedback for a request the relay does not hold names its own kind.231 elsewhere := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", strings.Repeat("e", 64)}, {"p", member}}}232 if notices := tenant.pushNotices(ctx, elsewhere); len(notices) != 1 || notices[0].body != "job 7000 error" {233 t.Fatalf("elsewhere notices %+v", notices)234 }235 // The requester's own feedback wakes nobody.236 own := event.Event{Kind: 7000, PubKey: member, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", request.ID}, {"p", member}}}237 if notices := tenant.pushNotices(ctx, own); len(notices) != 0 {238 t.Fatalf("own notices %+v", notices)239 }240}241242func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {243 _, tenant, member, agent := jobTenant(t)244 ctx := context.Background()245 owner := tenant.Policy().Owner246 now := time.Now().Unix()247248 open := wikiPublish(t, tenant, testMemberSecret, 5001, now-400, "", []string{"i", "hello", "text"}, []string{"i", strings.Repeat("d", 64), "event", "wss://relay.example", "context"}, []string{"output", "text/plain"}, []string{"bid", "1000"}, []string{"param", "lang", "es"}, []string{"relays", "wss://a.example", "wss://b.example"}, []string{"p", agent})249 wikiPublish(t, tenant, testAgentSecret, 7000, now-350, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"})250 working := wikiPublish(t, tenant, testAgentSecret, 7000, now-300, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "processing", "halfway"})251 done := wikiPublish(t, tenant, testMemberSecret, 5002, now-390, "", []string{"i", "summarize", "text"})252 wikiPublish(t, tenant, testAgentSecret, 7000, now-380, "", []string{"e", done.ID}, []string{"p", member}, []string{"status", "processing"})253 result := wikiPublish(t, tenant, testAgentSecret, 6002, now-200, "the summary", []string{"e", done.ID}, []string{"p", member}, []string{"amount", "700"}, []string{"request", "{}"})254 failed := wikiPublish(t, tenant, testOwnerSecret, 5001, now-300, "", []string{"i", "broken", "text"})255 wikiPublish(t, tenant, testAgentSecret, 7000, now-250, "", []string{"e", failed.ID}, []string{"p", owner}, []string{"status", "error", "no such input"})256 // An expired request is saved directly, as it would have been before it257 // lapsed, and is no longer served.258 expired := event.Event{Kind: 5001, CreatedAt: now - 7200, Content: "", Tags: [][]string{{"expiration", strconv.FormatInt(now-3600, 10)}}}259 if err := event.Sign(&expired, testMemberSecret); err != nil {260 t.Fatal(err)261 }262 if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {263 t.Fatal(err)264 }265 note := wikiPublish(t, tenant, testMemberSecret, 1, now-100, "not a job")266267 listed, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{})268 if err != nil {269 t.Fatal(err)270 }271 items := wikiItems(listed, "items")272 if len(items) != 3 || items[0]["id"] != failed.ID || items[1]["id"] != done.ID || items[2]["id"] != open.ID || listed["next_cursor"] != "" {273 t.Fatalf("listed %v", items)274 }275 item := approvalByID(items, open.ID)276 feedback, _ := item["feedback"].(map[string]any)277 if item["state"] != "open" || item["status"] != "processing" || item["kind"] != 5001.0 || item["requester"] != member || item["output"] != "text/plain" || item["bid"] != "1000" || item["result"] != nil || feedback == nil || feedback["id"] != working.ID || feedback["info"] != "halfway" || feedback["provider"] != agent {278 t.Fatalf("open item %v", item)279 }280 if inputs := item["inputs"].([]any); len(inputs) != 2 || inputs[0].(map[string]any)["type"] != "text" || inputs[1].(map[string]any)["relay"] != "wss://relay.example" || inputs[1].(map[string]any)["marker"] != "context" {281 t.Fatalf("inputs %v", item["inputs"])282 }283 if params := item["params"].([]any); len(params) != 1 || params[0].(map[string]any)["key"] != "lang" || params[0].(map[string]any)["value"] != "es" {284 t.Fatalf("params %v", item["params"])285 }286 if relays := item["relays"].([]any); len(relays) != 2 || relays[1] != "wss://b.example" {287 t.Fatalf("relays %v", item["relays"])288 }289 if providers := item["providers"].([]any); len(providers) != 1 || providers[0] != agent {290 t.Fatalf("providers %v", item["providers"])291 }292 item = approvalByID(items, done.ID)293 answer, _ := item["result"].(map[string]any)294 if item["state"] != "done" || item["status"] != "processing" || answer == nil || answer["id"] != result.ID || answer["kind"] != 6002.0 || answer["amount"] != "700" || answer["content"] != "the summary" {295 t.Fatalf("done item %v", item)296 }297 item = approvalByID(items, failed.ID)298 if item["state"] != "done" || item["status"] != "error" || item["result"] != nil {299 t.Fatalf("failed item %v", item)300 }301302 for state, want := range map[string][]string{"open": {open.ID}, "done": {failed.ID, done.ID}} {303 filtered, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": state})304 if err != nil {305 t.Fatal(err)306 }307 got := []string{}308 for _, item := range wikiItems(filtered, "items") {309 got = append(got, item["id"].(string))310 }311 if strings.Join(got, ",") != strings.Join(want, ",") {312 t.Fatalf("state %s lists %v", state, got)313 }314 }315 mine, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"mine": true})316 if err != nil {317 t.Fatal(err)318 }319 if items := wikiItems(mine, "items"); len(items) != 2 || approvalByID(items, failed.ID) != nil {320 t.Fatalf("mine %v", items)321 }322 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {323 t.Fatalf("bogus state %v", err)324 }325 if _, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"mine": true}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {326 t.Fatalf("guest mine %v", err)327 }328 // Guests see the requests on an open relay; pages follow the cursor.329 paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2})330 if err != nil {331 t.Fatal(err)332 }333 if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {334 t.Fatalf("paged %v", paged)335 }336 rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})337 if err != nil {338 t.Fatal(err)339 }340 if items := wikiItems(rest, "items"); len(items) != 1 || items[0]["id"] != open.ID || rest["next_cursor"] != "" {341 t.Fatalf("second page %v", rest)342 }343344 detail, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": open.ID})345 if err != nil {346 t.Fatal(err)347 }348 timeline := wikiItems(detail, "feedback")349 if item := detail["item"].(map[string]any); item["state"] != "open" || len(timeline) != 2 || timeline[0]["status"] != "payment-required" || timeline[0]["invoice"] != "lnbc1" || timeline[1]["id"] != working.ID || len(detail["results"].([]any)) != 0 {350 t.Fatalf("open detail %v", detail)351 }352 detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": done.ID})353 if err != nil {354 t.Fatal(err)355 }356 if results := wikiItems(detail, "results"); len(results) != 1 || results[0]["id"] != result.ID || len(wikiItems(detail, "feedback")) != 1 {357 t.Fatalf("done detail %v", detail)358 }359 for _, id := range []string{note.ID, expired.ID, strings.Repeat("f", 64)} {360 if _, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": id}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {361 t.Fatalf("browsejob %s: %v", id, err)362 }363 }364 // Switching long tasks off closes the browse surface too.365 policy := tenant.Policy()366 policy.Features.Jobs = false367 if err := tenant.applyPolicy(ctx, policy); err != nil {368 t.Fatal(err)369 }370 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{}); err == nil || !strings.Contains(err.Error(), "switched off") {371 t.Fatalf("browse with jobs off %v", err)372 }373}