1package rest
2
3import (
4	"bytes"
5	"fmt"
6	"io"
7	"net/http"
8	"net/http/httptest"
9	"strings"
10	"testing"
11	"time"
12)
13
14type Data struct {
15	Version int64  `json:"version"`
16	Msg     string `json:"msg"`
17}
18
19var goldenCases = []struct {
20	Name           string
21	Method         string
22	Path           string
23	Body           string
24	Headers        map[string]string
25	WantStatusCode int
26	WantBody       string
27	WantHeaders    map[string]string
28	Operation      interface{}
29}{
30
31	{"create",
32		"POST", "/", `{"msg": "hello"}`, map[string]string{"Content-Type": "application/json"},
33		201, "", map[string]string{
34			"Location": "/1001",
35			"ETag":     `"1456260879956532222"`,
36		},
37		func(d *Data) (int64, error) {
38			d.Version = 1456260879956532222
39			return 1001, nil
40		},
41	},
42	{"create fail & verify version untouched",
43		"POST", "/", `{"version": 3, "msg": "hello"}`, map[string]string{"Content-Type": "application/json"},
44		500, "error v3\n",
45		map[string]string{
46			"Location": "",
47			"ETag":     "",
48		},
49		func(d *Data) (int64, error) {
50			return 0, fmt.Errorf("error v%d", d.Version)
51		},
52	},
53
54	// Verify call arguments:
55	{"read latest fail",
56		"GET", "/99", "", nil,
57		500, "error 99 v0\n", nil,
58		func(id, version int64) (*Data, error) {
59			return nil, fmt.Errorf("error %d v%d", id, version)
60		},
61	},
62	{"read version fail",
63		"GET", "/99?v=2", "", nil,
64		500, "error 99 v2\n", nil,
65		func(id, version int64) (*Data, error) {
66			return nil, fmt.Errorf("error %d v%d", id, version)
67		},
68	},
69
70	// Verify return arguments:
71	{"read not found",
72		"GET", "/99?v=2", "", nil,
73		404, "ID 99 not found\n", map[string]string{
74			"Content-Location": "",
75			"ETag":             "",
76			"Last-Modified":    "",
77		},
78		func(id, version int64) (*Data, error) {
79			return nil, ErrNotFound
80		},
81	},
82	{"read version not found",
83		"GET", "/99?v=2", "", nil,
84		404, "version 2 not found (latest is 1)\n", map[string]string{
85			"Content-Location": "",
86			"ETag":             "",
87			"Last-Modified":    "",
88		},
89		func(id, version int64) (*Data, error) {
90			return &Data{Version: 1}, nil
91		},
92	},
93
94	// Caching:
95	{"read cache miss",
96		"GET", "/99", "", map[string]string{"If-None-Match": `"1456249153812139289"`, "If-Modified-Since": "Tue, 23 Feb 2016 20:54:39 UTC"},
97		200, "{\n\t\"version\": 1456260879956532222,\n\t\"msg\": \"hello 99 v0\"\n}\n", map[string]string{
98			"Content-Location": "/99?v=1456260879956532222",
99			"Allow":            "OPTIONS, GET, HEAD",
100			"ETag":             `"1456260879956532222"`,
101			"Last-Modified":    "Tue, 23 Feb 2016 20:54:39 UTC",
102			"Content-Type":     "application/json;charset=UTF-8",
103		},
104		func(id, version int64) (*Data, error) {
105			return &Data{1456260879956532222, fmt.Sprintf("hello %d v%d", id, version)}, nil
106		},
107	},
108	{"read ETag cache hit",
109		"GET", "/99", "", map[string]string{"If-None-Match": `"1456260879956532222"`},
110		304, "", map[string]string{
111			"Content-Location": "/99?v=1456260879956532222",
112			"Allow":            "OPTIONS, GET, HEAD",
113			"ETag":             `"1456260879956532222"`,
114			"Last-Modified":    "", // forbidden by status code
115		},
116		func(id, version int64) (*Data, error) {
117			return &Data{Version: 1456260879956532222}, nil
118		},
119	},
120	{"read timestamp cache hit",
121		"GET", "/99", "", map[string]string{"If-Modified-Since": "Tue, 23 Feb 2016 20:54:40 UTC"},
122		304, "", map[string]string{
123			"Content-Location": "/99?v=1456260879956532222",
124			"Allow":            "OPTIONS, GET, HEAD",
125			"ETag":             `"1456260879956532222"`,
126			"Last-Modified":    "", // forbidden by status code
127		},
128		func(id, version int64) (*Data, error) {
129			return &Data{Version: 1456260879956532222}, nil
130		},
131	},
132
133	{"update fail",
134		"PUT", "/99", `{"version": 2, "msg": "hello"}`, map[string]string{"Content-Type": "application/json"},
135		500, "hello 99 v2\n", nil,
136		func(id int64, d *Data) error {
137			return fmt.Errorf("%s %d v%d", d.Msg, id, d.Version)
138		},
139	},
140	{"update version match",
141		"PUT", "/99", `{"version": 2, "msg": "hello"}`, map[string]string{"Content-Type": "application/json", "If-Match": `"1456260879956532222"`},
142		200, "{\n\t\"version\": 1456264479956532222,\n\t\"msg\": \"hello\"\n}\n", map[string]string{
143			"Content-Location": "/99?v=1456264479956532222",
144			"Allow":            "OPTIONS, PUT",
145			"Content-Type":     "application/json;charset=UTF-8",
146			"ETag":             `"1456264479956532222"`,
147			"Last-Modified":    "Tue, 23 Feb 2016 21:54:39 UTC",
148		},
149		func(id int64, d *Data) error {
150			d.Version += int64(time.Hour)
151			return nil
152		},
153	},
154
155	{"delete fail",
156		"DELETE", "/666", "", nil,
157		500, "operation rejected\n", nil,
158		func(id, version int64) error {
159			if id != 666 || version != 0 {
160				return fmt.Errorf("got ID %d v%d", id, version)
161			}
162			return fmt.Errorf("operation rejected")
163		},
164	},
165	{"delete version match not found",
166		"DELETE", "/666", "", map[string]string{"If-Match": `"1456260879956532222"`},
167		404, "", nil,
168		func(id, version int64) error {
169			if id != 666 || version != 1456260879956532222 {
170				return fmt.Errorf("got ID %d v%d", id, version)
171			}
172			return ErrNotFound
173		},
174	},
175	{"delete version match optimistic lock lost",
176		"DELETE", "/666", "", map[string]string{"If-Match": `"1456260879956532222"`},
177		412, "lost optimistic lock\n", nil,
178		func(id, version int64) error {
179			if id != 666 || version != 1456260879956532222 {
180				return fmt.Errorf("got ID %d v%d", id, version)
181			}
182			return ErrOptimisticLock
183		},
184	},
185	{"delete version query optimistic lock lost",
186		"DELETE", "/666?v=1456260879956532222", "", nil,
187		405, "not the latest version\n", nil,
188		func(id, version int64) error {
189			if id != 666 || version != 1456260879956532222 {
190				return fmt.Errorf("got ID %d v%d", id, version)
191			}
192			return ErrOptimisticLock
193		},
194	},
195}
196
197func TestGolden(t *testing.T) {
198	for _, gold := range goldenCases {
199		repo := NewCRUD("/", "/Version")
200		switch gold.Method {
201		case "POST":
202			repo.SetCreateFunc(gold.Operation)
203		case "GET":
204			repo.SetReadFunc(gold.Operation)
205		case "PUT":
206			repo.SetUpdateFunc(gold.Operation)
207		case "DELETE":
208			repo.SetDeleteFunc(gold.Operation)
209		default:
210			t.Fatalf("%s: unknown HTTP method %q", gold.Name, gold.Method)
211		}
212		server := httptest.NewServer(repo)
213		defer server.Close()
214
215		var reqBody io.Reader
216		if gold.Body != "" {
217			reqBody = strings.NewReader(gold.Body)
218		}
219		req, err := http.NewRequest(gold.Method, server.URL+gold.Path, reqBody)
220		if err != nil {
221			t.Fatalf("%s: malformed request: %s", gold.Name, err)
222		}
223		for name, value := range gold.Headers {
224			req.Header.Set(name, value)
225		}
226
227		res, err := http.DefaultClient.Do(req)
228		if err != nil {
229			t.Fatalf("%s: HTTP exchange: %s", gold.Name, err)
230		}
231
232		if res.StatusCode != gold.WantStatusCode {
233			buf := new(bytes.Buffer)
234			if err := res.Write(buf); err != nil {
235				t.Fatalf("%s: Read response: %s", gold.Name, err)
236			}
237			t.Logf("%s: response: %s", gold.Name, buf.String())
238
239			t.Errorf("%s: Got HTTP %q, want HTTP %d", gold.Name, res.Status, gold.WantStatusCode)
240		}
241
242		for name, want := range gold.WantHeaders {
243			if got := res.Header.Get(name); got != want {
244				t.Errorf("%s: Got header %s value %q, want %q", gold.Name, name, got, want)
245			}
246		}
247
248		if want := gold.WantBody; want != "" {
249			buf := new(bytes.Buffer)
250			if _, err := buf.ReadFrom(res.Body); err != nil {
251				t.Fatalf("%s: Read response body: %s", gold.Name, err)
252			}
253
254			if got := buf.String(); got != want {
255				t.Errorf("%s: Got body %q, want %q", gold.Name, got, want)
256			}
257		}
258	}
259}
260