diff --git a/tests/integration/brew_test.go b/tests/integration/brew_test.go new file mode 100644 index 0000000..407a731 --- /dev/null +++ b/tests/integration/brew_test.go @@ -0,0 +1,115 @@ +package integration + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "arabica/internal/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// form is a small ergonomic helper that returns url.Values from an alternating +// key/value list. Panics on odd-length input — only used in tests. +func form(kv ...string) url.Values { + if len(kv)%2 != 0 { + panic("form: odd number of arguments") + } + v := url.Values{} + for i := 0; i < len(kv); i += 2 { + v.Set(kv[i], kv[i+1]) + } + return v +} + +// mustRKey decodes a JSON entity-create response and returns its rkey, failing +// the test if the request did not succeed or the response is unparseable. +func mustRKey(t *testing.T, resp *http.Response, label string) string { + t.Helper() + body := ReadBody(t, resp) + require.Equal(t, 200, resp.StatusCode, "%s create: %s", label, statusErr(resp, body)) + var generic struct { + RKey string `json:"rkey"` + } + require.NoError(t, json.Unmarshal([]byte(body), &generic)) + require.NotEmpty(t, generic.RKey, "%s create: empty rkey in %s", label, body) + return generic.RKey +} + +// TestHTTP_BrewCreatePourover exercises the most complex record marshaling +// path: a pour-over brew with bean/grinder/brewer references, multiple pours, +// and pourover params. After creation, /api/data is fetched and the brew is +// inspected to confirm pours and pourover params round-tripped through the +// PDS write -> witness cache read path. +func TestHTTP_BrewCreatePourover(t *testing.T) { + h := StartHarness(t, nil) + + // Set up the entities the brew references. + roasterRKey := mustRKey(t, h.PostForm("/api/roasters", form("name", "Brew Roaster")), "roaster") + beanRKey := mustRKey(t, h.PostForm("/api/beans", + form("name", "Brew Bean", "roaster_rkey", roasterRKey)), "bean") + grinderRKey := mustRKey(t, h.PostForm("/api/grinders", form("name", "Brew Grinder")), "grinder") + brewerRKey := mustRKey(t, h.PostForm("/api/brewers", form("name", "V60")), "brewer") + + brewForm := url.Values{} + brewForm.Set("bean_rkey", beanRKey) + brewForm.Set("grinder_rkey", grinderRKey) + brewForm.Set("brewer_rkey", brewerRKey) + brewForm.Set("method", "Pour Over") + brewForm.Set("temperature", "94") + brewForm.Set("water_amount", "300") + brewForm.Set("coffee_amount", "18") + brewForm.Set("time_seconds", "210") + brewForm.Set("rating", "8") + brewForm.Set("grind_size", "Medium") + brewForm.Set("tasting_notes", "bright, floral") + + // Three pours. + brewForm.Set("pour_water_0", "60") + brewForm.Set("pour_time_0", "0") + brewForm.Set("pour_water_1", "120") + brewForm.Set("pour_time_1", "45") + brewForm.Set("pour_water_2", "120") + brewForm.Set("pour_time_2", "90") + + // Pourover params. + brewForm.Set("pourover_bloom_water", "60") + brewForm.Set("pourover_bloom_seconds", "30") + brewForm.Set("pourover_drawdown_seconds", "45") + brewForm.Set("pourover_filter", "Hario tabbed") + + resp := h.PostForm("/brews", brewForm) + body := ReadBody(t, resp) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) + assert.Equal(t, "/my-coffee", resp.Header.Get("HX-Redirect")) + + // Verify the brew round-tripped by listing all data. + listResp := h.Get("/api/data") + listBody := ReadBody(t, listResp) + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) + + var data struct { + Brews []models.Brew `json:"brews"` + } + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) + require.Len(t, data.Brews, 1, "expected exactly one brew") + + brew := data.Brews[0] + assert.Equal(t, beanRKey, brew.BeanRKey) + assert.Equal(t, grinderRKey, brew.GrinderRKey) + assert.Equal(t, brewerRKey, brew.BrewerRKey) + assert.Equal(t, "Pour Over", brew.Method) + assert.Equal(t, 18, brew.CoffeeAmount) + assert.Equal(t, 300, brew.WaterAmount) + assert.Equal(t, 8, brew.Rating) + assert.Len(t, brew.Pours, 3, "expected three pours to round-trip") + + require.NotNil(t, brew.PouroverParams, "pourover params should be present") + assert.Equal(t, 60, brew.PouroverParams.BloomWater) + assert.Equal(t, 30, brew.PouroverParams.BloomSeconds) + assert.Equal(t, 45, brew.PouroverParams.DrawdownSeconds) + assert.Equal(t, "Hario tabbed", brew.PouroverParams.Filter) +} diff --git a/tests/integration/handlers_test.go b/tests/integration/handlers_test.go index d224a01..9f03b3e 100644 --- a/tests/integration/handlers_test.go +++ b/tests/integration/handlers_test.go @@ -66,6 +66,84 @@ func TestHTTP_RoasterCreateValidationError(t *testing.T) { assert.Equal(t, 400, resp.StatusCode, statusErr(resp, body)) } +// TestHTTP_RoasterUpdateFlow exercises PUT /api/roasters/{id}: create a +// roaster, update it, then verify the change round-tripped through the PDS by +// listing all data via /api/data. +func TestHTTP_RoasterUpdateFlow(t *testing.T) { + h := StartHarness(t, nil) + + createForm := url.Values{} + createForm.Set("name", "Sey Coffee") + createForm.Set("location", "Brooklyn, NY") + createResp := h.PostForm("/api/roasters", createForm) + createBody := ReadBody(t, createResp) + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) + + var created models.Roaster + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) + require.NotEmpty(t, created.RKey) + + updateForm := url.Values{} + updateForm.Set("name", "Sey Coffee Roasters") + updateForm.Set("location", "Brooklyn, NY") + updateForm.Set("website", "https://seycoffee.com") + updateResp := h.PutForm("/api/roasters/"+created.RKey, updateForm) + updateBody := ReadBody(t, updateResp) + require.Equal(t, 200, updateResp.StatusCode, statusErr(updateResp, updateBody)) + + listResp := h.Get("/api/data") + listBody := ReadBody(t, listResp) + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) + + var data struct { + Roasters []models.Roaster `json:"roasters"` + } + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) + + var found *models.Roaster + for i := range data.Roasters { + if data.Roasters[i].RKey == created.RKey { + found = &data.Roasters[i] + break + } + } + require.NotNil(t, found, "updated roaster not found in list") + assert.Equal(t, "Sey Coffee Roasters", found.Name) + assert.Equal(t, "https://seycoffee.com", found.Website) +} + +// TestHTTP_RoasterDeleteFlow exercises DELETE /api/roasters/{id}: create a +// roaster, delete it, then verify it's gone from /api/data. +func TestHTTP_RoasterDeleteFlow(t *testing.T) { + h := StartHarness(t, nil) + + createForm := url.Values{} + createForm.Set("name", "Heart Coffee") + createResp := h.PostForm("/api/roasters", createForm) + createBody := ReadBody(t, createResp) + require.Equal(t, 200, createResp.StatusCode, statusErr(createResp, createBody)) + + var created models.Roaster + require.NoError(t, json.Unmarshal([]byte(createBody), &created)) + require.NotEmpty(t, created.RKey) + + delResp := h.Delete("/api/roasters/" + created.RKey) + delBody := ReadBody(t, delResp) + require.Equal(t, 200, delResp.StatusCode, statusErr(delResp, delBody)) + + listResp := h.Get("/api/data") + listBody := ReadBody(t, listResp) + require.Equal(t, 200, listResp.StatusCode, statusErr(listResp, listBody)) + + var data struct { + Roasters []models.Roaster `json:"roasters"` + } + require.NoError(t, json.Unmarshal([]byte(listBody), &data)) + for _, r := range data.Roasters { + assert.NotEqual(t, created.RKey, r.RKey, "roaster still present after delete") + } +} + // TestHTTP_BeanCreateLinksToRoaster exercises a multi-step flow: create a // roaster, then create a bean referencing it. Verifies the cross-entity // reference round-trips through the handler layer. diff --git a/tests/integration/harness.go b/tests/integration/harness.go index 379cc04..e3a0818 100644 --- a/tests/integration/harness.go +++ b/tests/integration/harness.go @@ -286,6 +286,39 @@ func (h *Harness) Get(path string) *http.Response { return resp } +// GetHTMX fetches a path as the primary account with the HX-Request header set, +// for endpoints behind RequireHTMXMiddleware. +func (h *Harness) GetHTMX(path string) *http.Response { + h.T.Helper() + req, err := http.NewRequest("GET", h.URL(path), nil) + require.NoError(h.T, err) + req.Header.Set("HX-Request", "true") + resp, err := h.Client.Do(req) + require.NoError(h.T, err) + return resp +} + +// PutForm sends a urlencoded form via PUT as the primary account. +func (h *Harness) PutForm(path string, form url.Values) *http.Response { + h.T.Helper() + req, err := http.NewRequest("PUT", h.URL(path), strings.NewReader(form.Encode())) + require.NoError(h.T, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := h.Client.Do(req) + require.NoError(h.T, err) + return resp +} + +// Delete sends a DELETE request as the primary account. +func (h *Harness) Delete(path string) *http.Response { + h.T.Helper() + req, err := http.NewRequest("DELETE", h.URL(path), nil) + require.NoError(h.T, err) + resp, err := h.Client.Do(req) + require.NoError(h.T, err) + return resp +} + // ReadBody drains and returns the response body, closing it. func ReadBody(t *testing.T, resp *http.Response) string { t.Helper() diff --git a/tests/integration/smoke_test.go b/tests/integration/smoke_test.go new file mode 100644 index 0000000..727fe07 --- /dev/null +++ b/tests/integration/smoke_test.go @@ -0,0 +1,97 @@ +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHTTP_PageRenderSmoke renders top-level pages and asserts they return +// 200 with a non-empty body. Catches templ panics, missing layout data, and +// broken context plumbing introduced when refactoring shared page wiring. +func TestHTTP_PageRenderSmoke(t *testing.T) { + h := StartHarness(t, nil) + + pages := []string{ + "/", + "/my-coffee", + "/manage", + "/brews", + "/brews/new", + "/about", + "/settings", + "/notifications", + } + + for _, path := range pages { + t.Run(path, func(t *testing.T) { + resp := h.Get(path) + body := ReadBody(t, resp) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) + assert.NotEmpty(t, body, "empty body for %s", path) + }) + } +} + +// TestHTTP_EntityViewSmoke creates each kind of entity and renders its public +// view page. Catches breakage in view-handler templ rendering and reference +// resolution that unit tests with mocked stores miss. +func TestHTTP_EntityViewSmoke(t *testing.T) { + h := StartHarness(t, nil) + + // Create one of each entity that has a view page. + roasterResp := h.PostForm("/api/roasters", form("name", "View Roaster")) + roaster := mustRKey(t, roasterResp, "roaster") + + beanResp := h.PostForm("/api/beans", form("name", "View Bean", "roaster_rkey", roaster)) + bean := mustRKey(t, beanResp, "bean") + + grinderResp := h.PostForm("/api/grinders", form("name", "View Grinder")) + grinder := mustRKey(t, grinderResp, "grinder") + + brewerResp := h.PostForm("/api/brewers", form("name", "View Brewer")) + brewer := mustRKey(t, brewerResp, "brewer") + + views := map[string]string{ + "roaster": "/roasters/" + roaster, + "bean": "/beans/" + bean, + "grinder": "/grinders/" + grinder, + "brewer": "/brewers/" + brewer, + } + + for label, path := range views { + t.Run(label, func(t *testing.T) { + resp := h.Get(path) + body := ReadBody(t, resp) + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) + assert.NotEmpty(t, body) + }) + } +} + +// TestHTTP_HTMXPartialSmoke exercises HTMX-only fragment routes. They sit +// behind RequireHTMXMiddleware so the test sets HX-Request. A 200 + non-empty +// body catches silent template breakage in fragment renderers. +func TestHTTP_HTMXPartialSmoke(t *testing.T) { + h := StartHarness(t, nil) + + partials := []string{ + "/api/feed", + "/api/brews", + "/api/manage", + "/api/incomplete-records", + "/api/popular-recipes", + } + + for _, path := range partials { + t.Run(path, func(t *testing.T) { + resp := h.GetHTMX(path) + body := ReadBody(t, resp) + // Some partials legitimately render empty when the user has no data + // (incomplete-records, popular-recipes). A 200 is enough — templ + // panics would surface as 500. + require.Equal(t, 200, resp.StatusCode, statusErr(resp, body)) + }) + } +}