diff --git a/.gitignore b/.gitignore index 49ce3c1..61ead86 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/vendor \ No newline at end of file +/vendor diff --git a/add_on_usage.go b/add_on_usage.go new file mode 100644 index 0000000..6e6ff21 --- /dev/null +++ b/add_on_usage.go @@ -0,0 +1,121 @@ +package recurly + +import ( + "context" + "encoding/xml" + "fmt" + "net/http" +) + +// AddOnUsageService manages the interactions for add-on usages. +type AddOnUsageService interface { + // List returns a pager to paginate usages for add-on in subscription. PagerOptions are used to + // optionally filter the results. + // + // https://dev.recurly.com/docs/list-add-ons-usage + List(uuid, addOnCode string, opts *PagerOptions) Pager + + // Create creates a new usage for add-on in subscription. + // + // https://dev.recurly.com/docs/log-usage + Create(ctx context.Context, uuid, addOnCode string, usage AddOnUsage) (*AddOnUsage, error) + + // Get retrieves an usage. If the usage does not exist, + // a nil usage and nil error are returned. + // + // https://dev.recurly.com/docs/lookup-usage-record + Get(ctx context.Context, uuid, addOnCode, usageID string) (*AddOnUsage, error) + + // Update updates the usage information. Once usage is billed, only MerchantTag can be updated. + // + // https://dev.recurly.com/docs/update-usage + Update(ctx context.Context, uuid, addOnCode, usageID string, usage AddOnUsage) (*AddOnUsage, error) + + // Delete removes an usage from subscription add-on. If usage is billed, it can't be removed + // + // https://dev.recurly.com/docs/delete-a-usage-record + Delete(ctx context.Context, uuid, addOnCode, usageID string) error +} + +// Usage is a billable event or group of events recorded on a purchased usage-based add-on and billed in arrears each billing cycle. +// +// https://dev.recurly.com/docs/usage-record-object +type AddOnUsage struct { + XMLName xml.Name `xml:"usage"` + ID int `xml:"id,omitempty"` + Amount int `xml:"amount,omitempty"` + MerchantTag string `xml:"merchant_tag,omitempty"` + RecordingTimestamp NullTime `xml:"recording_timestamp,omitempty"` + UsageTimestamp NullTime `xml:"usage_timestamp,omitempty"` + CreatedAt NullTime `xml:"created_at,omitempty"` + UpdatedAt NullTime `xml:"updated_at,omitempty"` + BilledAt NullTime `xml:"billed_at,omitempty"` + UsageType string `xml:"usage_type,omitempty"` + UnitAmountInCents int `xml:"unit_amount_in_cents,omitempty"` + UsagePercentage NullFloat `xml:"usage_percentage,omitempty"` +} + +var _ AddOnUsageService = &addOnUsageServiceImpl{} + +type addOnUsageServiceImpl serviceImpl + +func (s *addOnUsageServiceImpl) List(uuid, addOnCode string, opts *PagerOptions) Pager { + path := fmt.Sprintf("/subscriptions/%s/add_ons/%s/usage", uuid, addOnCode) + return s.client.newPager("GET", path, opts) +} + +func (s *addOnUsageServiceImpl) Get(ctx context.Context, uuid, addOnCode, usageID string) (*AddOnUsage, error) { + path := fmt.Sprintf("/subscriptions/%s/add_ons/%s/usage/%s", uuid, addOnCode, usageID) + req, err := s.client.newRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var dst AddOnUsage + if _, err := s.client.do(ctx, req, &dst); err != nil { + if e, ok := err.(*ClientError); ok && e.Response.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, err + } + return &dst, nil +} + +func (s *addOnUsageServiceImpl) Create(ctx context.Context, uuid, addOnCode string, usage AddOnUsage) (*AddOnUsage, error) { + path := fmt.Sprintf("/subscriptions/%s/add_ons/%s/usage", uuid, addOnCode) + req, err := s.client.newRequest("POST", path, usage) + if err != nil { + return nil, err + } + + var dst AddOnUsage + if _, err := s.client.do(ctx, req, &dst); err != nil { + return nil, err + } + return &dst, nil +} + +func (s *addOnUsageServiceImpl) Update(ctx context.Context, uuid, addOnCode, usageID string, usage AddOnUsage) (*AddOnUsage, error) { + path := fmt.Sprintf("/subscriptions/%s/add_ons/%s/usage/%s", uuid, addOnCode, usageID) + req, err := s.client.newRequest("PUT", path, usage) + if err != nil { + return nil, err + } + + var dst AddOnUsage + if _, err := s.client.do(ctx, req, &dst); err != nil { + return nil, err + } + return &dst, nil +} + +func (s *addOnUsageServiceImpl) Delete(ctx context.Context, uuid, addOnCode, usageID string) error { + path := fmt.Sprintf("/subscriptions/%s/add_ons/%s/usage/%s", uuid, addOnCode, usageID) + req, err := s.client.newRequest("DELETE", path, nil) + if err != nil { + return err + } + + _, err = s.client.do(ctx, req, nil) + return err +} diff --git a/add_on_usage_test.go b/add_on_usage_test.go new file mode 100644 index 0000000..b8f1c1c --- /dev/null +++ b/add_on_usage_test.go @@ -0,0 +1,264 @@ +package recurly_test + +import ( + "bytes" + "context" + "encoding/xml" + "net/http" + "strconv" + "testing" + "time" + + "github.com/blacklightcms/recurly" + "github.com/google/go-cmp/cmp" +) + +// Ensure structs are encoded to XML properly. +func TestAddOnUsage_Encoding(t *testing.T) { + now := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { + v recurly.AddOnUsage + expected string + }{ + { + expected: MustCompactString(` + + + `), + }, + { + v: recurly.AddOnUsage{ID: 123456}, + expected: MustCompactString(` + + 123456 + + `), + }, + { + v: recurly.AddOnUsage{Amount: 100}, + expected: MustCompactString(` + + 100 + + `), + }, + { + v: recurly.AddOnUsage{MerchantTag: "some_merchant"}, + expected: MustCompactString(` + + some_merchant + + `), + }, + { + v: recurly.AddOnUsage{RecordingTimestamp: recurly.NewTime(now)}, + expected: MustCompactString(` + + 2000-01-01T00:00:00Z + + `), + }, + { + v: recurly.AddOnUsage{UsageTimestamp: recurly.NewTime(now)}, + expected: MustCompactString(` + + 2000-01-01T00:00:00Z + + `), + }, + { + v: recurly.AddOnUsage{CreatedAt: recurly.NewTime(now)}, + expected: MustCompactString(` + + 2000-01-01T00:00:00Z + + `), + }, + { + v: recurly.AddOnUsage{UpdatedAt: recurly.NewTime(now)}, + expected: MustCompactString(` + + 2000-01-01T00:00:00Z + + `), + }, + { + v: recurly.AddOnUsage{BilledAt: recurly.NewTime(now)}, + expected: MustCompactString(` + + 2000-01-01T00:00:00Z + + `), + }, + { + v: recurly.AddOnUsage{UsageType: "price"}, + expected: MustCompactString(` + + price + + `), + }, + + { + v: recurly.AddOnUsage{UnitAmountInCents: 313}, + expected: MustCompactString(` + + 313 + + `), + }, + { + v: recurly.AddOnUsage{UsagePercentage: recurly.NewFloat(0.50)}, + expected: MustCompactString(` + + 0.5 + + `), + }, + } + + for i, tt := range tests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + buf := new(bytes.Buffer) + if err := xml.NewEncoder(buf).Encode(tt.v); err != nil { + t.Fatal(err) + } else if buf.String() != tt.expected { + t.Fatal(buf.String()) + } + }) + } +} + +func TestAddOnUsage_List(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + var invocations int + s.HandleFunc("GET", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage", func(w http.ResponseWriter, r *http.Request) { + invocations++ + w.WriteHeader(http.StatusOK) + w.Write(MustOpenFile("add_on_usages.xml")) + }, t) + + pager := client.AddOnUsages.List("1122334455", "addOnCode", nil) + for pager.Next() { + var a []recurly.AddOnUsage + if err := pager.Fetch(context.Background(), &a); err != nil { + t.Fatal(err) + } else if !s.Invoked { + t.Fatal("expected s to be invoked") + } else if diff := cmp.Diff(a, []recurly.AddOnUsage{*NewTestAddOnUsage()}); diff != "" { + t.Fatal(diff) + } + } + if invocations != 1 { + t.Fatalf("unexpected number of invocations: %d", invocations) + } +} + +func TestAddOnUsage_Get(t *testing.T) { + t.Run("OK", func(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + s.HandleFunc("GET", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage/1234", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(MustOpenFile("add_on_usage.xml")) + }, t) + + if a, err := client.AddOnUsages.Get(context.Background(), "1122334455", "addOnCode", "1234"); err != nil { + t.Fatal(err) + } else if diff := cmp.Diff(a, NewTestAddOnUsage()); diff != "" { + t.Fatal(diff) + } else if !s.Invoked { + t.Fatal("expected fn invocation") + } + }) + + // Ensure a 404 returns nil values. + t.Run("ErrNotFound", func(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + s.HandleFunc("GET", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage/8888", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, t) + + if a, err := client.AddOnUsages.Get(context.Background(), "1122334455", "addOnCode", "8888"); !s.Invoked { + t.Fatal("expected fn invocation") + } else if err != nil { + t.Fatal(err) + } else if a != nil { + t.Fatalf("expected nil: %#v", a) + } + }) +} + +func TestAddOnUsage_Create(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + s.HandleFunc("POST", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write(MustOpenFile("add_on_usage.xml")) + }, t) + + if a, err := client.AddOnUsages.Create(context.Background(), "1122334455", "addOnCode", recurly.AddOnUsage{}); !s.Invoked { + t.Fatal("expected fn invocation") + } else if err != nil { + t.Fatal(err) + } else if diff := cmp.Diff(a, NewTestAddOnUsage()); diff != "" { + t.Fatal(diff) + } +} + +func TestAddOnUsage_Update(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + s.HandleFunc("PUT", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage/1234", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(MustOpenFile("add_on_usage.xml")) + }, t) + + if a, err := client.AddOnUsages.Update(context.Background(), "1122334455", "addOnCode", "1234", recurly.AddOnUsage{}); !s.Invoked { + t.Fatal("expected fn invocation") + } else if err != nil { + t.Fatal(err) + } else if diff := cmp.Diff(a, NewTestAddOnUsage()); diff != "" { + t.Fatal(diff) + } +} + +func TestAddOnUsage_Delete(t *testing.T) { + client, s := recurly.NewTestServer() + defer s.Close() + + s.HandleFunc("DELETE", "/v2/subscriptions/1122334455/add_ons/addOnCode/usage/1234", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, t) + + if err := client.AddOnUsages.Delete(context.Background(), "1122334455", "addOnCode", "1234"); !s.Invoked { + t.Fatal("expected fn invocation") + } else if err != nil { + t.Fatal(err) + } +} + +// Returns add on corresponding to testdata/add_on.xml +func NewTestAddOnUsage() *recurly.AddOnUsage { + now := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + return &recurly.AddOnUsage{ + XMLName: xml.Name{Local: "usage"}, + Amount: 1, + MerchantTag: "Order ID: 4939853977878713", + RecordingTimestamp: recurly.NewTime(now), + UsageTimestamp: recurly.NewTime(now), + CreatedAt: recurly.NewTime(now), + UpdatedAt: recurly.NullTime{}, + BilledAt: recurly.NullTime{}, + UsageType: "price", + UnitAmountInCents: 45, + UsagePercentage: recurly.NewFloat(12.34), + } +} diff --git a/mock/add_on_usage.go b/mock/add_on_usage.go new file mode 100644 index 0000000..7f6f3ff --- /dev/null +++ b/mock/add_on_usage.go @@ -0,0 +1,50 @@ +package mock + +import ( + "context" + "github.com/blacklightcms/recurly" +) + +var _ recurly.AddOnUsageService = &AddOnUsageService{} + +type AddOnUsageService struct { + OnList func(uuid, addOnCode string, opts *recurly.PagerOptions) recurly.Pager + ListInvoked bool + + OnGet func(ctx context.Context, uuid, addOnCode, usageId string) (*recurly.AddOnUsage, error) + GetInvoked bool + + OnCreate func(ctx context.Context, uuid, addOnCode string, usage recurly.AddOnUsage) (*recurly.AddOnUsage, error) + CreateInvoked bool + + OnUpdate func(ctx context.Context, uuid, addOnCode string, usage recurly.AddOnUsage) (*recurly.AddOnUsage, error) + UpdateInvoked bool + + OnDelete func(ctx context.Context, uuid, addOnCode, usageId string) error + DeleteInvoked bool +} + +func (m *AddOnUsageService) List(uuid, addOnCode string, opts *recurly.PagerOptions) recurly.Pager { + m.ListInvoked = true + return m.OnList(uuid, addOnCode, opts) +} + +func (m *AddOnUsageService) Get(ctx context.Context, uuid, addOnCode, usageId string) (*recurly.AddOnUsage, error) { + m.GetInvoked = true + return m.OnGet(ctx, uuid, addOnCode, usageId) +} + +func (m *AddOnUsageService) Create(ctx context.Context, uuid, addOnCode string, usage recurly.AddOnUsage) (*recurly.AddOnUsage, error) { + m.CreateInvoked = true + return m.OnCreate(ctx, uuid, addOnCode, usage) +} + +func (m *AddOnUsageService) Update(ctx context.Context, uuid, addOnCode, usageId string, usage recurly.AddOnUsage) (*recurly.AddOnUsage, error) { + m.UpdateInvoked = true + return m.OnUpdate(ctx, uuid, addOnCode, usage) +} + +func (m *AddOnUsageService) Delete(ctx context.Context, uuid, addOnCode, usageId string) error { + m.DeleteInvoked = true + return m.OnDelete(ctx, uuid, addOnCode, usageId) +} diff --git a/mock/client.go b/mock/client.go index bfe570a..efe28b8 100644 --- a/mock/client.go +++ b/mock/client.go @@ -11,6 +11,7 @@ type Client struct { Accounts AccountsService AddOns AddOnsService + AddOnUsages AddOnUsageService Adjustments AdjustmentsService Billing BillingService Coupons CouponsService @@ -33,6 +34,7 @@ func NewClient(subdomain, apiKey string) *Client { // Attach mock implementations. c.Client.Accounts = &c.Accounts c.Client.AddOns = &c.AddOns + c.Client.AddOnUsages = &c.AddOnUsages c.Client.Adjustments = &c.Adjustments c.Client.Billing = &c.Billing c.Client.Coupons = &c.Coupons diff --git a/pager.go b/pager.go index 25c860e..2a81255 100644 --- a/pager.go +++ b/pager.go @@ -116,6 +116,7 @@ func (p *pager) Fetch(ctx context.Context, dst interface{}) error { Account []Account `xml:"account"` Adjustment []Adjustment `xml:"adjustment"` AddOn []AddOn `xml:"add_on"` + AddOnUsage []AddOnUsage `xml:"usage"` Coupon []Coupon `xml:"coupon"` CreditPayment []CreditPayment `xml:"credit_payment"` ExportDate []ExportDate `xml:"export_date"` @@ -146,6 +147,8 @@ func (p *pager) Fetch(ctx context.Context, dst interface{}) error { *v = unmarshaler.Adjustment case *[]AddOn: *v = unmarshaler.AddOn + case *[]AddOnUsage: + *v = unmarshaler.AddOnUsage case *[]Coupon: *v = unmarshaler.Coupon case *[]CreditPayment: @@ -213,6 +216,16 @@ func (p *pager) FetchAll(ctx context.Context, dst interface{}) error { all = append(all, dst...) } *v = all + case *[]AddOnUsage: + var all []AddOnUsage + for p.Next() { + var dst []AddOnUsage + if err := p.Fetch(ctx, &dst); err != nil { + return err + } + all = append(all, dst...) + } + *v = all case *[]Coupon: var all []Coupon for p.Next() { @@ -345,7 +358,7 @@ type PagerOptions struct { // query is for any one-off URL params used by a specific endpoint. // Values sent as time.Time or recurly.NullTime will be automatically // converted to a valid datetime format for Recurly. - query query + Query query // Cursor is set internally by the library. If you are paginating // records non-consecutively and obtained the next cursor, you can set it @@ -390,19 +403,19 @@ func (q query) append(u *url.URL) { // append appends params to a URL. func (p PagerOptions) append(u *url.URL) { - if p.query == nil { - p.query = map[string]interface{}{} + if p.Query == nil { + p.Query = map[string]interface{}{} } if p.PerPage > 0 { - p.query["per_page"] = p.PerPage + p.Query["per_page"] = p.PerPage } - p.query["begin_time"] = p.BeginTime.String() - p.query["end_time"] = p.EndTime.String() - p.query["sort"] = p.Sort - p.query["order"] = p.Order - p.query["state"] = p.State - p.query["type"] = p.Type - p.query["cursor"] = p.Cursor - p.query.append(u) + p.Query["begin_time"] = p.BeginTime.String() + p.Query["end_time"] = p.EndTime.String() + p.Query["sort"] = p.Sort + p.Query["order"] = p.Order + p.Query["state"] = p.State + p.Query["type"] = p.Type + p.Query["cursor"] = p.Cursor + p.Query.append(u) } diff --git a/recurly.go b/recurly.go index b8de622..b94bdf3 100644 --- a/recurly.go +++ b/recurly.go @@ -54,6 +54,7 @@ type Client struct { Accounts AccountsService Adjustments AdjustmentsService AddOns AddOnsService + AddOnUsages AddOnUsageService AutomatedExports AutomatedExportsService Billing BillingService Coupons CouponsService @@ -96,6 +97,7 @@ func NewClient(subdomain, apiKey string) *Client { client.Accounts = &accountsImpl{client: client} client.Adjustments = &adjustmentsImpl{client: client} client.AddOns = &addOnsImpl{client: client} + client.AddOnUsages = &addOnUsageServiceImpl{client: client} client.AutomatedExports = &automatedExportsImpl{client: client} client.Billing = &billingImpl{client: client} client.Coupons = &couponsImpl{client: client} diff --git a/testdata/add_on_usage.xml b/testdata/add_on_usage.xml new file mode 100644 index 0000000..950fb4c --- /dev/null +++ b/testdata/add_on_usage.xml @@ -0,0 +1,14 @@ + + + + 1 + Order ID: 4939853977878713 + 2000-01-01T00:00:00Z + 2000-01-01T00:00:00Z + 2000-01-01T00:00:00Z + + + price + 45 + 12.34 + \ No newline at end of file diff --git a/testdata/add_on_usages.xml b/testdata/add_on_usages.xml new file mode 100644 index 0000000..671d242 --- /dev/null +++ b/testdata/add_on_usages.xml @@ -0,0 +1,16 @@ + + + + + 1 + Order ID: 4939853977878713 + 2000-01-01T00:00:00Z + 2000-01-01T00:00:00Z + 2000-01-01T00:00:00Z + + + price + 45 + 12.34 + + \ No newline at end of file diff --git a/xml.go b/xml.go index d528272..d049ae1 100644 --- a/xml.go +++ b/xml.go @@ -164,6 +164,82 @@ func (n NullInt) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return nil } +// NullFloat is used for properly handling float types that could be null. (float64 is returned) +type NullFloat struct { + value float64 + valid bool +} + +// NullFloat returns NullFloat with a valid value of f. +func NewFloat(f float64) NullFloat { + return NullFloat{value: f, valid: true} +} + +// NewFloatPtr returns a new NullFloat from a pointer to float64. +func NewFloatPtr(f *float64) NullFloat { + if f == nil { + return NullFloat{} + } + return NewFloat(*f) +} + +// Float64 returns the float64 value, regardless of validity. Use Value() if +// you need to know whether the value is valid. +func (n NullFloat) Float64() float64 { + return n.value +} + +// Float64Ptr returns a pointer to the float64 value, or nil if the value is not valid. +func (n NullFloat) Float64Ptr() *float64 { + if n.valid { + return &n.value + } + return nil +} + +// Value returns the value of NullFloat. The value should only be considered +// valid if ok returns true. +func (n NullFloat) Value() (value float64, ok bool) { + return n.value, n.valid +} + +// Equal compares the equality of two NullFloat. +func (n NullFloat) Equal(v NullFloat) bool { + return n.value == v.value && n.valid == v.valid +} + +// MarshalJSON marshals an float64 based on whether valid is true. +func (n NullFloat) MarshalJSON() ([]byte, error) { + if n.valid { + return json.Marshal(n.value) + } + return []byte("null"), nil +} + +// UnmarshalXML unmarshals an float64 properly, as well as marshaling an empty string to nil. +func (n *NullFloat) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v struct { + Float float64 `xml:",chardata"` + Nil string `xml:"nil,attr"` + } + if err := d.DecodeElement(&v, &start); err != nil { + return err + } else if strings.EqualFold(v.Nil, "nil") || strings.EqualFold(v.Nil, "true") { + return nil + } + *n = NewFloat(v.Float) + return nil +} + +// MarshalXML marshals NullFloat greater than zero to XML. Otherwise nothing is +// marshaled. +func (n NullFloat) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if n.valid { + return e.EncodeElement(n.value, start) + } + return nil +} + // DateTimeFormat is the format Recurly uses to represent datetimes. const DateTimeFormat = "2006-01-02T15:04:05Z07:00" diff --git a/xml_test.go b/xml_test.go index 8fa28ba..5f87d06 100644 --- a/xml_test.go +++ b/xml_test.go @@ -233,6 +233,116 @@ func TestXML_NullIntPtr(t *testing.T) { } } +func TestXML_NullFloat(t *testing.T) { + t.Run("ZeroValue", func(t *testing.T) { + var f recurly.NullFloat + if value, ok := f.Value(); ok { + t.Fatal("expected ok to be false") + } else if value != 0 { + t.Fatalf("unexpected value: %f", value) + } + + if f.Float64() != 0 { + t.Fatalf("unexpected value: %f", f.Float64()) + } else if ptr := f.Float64Ptr(); ptr != nil { + t.Fatalf("expected nil: %#v", ptr) + } + }) + + f := recurly.NewFloat(12.34) + if value, ok := f.Value(); !ok { + t.Fatal("expected ok to be true") + } else if value != 12.34 { + t.Fatalf("unexpected value: %f", value) + } + + if f.Float64() != 12.34 { + t.Fatalf("unexpected value: %f", f.Float64()) + } else if ptr := f.Float64Ptr(); ptr == nil { + t.Fatal("expected non-nil value") + } else if *ptr != 12.34 { + t.Fatalf("unexpected value: %#v", ptr) + } + + f = recurly.NewFloat(0.00) + if value, ok := f.Value(); !ok { + t.Fatal("expected ok to be true") + } else if value != 0 { + t.Fatalf("unexpected value: %f", value) + } + + if f.Float64() != 0 { + t.Fatalf("unexpected value: %f", f.Float64()) + } else if ptr := f.Float64Ptr(); ptr == nil { + t.Fatal("expected non-nil value") + } else if *ptr != 0 { + t.Fatalf("unexpected value: %#v", ptr) + } + + type testStruct struct { + XMLName xml.Name `xml:"test"` + Value recurly.NullFloat `xml:"f"` + } + + t.Run("Encode", func(t *testing.T) { + for i, tt := range []struct { + value recurly.NullFloat + expect string + }{ + {value: recurly.NewFloat(12.34), expect: `12.34`}, + {value: recurly.NewFloat(0.0000), expect: `0`}, + {value: recurly.NewFloat(-12.34), expect: `-12.34`}, + {value: recurly.NewFloat(-0.01), expect: `-0.01`}, + {value: recurly.NewFloat(0.009), expect: `0.009`}, + {expect: ``}, // zero value + } { + if aXml, err := xml.Marshal(testStruct{Value: tt.value}); err != nil { + t.Fatalf("%d %#v", i, err) + } else if string(aXml) != tt.expect { + t.Fatalf("%d %s", i, string(aXml)) + } + } + }) + + t.Run("Decode", func(t *testing.T) { + for i, tt := range []struct { + expect recurly.NullFloat + input string + }{ + {expect: recurly.NewFloat(12.34), input: `12.34`}, + {expect: recurly.NewFloat(0), input: `0`}, + {expect: recurly.NewFloat(-12.34), input: `-12.34`}, + {expect: recurly.NewFloat(-0.01), input: `-0.01`}, + {expect: recurly.NewFloat(0.009), input: `0.009`}, + {input: ``}, // zero value + } { + var dst testStruct + if err := xml.Unmarshal([]byte(tt.input), &dst); err != nil { + t.Fatalf("%d %#v", i, err) + } else if diff := cmp.Diff(testStruct{XMLName: xml.Name{Local: "test"}, Value: tt.expect}, dst); diff != "" { + t.Fatalf("%d %s", i, diff) + } + } + }) +} + +func TestXML_NullFloatPtr(t *testing.T) { + f := recurly.NewFloatPtr(nil) + if value, ok := f.Value(); ok { + t.Fatal("expected ok to be false") + } else if value != 0 { + t.Fatalf("unexpected value: %f", value) + } + + floatVal := 0.07 + f = recurly.NewFloatPtr(&floatVal) + if value, ok := f.Value(); !ok { + t.Fatal("expected ok to be true") + } else if value != 0.07 { + t.Fatalf("unexpected value: %f", value) + } +} + func TestXML_NullTime(t *testing.T) { t.Run("ZeroValue", func(t *testing.T) { var rt recurly.NullTime