ai cooking
0
fork

Configure Feed

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

Moveingredients (#305)

* seperate ingredients package

* linter change

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
b8ea5666 4a203f13

+177 -52
+3
cmd/careme/web.go
··· 5 5 "careme/internal/auth" 6 6 "careme/internal/cache" 7 7 "careme/internal/config" 8 + "careme/internal/ingredients" 8 9 "careme/internal/locations" 9 10 "careme/internal/logs" 10 11 "careme/internal/logsink" ··· 70 71 71 72 adminMux := http.NewServeMux() 72 73 adminMux.Handle("/users", users.AdminUsersPage(userStorage)) 74 + ingredientsHandler := ingredients.NewHandler(cache) 75 + ingredientsHandler.Register(adminMux) 73 76 mux.Handle("/admin/", admin.New(cfg, authClient).Enforce(http.StripPrefix("/admin", adminMux))) 74 77 75 78 if logsinkCfg.Enabled() {
+69
internal/ingredients/server.go
··· 1 + package ingredients 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/kroger" 6 + "careme/internal/recipes" 7 + "encoding/json" 8 + "errors" 9 + "log/slog" 10 + "net/http" 11 + ) 12 + 13 + type server struct { 14 + cache cache.Cache 15 + } 16 + 17 + func NewHandler(c cache.Cache) *server { 18 + return &server{cache: c} 19 + } 20 + 21 + func (s *server) Register(mux *http.ServeMux) { 22 + mux.HandleFunc("GET /ingredients/{hash}", s.handleIngredients) 23 + } 24 + 25 + func (s *server) handleIngredients(w http.ResponseWriter, r *http.Request) { 26 + ctx := r.Context() 27 + hash := r.PathValue("hash") 28 + rio := recipes.IO(s.cache) 29 + 30 + params, err := rio.ParamsFromCache(ctx, hash) 31 + if err != nil { 32 + if errors.Is(err, cache.ErrNotFound) { 33 + http.Error(w, "parameters not found in cache", http.StatusNotFound) 34 + return 35 + } 36 + slog.ErrorContext(ctx, "failed to load params for hash", "hash", hash, "error", err) 37 + http.Error(w, "failed to fetch params", http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + locationHash := params.LocationHash() 42 + ingredients, err := rio.IngredientsFromCache(ctx, locationHash) 43 + if err != nil { 44 + if errors.Is(err, cache.ErrNotFound) { 45 + http.Error(w, "ingredients not found in cache", http.StatusNotFound) 46 + return 47 + } 48 + slog.ErrorContext(ctx, "failed to load ingredients for hash", "hash", locationHash, "error", err) 49 + http.Error(w, "failed to fetch ingredients", http.StatusInternalServerError) 50 + return 51 + } 52 + 53 + slog.Info("serving cached ingredients", "location", params.String(), "hash", locationHash) 54 + if r.URL.Query().Get("format") == "tsv" { 55 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 56 + if err := kroger.ToTSV(ingredients, w); err != nil { 57 + http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 58 + } 59 + return 60 + } 61 + 62 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 63 + enc := json.NewEncoder(w) 64 + enc.SetIndent("", " ") 65 + if err := enc.Encode(ingredients); err != nil { 66 + http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 67 + return 68 + } 69 + }
+104
internal/ingredients/server_test.go
··· 1 + package ingredients 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/kroger" 6 + "careme/internal/locations" 7 + "careme/internal/recipes" 8 + "net/http" 9 + "net/http/httptest" 10 + "strings" 11 + "testing" 12 + "time" 13 + ) 14 + 15 + func TestServerReturnsIngredientsJSON(t *testing.T) { 16 + cacheStore := cache.NewInMemoryCache() 17 + rio := recipes.IO(cacheStore) 18 + params := recipes.DefaultParams( 19 + &locations.Location{ID: "loc-1", Name: "Store 1"}, 20 + time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 21 + ) 22 + if err := rio.SaveParams(t.Context(), params); err != nil { 23 + t.Fatalf("SaveParams failed: %v", err) 24 + } 25 + 26 + description := "Honeycrisp apple" 27 + entries := []kroger.Ingredient{{Description: &description}} 28 + if err := rio.SaveIngredients(t.Context(), params.LocationHash(), entries); err != nil { 29 + t.Fatalf("SaveIngredients failed: %v", err) 30 + } 31 + 32 + mux := http.NewServeMux() 33 + NewHandler(cacheStore).Register(mux) 34 + 35 + req := httptest.NewRequest(http.MethodGet, "/ingredients/"+params.Hash(), nil) 36 + rr := httptest.NewRecorder() 37 + mux.ServeHTTP(rr, req) 38 + 39 + if rr.Code != http.StatusOK { 40 + t.Fatalf("expected status 200, got %d", rr.Code) 41 + } 42 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { 43 + t.Fatalf("expected JSON content type, got %q", got) 44 + } 45 + if !strings.Contains(rr.Body.String(), "Honeycrisp apple") { 46 + t.Fatalf("expected response body to include ingredient description, got %q", rr.Body.String()) 47 + } 48 + } 49 + 50 + func TestServerReturnsIngredientsTSV(t *testing.T) { 51 + cacheStore := cache.NewInMemoryCache() 52 + rio := recipes.IO(cacheStore) 53 + params := recipes.DefaultParams( 54 + &locations.Location{ID: "loc-2", Name: "Store 2"}, 55 + time.Date(2026, 1, 26, 0, 0, 0, 0, time.UTC), 56 + ) 57 + if err := rio.SaveParams(t.Context(), params); err != nil { 58 + t.Fatalf("SaveParams failed: %v", err) 59 + } 60 + 61 + description := "Broccoli" 62 + entries := []kroger.Ingredient{{Description: &description}} 63 + if err := rio.SaveIngredients(t.Context(), params.LocationHash(), entries); err != nil { 64 + t.Fatalf("SaveIngredients failed: %v", err) 65 + } 66 + 67 + mux := http.NewServeMux() 68 + NewHandler(cacheStore).Register(mux) 69 + 70 + req := httptest.NewRequest(http.MethodGet, "/ingredients/"+params.Hash()+"?format=tsv", nil) 71 + rr := httptest.NewRecorder() 72 + mux.ServeHTTP(rr, req) 73 + 74 + if rr.Code != http.StatusOK { 75 + t.Fatalf("expected status 200, got %d", rr.Code) 76 + } 77 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "text/plain") { 78 + t.Fatalf("expected plain text content type, got %q", got) 79 + } 80 + body := rr.Body.String() 81 + if !strings.Contains(body, "ProductId\tAisleNumber\tBrand\tDescription") { 82 + t.Fatalf("expected TSV header in response, got %q", body) 83 + } 84 + if !strings.Contains(body, "Broccoli") { 85 + t.Fatalf("expected TSV body to include ingredient, got %q", body) 86 + } 87 + } 88 + 89 + func TestServerReturnsNotFoundWhenParamsMissing(t *testing.T) { 90 + cacheStore := cache.NewInMemoryCache() 91 + mux := http.NewServeMux() 92 + NewHandler(cacheStore).Register(mux) 93 + 94 + req := httptest.NewRequest(http.MethodGet, "/ingredients/missing-hash", nil) 95 + rr := httptest.NewRecorder() 96 + mux.ServeHTTP(rr, req) 97 + 98 + if rr.Code != http.StatusNotFound { 99 + t.Fatalf("expected status 404, got %d", rr.Code) 100 + } 101 + if !strings.Contains(rr.Body.String(), "parameters not found in cache") { 102 + t.Fatalf("expected missing params error, got %q", rr.Body.String()) 103 + } 104 + }
+1
internal/recipes/mock_test.go
··· 19 19 20 20 if result == nil { 21 21 t.Fatal("expected non-nil result") 22 + return 22 23 } 23 24 24 25 if len(result.Recipes) != 3 {
-52
internal/recipes/server.go
··· 5 5 "careme/internal/auth" 6 6 "careme/internal/cache" 7 7 "careme/internal/config" 8 - "careme/internal/kroger" 9 8 "careme/internal/locations" 10 9 "careme/internal/seasons" 11 10 "careme/internal/templates" 12 11 "careme/internal/users" 13 12 utypes "careme/internal/users/types" 14 13 "context" 15 - "encoding/json" 16 14 "errors" 17 15 "fmt" 18 16 "html/template" ··· 75 73 mux.HandleFunc("POST /recipe/{hash}/feedback", s.handleFeedback) 76 74 mux.HandleFunc("POST /recipe/{hash}/save", s.handleSaveRecipe) 77 75 mux.HandleFunc("POST /recipe/{hash}/dismiss", s.handleDismissRecipe) 78 - //maybe this should be under locations server? 79 - mux.HandleFunc("GET /ingredients/{hash}", s.ingredients) 80 - 81 76 } 82 77 83 78 func (s *server) handleSingle(w http.ResponseWriter, r *http.Request) { ··· 838 833 slog.InfoContext(ctx, "removed recipe from user profile", "user_id", currentUser.ID, "hash", recipeHash) 839 834 return nil 840 835 } 841 - 842 - // move to admin? Nah let the people see 843 - func (s *server) ingredients(w http.ResponseWriter, r *http.Request) { 844 - ctx := r.Context() 845 - hash := r.PathValue("hash") 846 - p, err := s.ParamsFromCache(ctx, hash) 847 - if err != nil { 848 - if errors.Is(err, cache.ErrNotFound) { 849 - http.Error(w, "parameters not found in cache", http.StatusNotFound) 850 - return 851 - } 852 - slog.ErrorContext(ctx, "failed to load params for hash", "hash", hash, "error", err) 853 - http.Error(w, "failed to fetch params", http.StatusInternalServerError) 854 - return 855 - } 856 - lochash := p.LocationHash() 857 - 858 - ingredients, err := s.IngredientsFromCache(ctx, lochash) 859 - if err != nil { 860 - if errors.Is(err, cache.ErrNotFound) { 861 - http.Error(w, "ingredients not found in cache", http.StatusNotFound) 862 - return 863 - } 864 - slog.ErrorContext(ctx, "failed to load ingredients for hash", "hash", lochash, "error", err) 865 - http.Error(w, "failed to fetch ingredients", http.StatusInternalServerError) 866 - return 867 - } 868 - slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash) 869 - // make this a html thats readable. 870 - if r.URL.Query().Get("format") == "tsv" { 871 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 872 - err := kroger.ToTSV(ingredients, w) 873 - if err != nil { 874 - http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 875 - return 876 - } 877 - return 878 - } 879 - 880 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 881 - enc := json.NewEncoder(w) 882 - enc.SetIndent("", " ") 883 - if err := enc.Encode(ingredients); err != nil { 884 - http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 885 - return 886 - } 887 - }