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: smoke test and complex brew test

authored by

Patrick Dewey and committed by tangled.org 506cd0ca 59aeaa4b

+323
+115
tests/integration/brew_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 + // form is a small ergonomic helper that returns url.Values from an alternating 16 + // key/value list. Panics on odd-length input — only used in tests. 17 + func form(kv ...string) url.Values { 18 + if len(kv)%2 != 0 { 19 + panic("form: odd number of arguments") 20 + } 21 + v := url.Values{} 22 + for i := 0; i < len(kv); i += 2 { 23 + v.Set(kv[i], kv[i+1]) 24 + } 25 + return v 26 + } 27 + 28 + // mustRKey decodes a JSON entity-create response and returns its rkey, failing 29 + // the test if the request did not succeed or the response is unparseable. 30 + func mustRKey(t *testing.T, resp *http.Response, label string) string { 31 + t.Helper() 32 + body := ReadBody(t, resp) 33 + require.Equal(t, 200, resp.StatusCode, "%s create: %s", label, statusErr(resp, body)) 34 + var generic struct { 35 + RKey string `json:"rkey"` 36 + } 37 + require.NoError(t, json.Unmarshal([]byte(body), &generic)) 38 + require.NotEmpty(t, generic.RKey, "%s create: empty rkey in %s", label, body) 39 + return generic.RKey 40 + } 41 + 42 + // TestHTTP_BrewCreatePourover exercises the most complex record marshaling 43 + // path: a pour-over brew with bean/grinder/brewer references, multiple pours, 44 + // and pourover params. After creation, /api/data is fetched and the brew is 45 + // inspected to confirm pours and pourover params round-tripped through the 46 + // PDS write -> witness cache read path. 47 + func TestHTTP_BrewCreatePourover(t *testing.T) { 48 + h := StartHarness(t, nil) 49 + 50 + // Set up the entities the brew references. 51 + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Brew Roaster")), "roaster") 52 + beanRKey := mustRKey(t, h.PostForm("/api/beans", 53 + form("name", "Brew Bean", "roaster_rkey", roasterRKey)), "bean") 54 + grinderRKey := mustRKey(t, h.PostForm("/api/grinders", form("name", "Brew Grinder")), "grinder") 55 + brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "V60")), "brewer") 56 + 57 + brewForm := url.Values{} 58 + brewForm.Set("bean_rkey", beanRKey) 59 + brewForm.Set("grinder_rkey", grinderRKey) 60 + brewForm.Set("brewer_rkey", brewerRKey) 61 + brewForm.Set("method", "Pour Over") 62 + brewForm.Set("temperature", "94") 63 + brewForm.Set("water_amount", "300") 64 + brewForm.Set("coffee_amount", "18") 65 + brewForm.Set("time_seconds", "210") 66 + brewForm.Set("rating", "8") 67 + brewForm.Set("grind_size", "Medium") 68 + brewForm.Set("tasting_notes", "bright, floral") 69 + 70 + // Three pours. 71 + brewForm.Set("pour_water_0", "60") 72 + brewForm.Set("pour_time_0", "0") 73 + brewForm.Set("pour_water_1", "120") 74 + brewForm.Set("pour_time_1", "45") 75 + brewForm.Set("pour_water_2", "120") 76 + brewForm.Set("pour_time_2", "90") 77 + 78 + // Pourover params. 79 + brewForm.Set("pourover_bloom_water", "60") 80 + brewForm.Set("pourover_bloom_seconds", "30") 81 + brewForm.Set("pourover_drawdown_seconds", "45") 82 + brewForm.Set("pourover_filter", "Hario tabbed") 83 + 84 + resp := h.PostForm("/brews", brewForm) 85 + body := ReadBody(t, resp) 86 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 87 + assert.Equal(t, "/my-coffee", resp.Header.Get("HX-Redirect")) 88 + 89 + // Verify the brew round-tripped by listing all data. 90 + listResp := h.Get("/api/data") 91 + listBody := ReadBody(t, listResp) 92 + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) 93 + 94 + var data struct { 95 + Brews []models.Brew `json:"brews"` 96 + } 97 + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) 98 + require.Len(t, data.Brews, 1, "expected exactly one brew") 99 + 100 + brew := data.Brews[0] 101 + assert.Equal(t, beanRKey, brew.BeanRKey) 102 + assert.Equal(t, grinderRKey, brew.GrinderRKey) 103 + assert.Equal(t, brewerRKey, brew.BrewerRKey) 104 + assert.Equal(t, "Pour Over", brew.Method) 105 + assert.Equal(t, 18, brew.CoffeeAmount) 106 + assert.Equal(t, 300, brew.WaterAmount) 107 + assert.Equal(t, 8, brew.Rating) 108 + assert.Len(t, brew.Pours, 3, "expected three pours to round-trip") 109 + 110 + require.NotNil(t, brew.PouroverParams, "pourover params should be present") 111 + assert.Equal(t, 60, brew.PouroverParams.BloomWater) 112 + assert.Equal(t, 30, brew.PouroverParams.BloomSeconds) 113 + assert.Equal(t, 45, brew.PouroverParams.DrawdownSeconds) 114 + assert.Equal(t, "Hario tabbed", brew.PouroverParams.Filter) 115 + }
+78
tests/integration/handlers_test.go
··· 66 66 assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) 67 67 } 68 68 69 + // TestHTTP_RoasterUpdateFlow exercises PUT /api/roasters/{id}: create a 70 + // roaster, update it, then verify the change round-tripped through the PDS by 71 + // listing all data via /api/data. 72 + func TestHTTP_RoasterUpdateFlow(t *testing.T) { 73 + h := StartHarness(t, nil) 74 + 75 + createForm := url.Values{} 76 + createForm.Set("name", "Sey Coffee") 77 + createForm.Set("location", "Brooklyn, NY") 78 + createResp := h.PostForm("/api/roasters", createForm) 79 + createBody := ReadBody(t, createResp) 80 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 81 + 82 + var created models.Roaster 83 + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) 84 + require.NotEmpty(t, created.RKey) 85 + 86 + updateForm := url.Values{} 87 + updateForm.Set("name", "Sey Coffee Roasters") 88 + updateForm.Set("location", "Brooklyn, NY") 89 + updateForm.Set("website", "https://seycoffee.com") 90 + updateResp := h.PutForm("/api/roasters/"+created.RKey, updateForm) 91 + updateBody := ReadBody(t, updateResp) 92 + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, updateBody)) 93 + 94 + listResp := h.Get("/api/data") 95 + listBody := ReadBody(t, listResp) 96 + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) 97 + 98 + var data struct { 99 + Roasters []models.Roaster `json:"roasters"` 100 + } 101 + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) 102 + 103 + var found *models.Roaster 104 + for i := range data.Roasters { 105 + if data.Roasters[i].RKey == created.RKey { 106 + found = &data.Roasters[i] 107 + break 108 + } 109 + } 110 + require.NotNil(t, found, "updated roaster not found in list") 111 + assert.Equal(t, "Sey Coffee Roasters", found.Name) 112 + assert.Equal(t, "https://seycoffee.com", found.Website) 113 + } 114 + 115 + // TestHTTP_RoasterDeleteFlow exercises DELETE /api/roasters/{id}: create a 116 + // roaster, delete it, then verify it's gone from /api/data. 117 + func TestHTTP_RoasterDeleteFlow(t *testing.T) { 118 + h := StartHarness(t, nil) 119 + 120 + createForm := url.Values{} 121 + createForm.Set("name", "Heart Coffee") 122 + createResp := h.PostForm("/api/roasters", createForm) 123 + createBody := ReadBody(t, createResp) 124 + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) 125 + 126 + var created models.Roaster 127 + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) 128 + require.NotEmpty(t, created.RKey) 129 + 130 + delResp := h.Delete("/api/roasters/" + created.RKey) 131 + delBody := ReadBody(t, delResp) 132 + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, delBody)) 133 + 134 + listResp := h.Get("/api/data") 135 + listBody := ReadBody(t, listResp) 136 + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) 137 + 138 + var data struct { 139 + Roasters []models.Roaster `json:"roasters"` 140 + } 141 + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) 142 + for _, r := range data.Roasters { 143 + assert.NotEqual(t, created.RKey, r.RKey, "roaster still present after delete") 144 + } 145 + } 146 + 69 147 // TestHTTP_BeanCreateLinksToRoaster exercises a multi-step flow: create a 70 148 // roaster, then create a bean referencing it. Verifies the cross-entity 71 149 // reference round-trips through the handler layer.
+33
tests/integration/harness.go
··· 286 286 return resp 287 287 } 288 288 289 + // GetHTMX fetches a path as the primary account with the HX-Request header set, 290 + // for endpoints behind RequireHTMXMiddleware. 291 + func (h *Harness) GetHTMX(path string) *http.Response { 292 + h.T.Helper() 293 + req, err := http.NewRequest("GET", h.URL(path), nil) 294 + require.NoError(h.T, err) 295 + req.Header.Set("HX-Request", "true") 296 + resp, err := h.Client.Do(req) 297 + require.NoError(h.T, err) 298 + return resp 299 + } 300 + 301 + // PutForm sends a urlencoded form via PUT as the primary account. 302 + func (h *Harness) PutForm(path string, form url.Values) *http.Response { 303 + h.T.Helper() 304 + req, err := http.NewRequest("PUT", h.URL(path), strings.NewReader(form.Encode())) 305 + require.NoError(h.T, err) 306 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 307 + resp, err := h.Client.Do(req) 308 + require.NoError(h.T, err) 309 + return resp 310 + } 311 + 312 + // Delete sends a DELETE request as the primary account. 313 + func (h *Harness) Delete(path string) *http.Response { 314 + h.T.Helper() 315 + req, err := http.NewRequest("DELETE", h.URL(path), nil) 316 + require.NoError(h.T, err) 317 + resp, err := h.Client.Do(req) 318 + require.NoError(h.T, err) 319 + return resp 320 + } 321 + 289 322 // ReadBody drains and returns the response body, closing it. 290 323 func ReadBody(t *testing.T, resp *http.Response) string { 291 324 t.Helper()
+97
tests/integration/smoke_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "github.com/stretchr/testify/require" 8 + ) 9 + 10 + // TestHTTP_PageRenderSmoke renders top-level pages and asserts they return 11 + // 200 with a non-empty body. Catches templ panics, missing layout data, and 12 + // broken context plumbing introduced when refactoring shared page wiring. 13 + func TestHTTP_PageRenderSmoke(t *testing.T) { 14 + h := StartHarness(t, nil) 15 + 16 + pages := []string{ 17 + "/", 18 + "/my-coffee", 19 + "/manage", 20 + "/brews", 21 + "/brews/new", 22 + "/about", 23 + "/settings", 24 + "/notifications", 25 + } 26 + 27 + for _, path := range pages { 28 + t.Run(path, func(t *testing.T) { 29 + resp := h.Get(path) 30 + body := ReadBody(t, resp) 31 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 32 + assert.NotEmpty(t, body, "empty body for %s", path) 33 + }) 34 + } 35 + } 36 + 37 + // TestHTTP_EntityViewSmoke creates each kind of entity and renders its public 38 + // view page. Catches breakage in view-handler templ rendering and reference 39 + // resolution that unit tests with mocked stores miss. 40 + func TestHTTP_EntityViewSmoke(t *testing.T) { 41 + h := StartHarness(t, nil) 42 + 43 + // Create one of each entity that has a view page. 44 + roasterResp := h.PostForm("/api/roasters", form("name", "View Roaster")) 45 + roaster := mustRKey(t, roasterResp, "roaster") 46 + 47 + beanResp := h.PostForm("/api/beans", form("name", "View Bean", "roaster_rkey", roaster)) 48 + bean := mustRKey(t, beanResp, "bean") 49 + 50 + grinderResp := h.PostForm("/api/grinders", form("name", "View Grinder")) 51 + grinder := mustRKey(t, grinderResp, "grinder") 52 + 53 + brewerResp := h.PostForm("/api/brewers", form("name", "View Brewer")) 54 + brewer := mustRKey(t, brewerResp, "brewer") 55 + 56 + views := map[string]string{ 57 + "roaster": "/roasters/" + roaster, 58 + "bean": "/beans/" + bean, 59 + "grinder": "/grinders/" + grinder, 60 + "brewer": "/brewers/" + brewer, 61 + } 62 + 63 + for label, path := range views { 64 + t.Run(label, func(t *testing.T) { 65 + resp := h.Get(path) 66 + body := ReadBody(t, resp) 67 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 68 + assert.NotEmpty(t, body) 69 + }) 70 + } 71 + } 72 + 73 + // TestHTTP_HTMXPartialSmoke exercises HTMX-only fragment routes. They sit 74 + // behind RequireHTMXMiddleware so the test sets HX-Request. A 200 + non-empty 75 + // body catches silent template breakage in fragment renderers. 76 + func TestHTTP_HTMXPartialSmoke(t *testing.T) { 77 + h := StartHarness(t, nil) 78 + 79 + partials := []string{ 80 + "/api/feed", 81 + "/api/brews", 82 + "/api/manage", 83 + "/api/incomplete-records", 84 + "/api/popular-recipes", 85 + } 86 + 87 + for _, path := range partials { 88 + t.Run(path, func(t *testing.T) { 89 + resp := h.GetHTMX(path) 90 + body := ReadBody(t, resp) 91 + // Some partials legitimately render empty when the user has no data 92 + // (incomplete-records, popular-recipes). A 200 is enough — templ 93 + // panics would surface as 500. 94 + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) 95 + }) 96 + } 97 + }