From 29f9b84140cf505d9ba38cfcab6a76d1c9ba785f Mon Sep 17 00:00:00 2001 From: jholdstock Date: Fri, 5 Dec 2025 10:25:07 +0000 Subject: [PATCH 1/2] webapi: Characterization test gin JSON binding. Tests to determine exactly how gin decodes JSON into go maps. It is non obvious because go and JSON values do not map 1-to-1 - a JSON map can be empty, null, or it can be missing completely, whereas in go it can only be nil or empty. Having these tests ensures that the behaviour does not change unexpectedly when a new version of gin is pulled. --- internal/webapi/binding_test.go | 114 ++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 internal/webapi/binding_test.go diff --git a/internal/webapi/binding_test.go b/internal/webapi/binding_test.go new file mode 100644 index 00000000..23b42ffa --- /dev/null +++ b/internal/webapi/binding_test.go @@ -0,0 +1,114 @@ +package webapi + +import ( + "testing" + + "github.com/decred/vspd/types/v3" + "github.com/gin-gonic/gin/binding" +) + +// TestGinJSONBinding does not test code in this package. It is a +// characterization test to determine exactly how Gin handles JSON binding tags. +func TestGinJSONBinding(t *testing.T) { + tests := map[string]struct { + req []byte + expectedErr string + }{ + + "Filled arrays bind without error": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": {"k": "v"}, + "tspendpolicy": {"k": "v"}, + "treasurypolicy": {"k": "v"} + }`), + expectedErr: "", + }, + + "Array filled beyond max does not bind": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": {"k": "v"}, + "tspendpolicy": {"k": "v"}, + "treasurypolicy": {"k1": "v","k2": "v","k3": "v","k4": "v"} + }`), + expectedErr: "Key: 'SetVoteChoicesRequest.TreasuryPolicy' Error:Field validation for 'TreasuryPolicy' failed on the 'max' tag", + }, + + "Empty arrays bind without error": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": {}, + "tspendpolicy": {}, + "treasurypolicy": {} + }`), + expectedErr: "", + }, + + "Missing array with 'required' tag does not bind": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "tspendpolicy": {}, + "treasurypolicy": {} + }`), + expectedErr: "Key: 'SetVoteChoicesRequest.VoteChoices' Error:Field validation for 'VoteChoices' failed on the 'required' tag", + }, + + "Missing array with 'max' tag binds without error": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": {}, + "treasurypolicy": {} + }`), + expectedErr: "", + }, + + "Null array with 'required' tag does not bind": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": null, + "tspendpolicy": {}, + "treasurypolicy": {} + }`), + expectedErr: "Key: 'SetVoteChoicesRequest.VoteChoices' Error:Field validation for 'VoteChoices' failed on the 'required' tag", + }, + + "Null array with 'max' tag binds without error": { + req: []byte(`{ + "timestamp": 12345, + "tickethash": "hash", + "votechoices": {}, + "tspendpolicy": null, + "treasurypolicy": {} + }`), + expectedErr: "", + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + err := binding.JSON.BindBody(test.req, &types.SetVoteChoicesRequest{}) + if test.expectedErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Fatalf("expected error but got none") + } + if err.Error() != test.expectedErr { + t.Fatalf("incorrect error, got %q expected %q", + err.Error(), test.expectedErr) + } + } + + }) + } + +} From 50c0e4b1176afac8cc6d74fa99c2df13b2732dd6 Mon Sep 17 00:00:00 2001 From: jholdstock Date: Fri, 5 Dec 2025 10:45:39 +0000 Subject: [PATCH 2/2] client: Send empty VoteChoices instead of nil --- client/client.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/client/client.go b/client/client.go index 16462d73..875c298d 100644 --- a/client/client.go +++ b/client/client.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2024 The Decred developers +// Copyright (c) 2022-2025 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -66,8 +66,11 @@ func (c *Client) FeeAddress(ctx context.Context, req types.FeeAddressRequest, func (c *Client) PayFee(ctx context.Context, req types.PayFeeRequest, commitmentAddr stdaddr.Address) (*types.PayFeeResponse, error) { - // TSpendPolicy and TreasuryPolicy are optional but must be an empty map - // rather than nil. + // VoteChoices, TSpendPolicy and TreasuryPolicy are optional but must be an + // empty map rather than nil. + if req.VoteChoices == nil { + req.VoteChoices = map[string]string{} + } if req.TSpendPolicy == nil { req.TSpendPolicy = map[string]string{} } @@ -119,8 +122,11 @@ func (c *Client) TicketStatus(ctx context.Context, req types.TicketStatusRequest func (c *Client) SetVoteChoices(ctx context.Context, req types.SetVoteChoicesRequest, commitmentAddr stdaddr.Address) (*types.SetVoteChoicesResponse, error) { - // TSpendPolicy and TreasuryPolicy are optional but must be an empty map - // rather than nil. + // VoteChoices, TSpendPolicy and TreasuryPolicy are optional but must be an + // empty map rather than nil. + if req.VoteChoices == nil { + req.VoteChoices = map[string]string{} + } if req.TSpendPolicy == nil { req.TSpendPolicy = map[string]string{} }