1package daemon23import (4 "context"5 "encoding/json"6 "strconv"7 "strings"8 "testing"9 "time"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12)1314const (15 approvalAskerSecret = "5555555555555555555555555555555555555555555555555555555555555555"16 approvalOtherSecret = "6666666666666666666666666666666666666666666666666666666666666666"17)1819func TestNativeInteractionValidatesChoicesAndSelection(t *testing.T) {20 e := event.Event{Kind: kindChatMessage, ID: strings.Repeat("e", 64), PubKey: strings.Repeat("a", 64), Tags: [][]string{{"tinyagent", "1"}, {"interaction", "question"}, {"selection", "single"}, {"option", "continue", "Continue playing"}, {"option", "stop", "Stop here"}}}21 options, selection, ok := nativeInteraction(e)22 if !ok || selection != "single" || len(options) != 2 || options[0].ID != "continue" {23 t.Fatalf("native interaction = %#v, %q, %v", options, selection, ok)24 }25 answerTags := [][]string{{"h", ""}, {"E", e.ID}, {"K", "9"}, {"P", e.PubKey}, {"e", e.ID}, {"k", "9"}, {"p", e.PubKey}}26 if nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: e}, event.Event{Kind: kindComment, Content: "other", Tags: answerTags}) {27 t.Fatal("unknown native choice accepted")28 }29 multiple := e30 multiple.Tags = append([][]string(nil), e.Tags[:2]...)31 multiple.Tags = append(multiple.Tags, []string{"selection", "multiple"}, []string{"option", "a", "A"}, []string{"option", "b", "B"})32 if !nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: multiple}, event.Event{Kind: kindComment, Content: `["a","b"]`, Tags: answerTags}) {33 t.Fatal("valid multiple choice rejected")34 }35 if nativeAnswerValid(approvalItem{ID: e.ID, Kind: e.Kind, Asker: e.PubKey, Event: multiple}, event.Event{Kind: kindComment, Content: `["a","a"]`, Tags: answerTags}) {36 t.Fatal("duplicate multiple choice accepted")37 }38}3940func TestNativeApprovalNotificationsRequireExplicitMention(t *testing.T) {41 _, tenant := testTenant(t)42 owner := tenant.Policy().Owner43 asker := wikiKey(t, approvalAskerSecret)44 base := [][]string{{"request", "question"}, {"tinyagent", "1"}, {"interaction", "question"}, {"selection", "single"}, {"option", "yes", "Yes"}, {"p", owner}, {"h", tenant.meta.Name}}45 request := event.Event{Kind: kindChatMessage, PubKey: asker, Tags: base, Content: "Choose"}46 if notices := tenant.pushNotices(context.Background(), request); len(notices) != 0 {47 t.Fatalf("unmentioned native request notified: %+v", notices)48 }49 request.Tags = append(request.Tags, []string{"mention", owner})50 if notices := tenant.pushNotices(context.Background(), request); len(notices) != 1 || notices[0].category != pushApprovals {51 t.Fatalf("mentioned native request notices: %+v", notices)52 }53}5455func TestMalformedNativeMarkerIsNotLegacyApproval(t *testing.T) {56 e := event.Event{Kind: kindChatMessage, Tags: [][]string{{"tinyagent", "1"}, {"request", "approve"}, {"p", strings.Repeat("a", 64)}}}57 if _, ok := approvalRequest(e); ok {58 t.Fatal("malformed native request fell back to legacy approval")59 }60}6162func approvalCall(t *testing.T, tenant *Tenant, actor, method string, params map[string]any) (map[string]any, error) {63 t.Helper()64 raw, err := json.Marshal(params)65 if err != nil {66 t.Fatal(err)67 }68 value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{raw})69 if err != nil {70 return nil, err71 }72 encoded, err := json.Marshal(value)73 if err != nil {74 t.Fatal(err)75 }76 var result map[string]any77 if err := json.Unmarshal(encoded, &result); err != nil {78 t.Fatal(err)79 }80 return result, nil81}8283func approvalByID(items []map[string]any, id string) map[string]any {84 for _, item := range items {85 if item["id"] == id {86 return item87 }88 }89 return nil90}9192func TestBrowseApprovalsStatesAndAccess(t *testing.T) {93 _, tenant := testTenant(t)94 ctx := context.Background()95 owner := tenant.Policy().Owner96 asker, other := wikiKey(t, approvalAskerSecret), wikiKey(t, approvalOtherSecret)97 now := time.Now().Unix()9899 open := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-400, "Publish release notes 1.4 as drafted?", []string{"request", "approve"}, []string{"p", owner}, []string{"subject", "Publish release notes 1.4"}, []string{"a", "30617:" + owner + ":notes"}, []string{"expiration", strconv.FormatInt(now+3600, 10)})100 decided := wikiPublish(t, tenant, approvalAskerSecret, kindThreadRoot, now-300, "Mark pull request 23 merged?", []string{"request", "decide"}, []string{"p", owner}, []string{"h", tenant.meta.Name})101 // An older denial, a newer approval and a stranger's reaction: the102 // newest reaction from an asked key wins and the stranger is ignored.103 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-250, "-", []string{"e", decided.ID}, []string{"p", asker})104 approval := wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-200, "+", []string{"e", decided.ID}, []string{"p", asker})105 wikiPublish(t, tenant, approvalOtherSecret, kindReaction, now-100, "-", []string{"e", decided.ID}, []string{"p", asker})106 question := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-200, "Should the edge be mentioned?", []string{"request", "question"}, []string{"p", owner}, []string{"E", decided.ID, "", asker}, []string{"K", "11"}, []string{"P", asker})107 reply := wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-150, "Leave it out.", []string{"E", question.ID, "", asker}, []string{"K", "1111"}, []string{"P", asker}, []string{"e", question.ID, "", asker}, []string{"k", "1111"}, []string{"p", asker})108 // An expired request is saved directly, as it would have been before it109 // lapsed, and shows as expired until maintenance removes it.110 expired := event.Event{Kind: kindComment, CreatedAt: now - 7200, Content: "Rotate the key tonight?", Tags: [][]string{{"request", "approve"}, {"p", owner}, {"expiration", strconv.FormatInt(now-3600, 10)}}}111 if err := event.Sign(&expired, approvalAskerSecret); err != nil {112 t.Fatal(err)113 }114 if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {115 t.Fatal(err)116 }117 elsewhere := wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-50, "Only for someone else", []string{"request", "approve"}, []string{"p", other})118 // Ordinary comments and unknown request types are not requests.119 wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-40, "Plain mention", []string{"p", owner})120 wikiPublish(t, tenant, approvalAskerSecret, kindComment, now-30, "Unknown type", []string{"request", "veto"}, []string{"p", owner})121122 result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})123 if err != nil {124 t.Fatal(err)125 }126 items := wikiItems(result, "items")127 if len(items) != 4 || approvalByID(items, elsewhere.ID) != nil {128 t.Fatalf("owner sees %d items: %v", len(items), items)129 }130 counts := result["counts"].(map[string]any)131 if counts["open"] != 1.0 || counts["answered"] != 2.0 || counts["expired"] != 1.0 {132 t.Fatalf("counts %v", counts)133 }134 if result["oldest_open"] != float64(open.CreatedAt) {135 t.Fatalf("oldest open %v", result["oldest_open"])136 }137 item := approvalByID(items, open.ID)138 if item["state"] != "open" || item["type"] != "approve" || item["subject"] != "Publish release notes 1.4" || item["asker"] != asker || item["expires"] != float64(now+3600) || item["answer"] != nil {139 t.Fatalf("open item %v", item)140 }141 if about := item["about"].(map[string]any); about["coordinate"] != "30617:"+owner+":notes" || !strings.HasSuffix(about["url"].(string), "/repos/"+owner+"/notes") {142 t.Fatalf("about %v", about)143 }144 item = approvalByID(items, decided.ID)145 answer, _ := item["answer"].(map[string]any)146 if item["state"] != "answered" || item["room"] != tenant.meta.Name || answer == nil || answer["decision"] != "approved" || answer["id"] != approval.ID {147 t.Fatalf("decided item %v", item)148 }149 item = approvalByID(items, question.ID)150 answer, _ = item["answer"].(map[string]any)151 if item["state"] != "answered" || answer == nil || answer["decision"] != "replied" || answer["id"] != reply.ID || answer["content"] != "Leave it out." {152 t.Fatalf("question item %v", item)153 }154 if about := item["about"].(map[string]any); about["event"] != decided.ID || about["kind"] != "11" || !strings.HasSuffix(about["url"].(string), "/e/"+decided.ID) {155 t.Fatalf("question about %v", about)156 }157 if item = approvalByID(items, expired.ID); item["state"] != "expired" {158 t.Fatalf("expired item %v", item)159 }160161 filtered, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "open"})162 if err != nil {163 t.Fatal(err)164 }165 if items := wikiItems(filtered, "items"); len(items) != 1 || items[0]["id"] != open.ID {166 t.Fatalf("open filter %v", items)167 }168 if _, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {169 t.Fatalf("bogus state %v", err)170 }171 paged, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2})172 if err != nil {173 t.Fatal(err)174 }175 if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {176 t.Fatalf("paged %v", paged)177 }178 rest, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})179 if err != nil {180 t.Fatal(err)181 }182 if items := wikiItems(rest, "items"); len(items) != 2 || items[0]["id"] == wikiItems(paged, "items")[0]["id"] {183 t.Fatalf("second page %v", rest)184 }185186 // The other key sees only its own request; guests see nothing.187 theirs, err := approvalCall(t, tenant, other, "browseapprovals", map[string]any{})188 if err != nil {189 t.Fatal(err)190 }191 if items := wikiItems(theirs, "items"); len(items) != 1 || items[0]["id"] != elsewhere.ID {192 t.Fatalf("other sees %v", items)193 }194 if _, err := approvalCall(t, tenant, "", "browseapprovals", map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {195 t.Fatalf("guest %v", err)196 }197198 // One request with its history: the asked person and the asker may199 // read it, nobody else.200 detail, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": decided.ID})201 if err != nil {202 t.Fatal(err)203 }204 if got := detail["item"].(map[string]any); got["state"] != "answered" || got["answer"].(map[string]any)["decision"] != "approved" {205 t.Fatalf("detail %v", detail)206 }207 if answers := detail["answers"].([]any); len(answers) != 2 || answers[0].(map[string]any)["id"] != approval.ID {208 t.Fatalf("answers %v", answers)209 }210 if _, err := approvalCall(t, tenant, asker, "browseapproval", map[string]any{"id": decided.ID}); err != nil {211 t.Fatalf("asker detail %v", err)212 }213 if _, err := approvalCall(t, tenant, other, "browseapproval", map[string]any{"id": decided.ID}); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {214 t.Fatalf("stranger detail %v", err)215 }216 if _, err := approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": reply.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {217 t.Fatalf("reply as request %v", err)218 }219}220221func TestBrowseApprovalsIncludesWikiProposalsAndMerges(t *testing.T) {222 _, tenant, owner, _, moderator, member := wikiProposalTenant(t, "propose")223 now := time.Now().Unix()224 proposal := wikiArticle(t, tenant, testAgentSecret, "Approvals", now-200)225 wikiArticle(t, tenant, wikiOwnerSecret, "Approvals", now-300)226 merge := wikiPublish(t, tenant, wikiMemberSecret, kindWikiMerge, now-100, "Please take this change.", []string{"a", "30818:" + owner + ":approvals"}, []string{"p", owner}, []string{"e", proposal.ID, "", "source"})227 result, err := approvalCall(t, tenant, owner, "browseapprovals", map[string]any{})228 if err != nil {229 t.Fatal(err)230 }231 items := wikiItems(result, "items")232 if approvalByID(items, proposal.ID) == nil || approvalByID(items, merge.ID) == nil {233 t.Fatalf("wiki approvals missing: %v", items)234 }235 proposalItem := approvalByID(items, proposal.ID)236 if proposalItem["type"] != approvalWikiProposal || proposalItem["state"] != "open" || proposalItem["about"].(map[string]any)["url"] != "http://relay.test/wiki/approvals?version="+proposal.ID {237 t.Fatalf("proposal approval %v", proposalItem)238 }239 mergeItem := approvalByID(items, merge.ID)240 if mergeItem["type"] != approvalWikiMerge || mergeItem["state"] != "open" || !strings.Contains(mergeItem["about"].(map[string]any)["url"].(string), "/proposals/"+merge.ID) {241 t.Fatalf("merge approval %v", mergeItem)242 }243 if got, err := approvalCall(t, tenant, member, "browseapprovals", map[string]any{}); err != nil || len(wikiItems(got, "items")) != 0 {244 t.Fatalf("member saw wiki approvals: %v %v", got, err)245 }246 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-50, "+", []string{"e", proposal.ID})247 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, now-40, "-", []string{"e", merge.ID})248 result, err = approvalCall(t, tenant, moderator, "browseapprovals", map[string]any{})249 if err != nil {250 t.Fatal(err)251 }252 if approvalByID(wikiItems(result, "items"), proposal.ID) == nil {253 t.Fatal("moderator cannot see proposal")254 }255 if approvalByID(wikiItems(result, "items"), proposal.ID)["state"] != "answered" {256 t.Fatalf("proposal state %v", result)257 }258 detail, err := approvalCall(t, tenant, moderator, "browseapproval", map[string]any{"id": proposal.ID})259 if err != nil || detail["item"].(map[string]any)["state"] != "answered" {260 t.Fatalf("proposal detail %v %v", detail, err)261 }262 if _, err := approvalCall(t, tenant, member, "browseapproval", map[string]any{"id": proposal.ID}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {263 t.Fatalf("proposal detail leaked to member: %v", err)264 }265 detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": merge.ID})266 if err != nil || detail["item"].(map[string]any)["state"] != "answered" {267 t.Fatalf("merge detail %v %v", detail, err)268 }269 proposal2 := wikiArticle(t, tenant, testAgentSecret, "Reply stays open", now-20)270 wikiPublish(t, tenant, wikiOwnerSecret, kindComment, now-10, "A note", []string{"e", proposal2.ID})271 detail, err = approvalCall(t, tenant, owner, "browseapproval", map[string]any{"id": proposal2.ID})272 if err != nil || detail["item"].(map[string]any)["state"] != "open" {273 t.Fatalf("wiki reply settled proposal: %v %v", detail, err)274 }275}276277func TestApprovalRequestsWakeDevicesWithActionsAndCountOnBadge(t *testing.T) {278 _, tenant := testTenant(t)279 ctx := context.Background()280 owner := tenant.Policy().Owner281 asker := wikiKey(t, approvalAskerSecret)282 now := time.Now().Unix()283284 request := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "decide"}, {"p", owner}, {"subject", "Publish release notes"}, {"expiration", strconv.FormatInt(now+3600, 10)}}, Content: "Long body"}285 notices := tenant.pushNotices(ctx, request)286 if len(notices) != 1 || notices[0].category != pushApprovals || notices[0].recipient != owner || notices[0].body != asker[:12]+" asks: Publish release notes" || !strings.HasSuffix(notices[0].url, "/approvals/"+request.ID) {287 t.Fatalf("decision notices %+v", notices)288 }289 if actions := notices[0].actions; len(actions) != 3 || actions[0].Action != "approve" || actions[1].Action != "deny" || actions[2].Action != "reply" || actions[0].Title != "Approve" {290 t.Fatalf("decision actions %+v", notices[0].actions)291 }292 question := event.Event{Kind: kindChatMessage, PubKey: asker, CreatedAt: now, Tags: [][]string{{"request", "question"}, {"p", owner}, {"h", "build"}}, Content: "Mention the edge?"}293 notices = tenant.pushNotices(ctx, question)294 if len(notices) != 1 || notices[0].body != asker[:12]+" asks: Mention the edge?" || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "reply" {295 t.Fatalf("question notices %+v", notices)296 }297 // A comment that asks nobody keeps its mention notice.298 plain := event.Event{Kind: kindComment, PubKey: asker, CreatedAt: now, Tags: [][]string{{"p", owner}}, Content: "Hello"}299 if notices := tenant.pushNotices(ctx, plain); len(notices) != 1 || notices[0].category != pushMentions {300 t.Fatalf("plain notices %+v", notices)301 }302 if !containsString(pushCategories, pushApprovals) {303 t.Fatal("approvals is not a device category")304 }305306 // The actions travel through the payload to the device message.307 message := tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushApprovals, Text: notices[0].body, URL: notices[0].url, Actions: notices[0].actions})308 encoded, err := json.Marshal(message)309 if err != nil {310 t.Fatal(err)311 }312 if !strings.Contains(string(encoded), `"actions":[{"action":"reply","title":"Reply"}]`) || !strings.Contains(string(encoded), `"tag":"tiny-approvals"`) {313 t.Fatalf("message %s", encoded)314 }315 if encoded, _ := json.Marshal(tenant.pushMessage(ctx, pushPayload{Recipient: owner, Kind: pushMentions, Text: "hi"})); strings.Contains(string(encoded), "actions") {316 t.Fatalf("mention message carries actions: %s", encoded)317 }318319 // The badge counts open requests once, whether they arrived before or320 // after the last inbox visit, and drops them once answered.321 if err := tenant.markInboxSeen(ctx, owner); err != nil {322 t.Fatal(err)323 }324 seen := tenant.inboxSeen(ctx, owner)325 older := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen-100, "Older request", []string{"request", "approve"}, []string{"p", owner})326 if got := tenant.inboxUnread(ctx, owner); got != 1 {327 t.Fatalf("unread with an older open request = %d", got)328 }329 newer := wikiPublish(t, tenant, approvalAskerSecret, kindComment, seen+100, "Newer request", []string{"request", "approve"}, []string{"p", owner})330 wikiPublish(t, tenant, approvalAskerSecret, kindChatMessage, seen+100, "Room request", []string{"request", "question"}, []string{"p", owner}, []string{"h", tenant.meta.Name})331 if got := tenant.inboxUnread(ctx, owner); got != 3 {332 t.Fatalf("unread with three open requests = %d", got)333 }334 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "+", []string{"e", older.ID}, []string{"p", asker})335 wikiPublish(t, tenant, wikiOwnerSecret, kindReaction, seen+200, "-", []string{"e", newer.ID}, []string{"p", asker})336 // The newer request still counts as a conversation event since the337 // visit; the older one is answered and gone from the badge.338 if got := tenant.inboxUnread(ctx, owner); got != 2 {339 t.Fatalf("unread after answering = %d", got)340 }341}342343func TestNativeFreeformAllowsBothOptionsAndCustomText(t *testing.T) {344 for _, selection := range []string{"single", "multiple", "text"} {345 t.Run(selection, func(t *testing.T) {346 e := event.Event{Kind: 9, ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), CreatedAt: 100,347 Tags: [][]string{{"h", "lab"}, {"tinyagent", "1"}, {"interaction", "question"}, {"selection", selection}, {"freeform", "true"}, {"expiration", "200"}}}348 if selection != "text" {349 e.Tags = append(e.Tags, []string{"option", "c0", "Continue"})350 }351 item := (&Tenant{}).approvalItemFrom(context.Background(), e, "question")352 answer := event.Event{Kind: 1111, CreatedAt: 110, Tags: [][]string{{"h", "lab"}, {"e", e.ID}, {"E", e.ID}, {"p", e.PubKey}, {"P", e.PubKey}, {"k", "9"}, {"K", "9"}}}353 valid := []string{`{"text":"custom"}`, "c0"}354 invalid := []string{`{"text":"custom","extra":true}`, `{"Text":"custom"}`, `{"text":"one","text":"two"}`, "unknown", `[]`}355 if selection == "multiple" {356 valid[1] = `["c0"]`357 invalid = append(invalid, `["c0","c0"]`)358 }359 if selection == "text" {360 valid = []string{"free text"}361 invalid = []string{"", " "}362 }363 for _, content := range valid {364 answer.Content = content365 if !nativeAnswerValid(item, answer) {366 t.Errorf("valid answer rejected: %s", content)367 }368 }369 for _, content := range invalid {370 answer.Content = content371 if nativeAnswerValid(item, answer) {372 t.Errorf("invalid answer accepted: %s", content)373 }374 }375 answer.Content = valid[0]376 answer.CreatedAt = 200377 if nativeAnswerValid(item, answer) {378 t.Error("answer at expiration accepted")379 }380 })381 }382}383384func TestNativeFreeformAcceptsChoiceAndExplanationTogether(t *testing.T) {385 base := event.Event{Kind: kindChatMessage, ID: strings.Repeat("a", 64), PubKey: strings.Repeat("b", 64), CreatedAt: 100,386 Tags: [][]string{{"h", "lab"}, {"tinyagent", "1"}, {"interaction", "question"}, {"freeform", "true"}, {"expiration", "200"}, {"option", "yes", "Yes"}, {"option", "no", "No"}}}387 answerTags := [][]string{{"h", "lab"}, {"e", base.ID}, {"E", base.ID}, {"p", base.PubKey}, {"P", base.PubKey}, {"k", "9"}, {"K", "9"}}388389 tests := []struct {390 name string391 selection string392 content string393 valid bool394 }{395 {name: "single", selection: "single", content: `{"choices":["no"],"text":"Needs more context."}`, valid: true},396 {name: "multiple", selection: "multiple", content: `{"choices":["yes","no"],"text":"Both apply."}`, valid: true},397 {name: "unknown choice", selection: "single", content: `{"choices":["maybe"],"text":"No."}`},398 {name: "duplicate choice", selection: "multiple", content: `{"choices":["yes","yes"],"text":"No."}`},399 {name: "empty explanation", selection: "single", content: `{"choices":["yes"],"text":" "}`},400 {name: "unknown field", selection: "single", content: `{"choices":["yes"],"text":"Yes.","extra":true}`},401 {name: "duplicate field", selection: "single", content: `{"choices":["yes"],"choices":["no"],"text":"No."}`},402 }403 for _, test := range tests {404 t.Run(test.name, func(t *testing.T) {405 e := base406 e.Tags = append([][]string(nil), base.Tags...)407 e.Tags = append(e.Tags, []string{"selection", test.selection})408 item := (&Tenant{}).approvalItemFrom(context.Background(), e, "question")409 answer := event.Event{Kind: kindComment, CreatedAt: 110, Content: test.content, Tags: answerTags}410 if got := nativeAnswerValid(item, answer); got != test.valid {411 t.Fatalf("nativeAnswerValid = %v, want %v for %s", got, test.valid, test.content)412 }413 })414 }415}