| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/mcp" |
| 15 | ) |
| 16 | |
| 17 | // jobTenant returns a tenant with a member who requests jobs and an agent |
| 18 | // granted to serve them. |
| 19 | func jobTenant(t *testing.T) (*App, *Tenant, string, string) { |
| 20 | t.Helper() |
| 21 | app, tenant := testTenant(t) |
| 22 | ctx := context.Background() |
| 23 | owner := tenant.Policy().Owner |
| 24 | 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, agent |
| 34 | } |
| 35 | |
| 36 | func 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 value |
| 50 | } |
| 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)] = true |
| 62 | } |
| 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 | } |
| 68 | |
| 69 | // request_job: the template carries the NIP-90 tags in order, refuses |
| 70 | // 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 | } |
| 94 | |
| 95 | // job_feedback: the serving agent's template and signed publish, and |
| 96 | // 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 | } |
| 113 | |
| 114 | // job_result: the template derives the kind, the request tag and the |
| 115 | // inputs from the request; the relay checks the answer against the |
| 116 | // 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.Event |
| 125 | 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 | } |
| 154 | |
| 155 | // 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 | } |
| 172 | |
| 173 | func 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 | } |
| 185 | |
| 186 | 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 | } |
| 196 | |
| 197 | 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 | } |
| 209 | |
| 210 | func 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 | } |
| 241 | |
| 242 | func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) { |
| 243 | _, tenant, member, agent := jobTenant(t) |
| 244 | ctx := context.Background() |
| 245 | owner := tenant.Policy().Owner |
| 246 | now := time.Now().Unix() |
| 247 | |
| 248 | 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 it |
| 257 | // 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" ) |
| 266 | |
| 267 | 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 | } |
| 301 | |
| 302 | 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 | } |
| 343 | |
| 344 | 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 = false |
| 367 | 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 | } |