Refactor the internal HTTP helper from get() returning raw bytes to getAndDecode() that streams directly into the target struct via json.NewDecoder. This eliminates the intermediate []byte allocation from io.ReadAll on every API response. The new decoder also validates that responses contain exactly one JSON value by attempting a second Decode after the primary one — any content beyond the first value (e.g., concatenated objects from a misbehaving proxy) returns an error instead of silently discarding it. Changes: - api/client.go: Replace get() with getAndDecode(), update FetchStores and FetchSavings callers to use the new signature - api/client_test.go: Add TestFetchSavings_TrailingJSONIsRejected and TestFetchStores_MalformedJSONReturnsDecodeError covering the new decoder error paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
4.7 KiB
Go
172 lines
4.7 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tayloree/publix-deals/internal/api"
|
|
)
|
|
|
|
func ptr(s string) *string { return &s }
|
|
|
|
func newTestSavingsServer(t *testing.T, storeNumber string, items []api.SavingItem) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify the PublixStore header is sent
|
|
got := r.Header.Get("PublixStore")
|
|
if storeNumber != "" {
|
|
assert.Equal(t, storeNumber, got, "PublixStore header mismatch")
|
|
}
|
|
|
|
resp := api.SavingsResponse{
|
|
Savings: items,
|
|
LanguageID: 1,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
}
|
|
|
|
func newTestStoreServer(t *testing.T, stores []api.Store) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.NotEmpty(t, r.URL.Query().Get("zipCode"), "zipCode param required")
|
|
|
|
resp := api.StoreResponse{Stores: stores}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
}
|
|
|
|
func TestFetchSavings(t *testing.T) {
|
|
items := []api.SavingItem{
|
|
{
|
|
ID: "test-1",
|
|
Title: ptr("Chicken Breasts"),
|
|
Savings: ptr("$3.99 lb"),
|
|
Department: ptr("Meat"),
|
|
Categories: []string{"meat"},
|
|
StartFormatted: "2/18",
|
|
EndFormatted: "2/24",
|
|
},
|
|
{
|
|
ID: "test-2",
|
|
Title: ptr("Nutella"),
|
|
Savings: ptr("Buy 1 Get 1 FREE"),
|
|
Categories: []string{"bogo", "grocery"},
|
|
},
|
|
}
|
|
|
|
srv := newTestSavingsServer(t, "1425", items)
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs(srv.URL, "")
|
|
resp, err := client.FetchSavings(context.Background(), "1425")
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, resp.Savings, 2)
|
|
assert.Equal(t, "Chicken Breasts", *resp.Savings[0].Title)
|
|
assert.Equal(t, "Buy 1 Get 1 FREE", *resp.Savings[1].Savings)
|
|
}
|
|
|
|
func TestFetchSavings_EmptyStore(t *testing.T) {
|
|
srv := newTestSavingsServer(t, "", nil)
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs(srv.URL, "")
|
|
resp, err := client.FetchSavings(context.Background(), "")
|
|
|
|
require.NoError(t, err)
|
|
assert.Empty(t, resp.Savings)
|
|
}
|
|
|
|
func TestFetchSavings_ServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs(srv.URL, "")
|
|
_, err := client.FetchSavings(context.Background(), "1425")
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "500")
|
|
}
|
|
|
|
func TestFetchStores(t *testing.T) {
|
|
stores := []api.Store{
|
|
{Key: "01425", Name: "Peachers Mill", City: "Clarksville", State: "TN", Zip: "37042", Distance: "5"},
|
|
{Key: "00100", Name: "Downtown", City: "Nashville", State: "TN", Zip: "37201", Distance: "15"},
|
|
}
|
|
|
|
srv := newTestStoreServer(t, stores)
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs("", srv.URL)
|
|
result, err := client.FetchStores(context.Background(), "37042", 5)
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, result, 2)
|
|
assert.Equal(t, "Peachers Mill", result[0].Name)
|
|
assert.Equal(t, "01425", result[0].Key)
|
|
}
|
|
|
|
func TestFetchStores_NoResults(t *testing.T) {
|
|
srv := newTestStoreServer(t, nil)
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs("", srv.URL)
|
|
result, err := client.FetchStores(context.Background(), "00000", 5)
|
|
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
func TestFetchSavings_TrailingJSONIsRejected(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"Savings":[],"LanguageId":1} {"extra":true}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs(srv.URL, "")
|
|
_, err := client.FetchSavings(context.Background(), "1425")
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "decoding")
|
|
}
|
|
|
|
func TestFetchStores_MalformedJSONReturnsDecodeError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"Stores":`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := api.NewClientWithBaseURLs("", srv.URL)
|
|
_, err := client.FetchStores(context.Background(), "37042", 5)
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "decoding")
|
|
}
|
|
|
|
func TestStoreNumber(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"01425", "1425"},
|
|
{"00100", "100"},
|
|
{"1425", "1425"},
|
|
{"0", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, api.StoreNumber(tt.input), "StoreNumber(%q)", tt.input)
|
|
}
|
|
}
|