Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: cross-user tests, test matrix for recipes, record lifecycle tests

authored by

Patrick Dewey and committed by tangled.org 06b0b19b 506cd0ca

+622
+134
tests/integration/crossuser_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "testing" 8 + 9 + "arabica/internal/models" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + // withClient swaps the harness client temporarily so calls run as a different 16 + // account. Returns a restore function the test should defer. 17 + func withClient(h *Harness, c *http.Client) func() { 18 + prev := h.Client 19 + h.Client = c 20 + return func() { h.Client = prev } 21 + } 22 + 23 + // TestHTTP_CrossUserView creates a roaster as Alice and renders the view page 24 + // as Bob via ?owner=did:alice. Verifies the witness-cache-backed cross-user 25 + // read path that single-user tests skip entirely. 26 + func TestHTTP_CrossUserView(t *testing.T) { 27 + h := StartHarness(t, nil) 28 + 29 + // Alice creates a roaster. 30 + createResp := h.PostForm("/api/roasters", form("name", "Alice Roaster", "location", "Seattle")) 31 + createBody := ReadBody(t, createResp) 32 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 33 + 34 + var roaster models.Roaster 35 + require.NoError(t, json.Unmarshal([]byte(createBody), &roaster)) 36 + require.NotEmpty(t, roaster.RKey) 37 + 38 + // Bob signs in. 39 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 40 + bobClient := h.NewClientForAccount(bob) 41 + defer withClient(h, bobClient)() 42 + 43 + // Bob fetches Alice's roaster view via ?owner=did:alice. 44 + viewURL := "/roasters/" + roaster.RKey + "?owner=" + url.QueryEscape(h.PrimaryAccount.DID) 45 + resp := h.Get(viewURL) 46 + body := ReadBody(t, resp) 47 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 48 + assert.Contains(t, body, "Alice Roaster", 49 + "Bob should see Alice's roaster name in the rendered view") 50 + } 51 + 52 + // TestHTTP_CrossUserDeleteIsolation verifies that DELETE /api/roasters/{rkey} 53 + // only operates on the calling user's PDS — Bob attempting to delete a record 54 + // owned by Alice cannot affect Alice's data. 55 + // 56 + // This is the catastrophic-class authz check: if it ever regresses, one user 57 + // can wipe another user's records by guessing their rkeys. 58 + func TestHTTP_CrossUserDeleteIsolation(t *testing.T) { 59 + h := StartHarness(t, nil) 60 + 61 + // Alice creates a roaster. 62 + createResp := h.PostForm("/api/roasters", form("name", "Alice Owned")) 63 + createBody := ReadBody(t, createResp) 64 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 65 + 66 + var alicesRoaster models.Roaster 67 + require.NoError(t, json.Unmarshal([]byte(createBody), &alicesRoaster)) 68 + 69 + // Bob signs in and tries to delete Alice's record by rkey. 70 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 71 + bobClient := h.NewClientForAccount(bob) 72 + 73 + bobAttempt := func() *http.Response { 74 + restore := withClient(h, bobClient) 75 + defer restore() 76 + return h.Delete("/api/roasters/" + alicesRoaster.RKey) 77 + }() 78 + bobBody := ReadBody(t, bobAttempt) 79 + // We don't pin the exact response — the PDS may return success-as-noop or 80 + // a not-found error. What matters is that Alice's data survives. 81 + t.Logf("bob delete attempt: status=%d body=%s", bobAttempt.StatusCode, bobBody) 82 + 83 + // Back as Alice: confirm her roaster is still present. 84 + data := fetchData(t, h) 85 + var found bool 86 + for _, r := range data.Roasters { 87 + if r.RKey == alicesRoaster.RKey { 88 + found = true 89 + assert.Equal(t, "Alice Owned", r.Name) 90 + } 91 + } 92 + assert.True(t, found, "Alice's roaster must still exist after Bob's delete attempt") 93 + } 94 + 95 + // TestHTTP_CrossUserUpdateIsolation verifies the PUT path is similarly 96 + // isolated: Bob attempting to update a record at Alice's rkey cannot mutate 97 + // Alice's record. 98 + func TestHTTP_CrossUserUpdateIsolation(t *testing.T) { 99 + h := StartHarness(t, nil) 100 + 101 + createResp := h.PostForm("/api/roasters", form( 102 + "name", "Untouchable", "location", "Original Location", 103 + )) 104 + createBody := ReadBody(t, createResp) 105 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 106 + 107 + var alicesRoaster models.Roaster 108 + require.NoError(t, json.Unmarshal([]byte(createBody), &alicesRoaster)) 109 + 110 + bob := h.CreateAccount("bob@test.com", "bob.test", "hunter2") 111 + bobClient := h.NewClientForAccount(bob) 112 + 113 + bobAttempt := func() *http.Response { 114 + restore := withClient(h, bobClient) 115 + defer restore() 116 + return h.PutForm("/api/roasters/"+alicesRoaster.RKey, form( 117 + "name", "PWNED", "location", "Hacker House", 118 + )) 119 + }() 120 + bobBody := ReadBody(t, bobAttempt) 121 + t.Logf("bob update attempt: status=%d body=%s", bobAttempt.StatusCode, bobBody) 122 + 123 + // Alice re-reads her roaster: name and location must be untouched. 124 + data := fetchData(t, h) 125 + var found *models.Roaster 126 + for i := range data.Roasters { 127 + if data.Roasters[i].RKey == alicesRoaster.RKey { 128 + found = &data.Roasters[i] 129 + } 130 + } 131 + require.NotNil(t, found, "Alice's roaster missing after Bob's update attempt") 132 + assert.Equal(t, "Untouchable", found.Name, "Alice's name must not be overwritten") 133 + assert.Equal(t, "Original Location", found.Location, "Alice's location must not be overwritten") 134 + }
+299
tests/integration/lifecycle_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "encoding/json" 5 + "net/url" 6 + "testing" 7 + 8 + "arabica/internal/models" 9 + 10 + "github.com/stretchr/testify/assert" 11 + "github.com/stretchr/testify/require" 12 + ) 13 + 14 + // fetchData fetches /api/data and unmarshals into the generic data envelope. 15 + // Used by lifecycle tests to verify a record's post-update or post-delete 16 + // state through the same code path that the JS cache uses. 17 + func fetchData(t *testing.T, h *Harness) listAllResponse { 18 + t.Helper() 19 + resp := h.Get("/api/data") 20 + body := ReadBody(t, resp) 21 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 22 + 23 + var data listAllResponse 24 + require.NoError(t, json.Unmarshal([]byte(body), &data)) 25 + return data 26 + } 27 + 28 + type listAllResponse struct { 29 + Beans []models.Bean `json:"beans"` 30 + Roasters []models.Roaster `json:"roasters"` 31 + Grinders []models.Grinder `json:"grinders"` 32 + Brewers []models.Brewer `json:"brewers"` 33 + Recipes []models.Recipe `json:"recipes"` 34 + Brews []models.Brew `json:"brews"` 35 + } 36 + 37 + // TestHTTP_BeanLifecycle covers POST → PUT → DELETE for beans, including the 38 + // roaster reference field that's unique to bean update. 39 + func TestHTTP_BeanLifecycle(t *testing.T) { 40 + h := StartHarness(t, nil) 41 + 42 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Lifecycle Roaster")), "roaster") 43 + otherRoasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Other Roaster")), "roaster") 44 + 45 + createResp := h.PostForm("/api/beans", form( 46 + "name", "Lifecycle Bean", 47 + "origin", "Ethiopia", 48 + "roaster_rkey", roasterRKey, 49 + "roast_level", "Light", 50 + )) 51 + beanRKey := mustRKey(t, createResp, "bean") 52 + 53 + // Update: change name + swap roasters. 54 + updateResp := h.PutForm("/api/beans/"+beanRKey, form( 55 + "name", "Lifecycle Bean v2", 56 + "origin", "Kenya", 57 + "roaster_rkey", otherRoasterRKey, 58 + "roast_level", "Medium", 59 + )) 60 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, ReadBody(t, updateResp))) 61 + 62 + data := fetchData(t, h) 63 + var found *models.Bean 64 + for i := range data.Beans { 65 + if data.Beans[i].RKey == beanRKey { 66 + found = &data.Beans[i] 67 + } 68 + } 69 + require.NotNil(t, found) 70 + assert.Equal(t, "Lifecycle Bean v2", found.Name) 71 + assert.Equal(t, "Kenya", found.Origin) 72 + assert.Equal(t, otherRoasterRKey, found.RoasterRKey) 73 + 74 + // Delete. 75 + delResp := h.Delete("/api/beans/" + beanRKey) 76 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 77 + 78 + data = fetchData(t, h) 79 + for _, b := range data.Beans { 80 + assert.NotEqual(t, beanRKey, b.RKey) 81 + } 82 + } 83 + 84 + // TestHTTP_GrinderLifecycle covers POST → PUT → DELETE for grinders. 85 + func TestHTTP_GrinderLifecycle(t *testing.T) { 86 + h := StartHarness(t, nil) 87 + 88 + rkey := mustRKey(t, h.PostForm("/api/grinders", form( 89 + "name", "Original Grinder", 90 + "grinder_type", "Manual", 91 + "burr_type", "Conical", 92 + )), "grinder") 93 + 94 + updateResp := h.PutForm("/api/grinders/"+rkey, form( 95 + "name", "Updated Grinder", 96 + "grinder_type", "Electric", 97 + "burr_type", "Flat", 98 + "notes", "upgraded", 99 + )) 100 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, ReadBody(t, updateResp))) 101 + 102 + data := fetchData(t, h) 103 + var found *models.Grinder 104 + for i := range data.Grinders { 105 + if data.Grinders[i].RKey == rkey { 106 + found = &data.Grinders[i] 107 + } 108 + } 109 + require.NotNil(t, found) 110 + assert.Equal(t, "Updated Grinder", found.Name) 111 + assert.Equal(t, "Electric", found.GrinderType) 112 + assert.Equal(t, "Flat", found.BurrType) 113 + 114 + delResp := h.Delete("/api/grinders/" + rkey) 115 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 116 + 117 + data = fetchData(t, h) 118 + for _, g := range data.Grinders { 119 + assert.NotEqual(t, rkey, g.RKey) 120 + } 121 + } 122 + 123 + // TestHTTP_BrewerLifecycle covers POST → PUT → DELETE for brewers. 124 + func TestHTTP_BrewerLifecycle(t *testing.T) { 125 + h := StartHarness(t, nil) 126 + 127 + rkey := mustRKey(t, h.PostForm("/api/brewers", form( 128 + "name", "V60", 129 + "brewer_type", "Pour Over", 130 + )), "brewer") 131 + 132 + updateResp := h.PutForm("/api/brewers/"+rkey, form( 133 + "name", "V60 Plastic", 134 + "brewer_type", "Pour Over", 135 + "description", "02 size, plastic body", 136 + )) 137 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, ReadBody(t, updateResp))) 138 + 139 + data := fetchData(t, h) 140 + var found *models.Brewer 141 + for i := range data.Brewers { 142 + if data.Brewers[i].RKey == rkey { 143 + found = &data.Brewers[i] 144 + } 145 + } 146 + require.NotNil(t, found) 147 + assert.Equal(t, "V60 Plastic", found.Name) 148 + assert.Equal(t, "02 size, plastic body", found.Description) 149 + 150 + delResp := h.Delete("/api/brewers/" + rkey) 151 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 152 + 153 + data = fetchData(t, h) 154 + for _, b := range data.Brewers { 155 + assert.NotEqual(t, rkey, b.RKey) 156 + } 157 + } 158 + 159 + // TestHTTP_RecipeLifecycle covers POST → PUT → DELETE for recipes, including 160 + // the brewer reference and pours field. 161 + func TestHTTP_RecipeLifecycle(t *testing.T) { 162 + h := StartHarness(t, nil) 163 + 164 + brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "Recipe Brewer", "brewer_type", "Pour Over")), "brewer") 165 + 166 + createResp := h.PostForm("/api/recipes", form( 167 + "name", "Original Recipe", 168 + "brewer_rkey", brewerRKey, 169 + "brewer_type", "Pour Over", 170 + "coffee_amount", "18", 171 + "water_amount", "300", 172 + "notes", "v1", 173 + "pour_water_0", "60", 174 + "pour_time_0", "0", 175 + "pour_water_1", "240", 176 + "pour_time_1", "45", 177 + )) 178 + recipeRKey := mustRKey(t, createResp, "recipe") 179 + 180 + updateResp := h.PutForm("/api/recipes/"+recipeRKey, form( 181 + "name", "Updated Recipe", 182 + "brewer_rkey", brewerRKey, 183 + "brewer_type", "Pour Over", 184 + "coffee_amount", "20", 185 + "water_amount", "320", 186 + "notes", "v2", 187 + "pour_water_0", "60", 188 + "pour_time_0", "0", 189 + "pour_water_1", "130", 190 + "pour_time_1", "45", 191 + "pour_water_2", "130", 192 + "pour_time_2", "90", 193 + )) 194 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, ReadBody(t, updateResp))) 195 + 196 + data := fetchData(t, h) 197 + var found *models.Recipe 198 + for i := range data.Recipes { 199 + if data.Recipes[i].RKey == recipeRKey { 200 + found = &data.Recipes[i] 201 + } 202 + } 203 + require.NotNil(t, found) 204 + assert.Equal(t, "Updated Recipe", found.Name) 205 + assert.Equal(t, 20.0, found.CoffeeAmount) 206 + assert.Equal(t, "v2", found.Notes) 207 + assert.Len(t, found.Pours, 3, "expected three pours after update") 208 + 209 + delResp := h.Delete("/api/recipes/" + recipeRKey) 210 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 211 + 212 + data = fetchData(t, h) 213 + for _, r := range data.Recipes { 214 + assert.NotEqual(t, recipeRKey, r.RKey) 215 + } 216 + } 217 + 218 + // TestHTTP_BrewLifecycle covers POST → PUT → DELETE for brews. The update path 219 + // is the most complex: it re-marshals references, pours, and method-specific 220 + // params. This test starts with a pourover brew and updates it to espresso to 221 + // exercise method-param swapping (the EspressoParams marshaling path that 222 + // TestHTTP_BrewCreatePourover doesn't reach). 223 + func TestHTTP_BrewLifecycle(t *testing.T) { 224 + h := StartHarness(t, nil) 225 + 226 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Brew LC Roaster")), "roaster") 227 + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( 228 + "name", "Brew LC Bean", 229 + "roaster_rkey", roasterRKey, 230 + "roast_level", "Medium", 231 + )), "bean") 232 + grinderRKey := mustRKey(t, h.PostForm("/api/grinders", form("name", "LC Grinder", "grinder_type", "Electric")), "grinder") 233 + pourBrewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "LC V60", "brewer_type", "Pour Over")), "brewer") 234 + espBrewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "LC Espresso", "brewer_type", "Espresso")), "brewer") 235 + 236 + // Create as pourover. 237 + createForm := url.Values{} 238 + createForm.Set("bean_rkey", beanRKey) 239 + createForm.Set("grinder_rkey", grinderRKey) 240 + createForm.Set("brewer_rkey", pourBrewerRKey) 241 + createForm.Set("method", "Pour Over") 242 + createForm.Set("water_amount", "300") 243 + createForm.Set("coffee_amount", "18") 244 + createForm.Set("time_seconds", "210") 245 + createForm.Set("rating", "7") 246 + createForm.Set("pour_water_0", "60") 247 + createForm.Set("pour_time_0", "0") 248 + createForm.Set("pour_water_1", "240") 249 + createForm.Set("pour_time_1", "45") 250 + createForm.Set("pourover_bloom_water", "60") 251 + createForm.Set("pourover_bloom_seconds", "30") 252 + 253 + createResp := h.PostForm("/brews", createForm) 254 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, ReadBody(t, createResp))) 255 + 256 + data := fetchData(t, h) 257 + require.Len(t, data.Brews, 1) 258 + brewRKey := data.Brews[0].RKey 259 + require.NotEmpty(t, brewRKey) 260 + 261 + // Update to espresso: drop pours + pourover params, add espresso params, 262 + // swap brewer. 263 + updateForm := url.Values{} 264 + updateForm.Set("bean_rkey", beanRKey) 265 + updateForm.Set("grinder_rkey", grinderRKey) 266 + updateForm.Set("brewer_rkey", espBrewerRKey) 267 + updateForm.Set("method", "Espresso") 268 + updateForm.Set("water_amount", "36") 269 + updateForm.Set("coffee_amount", "18") 270 + updateForm.Set("time_seconds", "28") 271 + updateForm.Set("rating", "9") 272 + updateForm.Set("tasting_notes", "syrupy, chocolate") 273 + updateForm.Set("espresso_yield_weight", "36") 274 + updateForm.Set("espresso_pressure", "9") 275 + updateForm.Set("espresso_pre_infusion_seconds", "5") 276 + 277 + updateResp := h.PutForm("/brews/"+brewRKey, updateForm) 278 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, ReadBody(t, updateResp))) 279 + 280 + data = fetchData(t, h) 281 + require.Len(t, data.Brews, 1) 282 + updated := data.Brews[0] 283 + assert.Equal(t, "Espresso", updated.Method) 284 + assert.Equal(t, espBrewerRKey, updated.BrewerRKey) 285 + assert.Equal(t, 9, updated.Rating) 286 + assert.Equal(t, 36, updated.WaterAmount) 287 + assert.Equal(t, "syrupy, chocolate", updated.TastingNotes) 288 + require.NotNil(t, updated.EspressoParams, "espresso params should be present after update") 289 + assert.Equal(t, 36.0, updated.EspressoParams.YieldWeight) 290 + assert.Equal(t, 9.0, updated.EspressoParams.Pressure) 291 + assert.Equal(t, 5, updated.EspressoParams.PreInfusionSeconds) 292 + 293 + // Delete. 294 + delResp := h.Delete("/brews/" + brewRKey) 295 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, ReadBody(t, delResp))) 296 + 297 + data = fetchData(t, h) 298 + assert.Empty(t, data.Brews, "brew should be gone after delete") 299 + }
+189
tests/integration/validation_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "net/url" 5 + "strings" 6 + "testing" 7 + 8 + "github.com/stretchr/testify/assert" 9 + "github.com/stretchr/testify/require" 10 + ) 11 + 12 + // TestHTTP_ValidationErrors covers the per-entity Validate() error matrix. 13 + // Each case asserts the create handler returns 4xx for clearly invalid input. 14 + // We don't pin the exact status (some validations land in the decode path, 15 + // some in Validate(), some in field-specific guards) — only that the request 16 + // is rejected and no record was persisted. 17 + func TestHTTP_ValidationErrors(t *testing.T) { 18 + h := StartHarness(t, nil) 19 + 20 + // Build long strings once. Limits per models.go: name=200, location=200, 21 + // website=500. Use 1000 to comfortably exceed any boundary. 22 + tooLong := strings.Repeat("a", 1000) 23 + 24 + cases := []struct { 25 + name string 26 + path string 27 + form url.Values 28 + wantCode int 29 + }{ 30 + // Roaster 31 + { 32 + name: "roaster_empty_name", 33 + path: "/api/roasters", 34 + form: form("name", ""), 35 + wantCode: 400, 36 + }, 37 + { 38 + name: "roaster_name_too_long", 39 + path: "/api/roasters", 40 + form: form("name", tooLong), 41 + wantCode: 400, 42 + }, 43 + { 44 + name: "roaster_location_too_long", 45 + path: "/api/roasters", 46 + form: form("name", "OK", "location", tooLong), 47 + wantCode: 400, 48 + }, 49 + { 50 + name: "roaster_website_too_long", 51 + path: "/api/roasters", 52 + form: form("name", "OK", "website", tooLong), 53 + wantCode: 400, 54 + }, 55 + 56 + // Bean 57 + { 58 + name: "bean_empty_name", 59 + path: "/api/beans", 60 + form: form("name", "", "origin", "Ethiopia"), 61 + wantCode: 400, 62 + }, 63 + { 64 + name: "bean_name_too_long", 65 + path: "/api/beans", 66 + form: form("name", tooLong), 67 + wantCode: 400, 68 + }, 69 + { 70 + name: "bean_origin_too_long", 71 + path: "/api/beans", 72 + form: form("name", "OK", "origin", tooLong), 73 + wantCode: 400, 74 + }, 75 + 76 + // Grinder 77 + { 78 + name: "grinder_empty_name", 79 + path: "/api/grinders", 80 + form: form("name", ""), 81 + wantCode: 400, 82 + }, 83 + { 84 + name: "grinder_name_too_long", 85 + path: "/api/grinders", 86 + form: form("name", tooLong), 87 + wantCode: 400, 88 + }, 89 + 90 + // Brewer 91 + { 92 + name: "brewer_empty_name", 93 + path: "/api/brewers", 94 + form: form("name", ""), 95 + wantCode: 400, 96 + }, 97 + { 98 + name: "brewer_name_too_long", 99 + path: "/api/brewers", 100 + form: form("name", tooLong), 101 + wantCode: 400, 102 + }, 103 + 104 + // Recipe 105 + { 106 + name: "recipe_empty_name", 107 + path: "/api/recipes", 108 + form: form("name", ""), 109 + wantCode: 400, 110 + }, 111 + { 112 + name: "recipe_name_too_long", 113 + path: "/api/recipes", 114 + form: form("name", tooLong), 115 + wantCode: 400, 116 + }, 117 + } 118 + 119 + for _, tc := range cases { 120 + t.Run(tc.name, func(t *testing.T) { 121 + resp := h.PostForm(tc.path, tc.form) 122 + body := ReadBody(t, resp) 123 + assert.Equal(t, tc.wantCode, resp.StatusCode, statusErr(resp, body)) 124 + }) 125 + } 126 + 127 + // Sanity check: nothing was persisted by any failing case. 128 + data := fetchData(t, h) 129 + assert.Empty(t, data.Roasters, "no roasters should have been created by validation cases") 130 + assert.Empty(t, data.Beans, "no beans should have been created by validation cases") 131 + assert.Empty(t, data.Grinders, "no grinders should have been created by validation cases") 132 + assert.Empty(t, data.Brewers, "no brewers should have been created by validation cases") 133 + assert.Empty(t, data.Recipes, "no recipes should have been created by validation cases") 134 + } 135 + 136 + // TestHTTP_BrewValidationErrors covers brew-specific input rejection: missing 137 + // bean reference, malformed rkey, and out-of-range numeric fields validated by 138 + // validateBrewRequest. 139 + func TestHTTP_BrewValidationErrors(t *testing.T) { 140 + h := StartHarness(t, nil) 141 + 142 + // Set up a valid bean so we can isolate other field failures. 143 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Val Roaster")), "roaster") 144 + beanRKey := mustRKey(t, h.PostForm("/api/beans", form( 145 + "name", "Val Bean", "roaster_rkey", roasterRKey, "roast_level", "Medium", 146 + )), "bean") 147 + 148 + cases := []struct { 149 + name string 150 + form url.Values 151 + }{ 152 + { 153 + name: "missing_bean_rkey", 154 + form: form("method", "Pour Over", "water_amount", "300"), 155 + }, 156 + { 157 + name: "invalid_bean_rkey_format", 158 + form: form("bean_rkey", "not a valid rkey!"), 159 + }, 160 + { 161 + name: "invalid_grinder_rkey_format", 162 + form: form("bean_rkey", beanRKey, "grinder_rkey", "bad rkey"), 163 + }, 164 + { 165 + name: "temperature_out_of_range", 166 + form: form("bean_rkey", beanRKey, "temperature", "999"), 167 + }, 168 + { 169 + name: "water_amount_out_of_range", 170 + form: form("bean_rkey", beanRKey, "water_amount", "999999"), 171 + }, 172 + { 173 + name: "rating_out_of_range", 174 + form: form("bean_rkey", beanRKey, "rating", "42"), 175 + }, 176 + } 177 + 178 + for _, tc := range cases { 179 + t.Run(tc.name, func(t *testing.T) { 180 + resp := h.PostForm("/brews", tc.form) 181 + body := ReadBody(t, resp) 182 + require.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) 183 + }) 184 + } 185 + 186 + // Sanity: no brew was persisted. 187 + data := fetchData(t, h) 188 + assert.Empty(t, data.Brews, "no brew should have been created by failing validation cases") 189 + }