| 1 | package hostrpc |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | type fixtureItem struct { |
| 12 | ID string `json:"id"` |
| 13 | Note string `json:"note,omitempty"` |
| 14 | Next *fixtureItem `json:"next"` |
| 15 | Count int `json:"-"` |
| 16 | } |
| 17 | |
| 18 | type fixtureTarget struct { |
| 19 | calls []string |
| 20 | fail bool |
| 21 | empty bool |
| 22 | } |
| 23 | |
| 24 | func (f *fixtureTarget) Void() { f.calls = append(f.calls, "Void") } |
| 25 | func (f *fixtureTarget) Platform() string { return "test-os" } |
| 26 | func (f *fixtureTarget) Fail() error { return errors.New("boom") } |
| 27 | func (f *fixtureTarget) Ping(id string, n int) (fixtureItem, error) { |
| 28 | f.calls = append(f.calls, "Ping:"+id) |
| 29 | if f.fail { |
| 30 | return fixtureItem{}, errors.New("ping failed: " + id) |
| 31 | } |
| 32 | return fixtureItem{ID: id, Count: n}, nil |
| 33 | } |
| 34 | func (f *fixtureTarget) Items() []fixtureItem { |
| 35 | if f.empty { |
| 36 | return []fixtureItem{} |
| 37 | } |
| 38 | return nil |
| 39 | } |
| 40 | func (f *fixtureTarget) Explode() { panic("kaboom") } |
| 41 | func (f *fixtureTarget) Hidden(map[string]any) {} |
| 42 | func (f *fixtureTarget) Update(item fixtureItem) fixtureItem { return item } |
| 43 | |
| 44 | type badResults struct{} |
| 45 | |
| 46 | func (badResults) Three() (int, int, error) { return 0, 0, nil } |
| 47 | |
| 48 | type badOrder struct{} |
| 49 | |
| 50 | func (badOrder) Pair() (int, string) { return 0, "" } |
| 51 | |
| 52 | type badParam struct{} |
| 53 | |
| 54 | func (badParam) Ctx(string, context.Context) {} |
| 55 | |
| 56 | type badVariadic struct{} |
| 57 | |
| 58 | func (badVariadic) Many(...string) {} |
| 59 | |
| 60 | type badFunc struct{} |
| 61 | |
| 62 | func (badFunc) Callback(func()) {} |
| 63 | |
| 64 | type notStruct int |
| 65 | |
| 66 | func (notStruct) M() {} |
| 67 | |
| 68 | func mustRegistry(t *testing.T, target any, skip Skip) *Registry { |
| 69 | t.Helper() |
| 70 | r, err := NewRegistry(target, skip) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | return r |
| 75 | } |
| 76 | |
| 77 | func TestRegistryDescribesAcceptedSignatures(t *testing.T) { |
| 78 | r := mustRegistry(t, &fixtureTarget{}, Skip{"Hidden": true}) |
| 79 | got := map[string]Command{} |
| 80 | for _, cmd := range r.Commands() { |
| 81 | got[cmd.Name] = cmd |
| 82 | } |
| 83 | if _, ok := got["Hidden"]; ok { |
| 84 | t.Fatal("skipped method appeared in the contract") |
| 85 | } |
| 86 | if cmd := got["Void"]; cmd.Result != nil || cmd.ReturnsError || len(cmd.Params) != 0 { |
| 87 | t.Fatalf("Void = %+v", cmd) |
| 88 | } |
| 89 | if cmd := got["Platform"]; cmd.Result == nil || cmd.Result.Kind != KindString || cmd.ReturnsError { |
| 90 | t.Fatalf("Platform = %+v", cmd) |
| 91 | } |
| 92 | if cmd := got["Fail"]; cmd.Result != nil || !cmd.ReturnsError { |
| 93 | t.Fatalf("Fail = %+v", cmd) |
| 94 | } |
| 95 | ping := got["Ping"] |
| 96 | if !ping.ReturnsError || ping.Result == nil || ping.Result.Kind != KindObject || ping.Result.Ref != "hostrpc.fixtureItem" { |
| 97 | t.Fatalf("Ping = %+v", ping) |
| 98 | } |
| 99 | if len(ping.Params) != 2 || ping.Params[0].Kind != KindString || ping.Params[1].Kind != KindInteger { |
| 100 | t.Fatalf("Ping params = %+v", ping.Params) |
| 101 | } |
| 102 | if items := got["Items"]; items.Result.Kind != KindArray || items.Result.Elem.Ref != "hostrpc.fixtureItem" { |
| 103 | t.Fatalf("Items = %+v", items) |
| 104 | } |
| 105 | names := make([]string, 0, len(r.Commands())) |
| 106 | for _, cmd := range r.Commands() { |
| 107 | names = append(names, cmd.Name) |
| 108 | } |
| 109 | if strings.Join(names, ",") != "Explode,Fail,Items,Ping,Platform,Update,Void" { |
| 110 | t.Fatalf("commands not sorted by name: %v", names) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestRegistryRejectsUnsupportedSignatures(t *testing.T) { |
| 115 | cases := []struct { |
| 116 | target any |
| 117 | want string |
| 118 | }{ |
| 119 | {&badResults{}, "badResults.Three: 3 results"}, |
| 120 | {&badOrder{}, "badOrder.Pair: results must be (T, error), got (int, string)"}, |
| 121 | {&badParam{}, "badParam.Ctx: parameter 1: context.Context cannot be decoded"}, |
| 122 | {&badVariadic{}, "badVariadic.Many: variadic"}, |
| 123 | {&badFunc{}, "badFunc.Callback: parameter 0: func() is not JSON-serialisable"}, |
| 124 | {new(notStruct), "must be a pointer to a struct"}, |
| 125 | {fixtureTarget{}, "must be a pointer to a struct"}, |
| 126 | {nil, "nil target"}, |
| 127 | } |
| 128 | for _, tc := range cases { |
| 129 | _, err := NewRegistry(tc.target, nil) |
| 130 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 131 | t.Errorf("NewRegistry(%T) error = %v, want substring %q", tc.target, err, tc.want) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func raw(values ...string) []json.RawMessage { |
| 137 | out := make([]json.RawMessage, 0, len(values)) |
| 138 | for _, v := range values { |
| 139 | out = append(out, json.RawMessage(v)) |
| 140 | } |
| 141 | return out |
| 142 | } |
| 143 | |
| 144 | func TestRegistryInvokeMarshalsArgumentsAndResults(t *testing.T) { |
| 145 | target := &fixtureTarget{} |
| 146 | r := mustRegistry(t, target, nil) |
| 147 | ctx := context.Background() |
| 148 | |
| 149 | result, err := r.Invoke(ctx, "Void", nil) |
| 150 | if err != nil || result != nil { |
| 151 | t.Fatalf("Void = %v, %v", result, err) |
| 152 | } |
| 153 | result, err = r.Invoke(ctx, "Ping", raw(`"alpha"`, `7`)) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if item, ok := result.(fixtureItem); !ok || item.ID != "alpha" || item.Count != 7 { |
| 158 | t.Fatalf("Ping result = %#v", result) |
| 159 | } |
| 160 | if _, err := r.Invoke(ctx, "Ping", raw(`"missing"`)); err != nil { |
| 161 | t.Fatalf("missing trailing argument must decode as zero: %v", err) |
| 162 | } |
| 163 | if _, err := r.Invoke(ctx, "Ping", raw(`"a"`, `1`, `2`)); err == nil { |
| 164 | t.Fatal("extra argument must be rejected") |
| 165 | } |
| 166 | var invalid *InvalidArgsError |
| 167 | if _, err := r.Invoke(ctx, "Ping", raw(`42`)); !errors.As(err, &invalid) { |
| 168 | t.Fatalf("type mismatch error = %v", err) |
| 169 | } |
| 170 | updated, err := r.Invoke(ctx, "Update", raw(`{"id":"x","next":{"id":"y","next":null}}`)) |
| 171 | if err != nil || updated.(fixtureItem).Next == nil || updated.(fixtureItem).Next.ID != "y" { |
| 172 | t.Fatalf("Update = %#v, %v", updated, err) |
| 173 | } |
| 174 | if strings.Join(target.calls, " ") != "Void Ping:alpha Ping:missing" { |
| 175 | t.Fatalf("calls = %v", target.calls) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestRegistryInvokeReportsErrorsAndUnknownMethods(t *testing.T) { |
| 180 | target := &fixtureTarget{fail: true} |
| 181 | r := mustRegistry(t, target, Skip{"Hidden": true}) |
| 182 | ctx := context.Background() |
| 183 | |
| 184 | if _, err := r.Invoke(ctx, "Fail", nil); err == nil || err.Error() != "boom" { |
| 185 | t.Fatalf("Fail error = %v", err) |
| 186 | } |
| 187 | if _, err := r.Invoke(ctx, "Ping", raw(`"z"`)); err == nil || err.Error() != "ping failed: z" { |
| 188 | t.Fatalf("Ping error = %v", err) |
| 189 | } |
| 190 | var unknown *UnknownMethodError |
| 191 | if _, err := r.Invoke(ctx, "Nope", nil); !errors.As(err, &unknown) || unknown.Method != "Nope" { |
| 192 | t.Fatalf("unknown method error = %v", err) |
| 193 | } |
| 194 | if _, err := r.Invoke(ctx, "Hidden", raw(`{}`)); !errors.As(err, &unknown) { |
| 195 | t.Fatalf("skipped method must be unknown at invoke: %v", err) |
| 196 | } |
| 197 | var panicked *PanicError |
| 198 | if _, err := r.Invoke(ctx, "Explode", nil); !errors.As(err, &panicked) || panicked.Method != "Explode" { |
| 199 | t.Fatalf("panic error = %v", err) |
| 200 | } |
| 201 | cancelled, cancel := context.WithCancel(ctx) |
| 202 | cancel() |
| 203 | if _, err := r.Invoke(cancelled, "Void", nil); !errors.Is(err, context.Canceled) { |
| 204 | t.Fatalf("cancelled context error = %v", err) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestRegistryInvokeKeepsNilAndEmptySlicesDistinct(t *testing.T) { |
| 209 | ctx := context.Background() |
| 210 | for _, tc := range []struct { |
| 211 | empty bool |
| 212 | want string |
| 213 | }{{false, "null"}, {true, "[]"}} { |
| 214 | r := mustRegistry(t, &fixtureTarget{empty: tc.empty}, nil) |
| 215 | result, err := r.Invoke(ctx, "Items", nil) |
| 216 | if err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | encoded, _ := json.Marshal(result) |
| 220 | if string(encoded) != tc.want { |
| 221 | t.Fatalf("Items(empty=%v) encodes as %s, want %s", tc.empty, encoded, tc.want) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestRegistryWithoutInstanceDescribesButCannotInvoke(t *testing.T) { |
| 227 | r := mustRegistry(t, (*fixtureTarget)(nil), nil) |
| 228 | if len(r.Commands()) == 0 { |
| 229 | t.Fatal("typed nil target must still describe the contract") |
| 230 | } |
| 231 | if _, err := r.Invoke(context.Background(), "Void", nil); err == nil { |
| 232 | t.Fatal("invoke on a nil target must fail") |
| 233 | } |
| 234 | } |
| 235 |