ai cooking
0
fork

Configure Feed

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

Respectlegacyhashes (#291)

* respect legacy orgin hashes

* overwrite originhash

* commetns about backfill

* add tests back in

* one more test

authored by

Paul Miller and committed by
GitHub
d0adb197 9d280a87

+144 -160
+2 -2
internal/recipes/generator_hash_test.go
··· 76 76 t.Fatal("expected to derive legacy recipe hash") 77 77 } 78 78 79 - normalized, ok := normalizeLegacyRecipeHash(legacyHash) 79 + normalized, ok := legacyHashToCurrent(legacyHash, legacyRecipeHashSeed) 80 80 if !ok { 81 81 t.Fatal("expected legacy hash normalization to succeed") 82 82 } ··· 84 84 t.Fatalf("expected normalized hash %q, got %q", hash, normalized) 85 85 } 86 86 87 - if _, ok := normalizeLegacyRecipeHash(hash); ok { 87 + if _, ok := legacyHashToCurrent(hash, legacyRecipeHashSeed); ok { 88 88 t.Fatalf("expected canonical hash %q not to be treated as legacy", hash) 89 89 } 90 90 }
+6 -37
internal/recipes/io.go
··· 49 49 primaryKey := ShoppingListCachePrefix + hash 50 50 shoppinglist, err := rio.Cache.Get(ctx, primaryKey) 51 51 if err != nil { 52 - if !errors.Is(err, cache.ErrNotFound) { 53 - return nil, err 54 - } 55 - legacyHash, ok := legacyRecipeHash(hash) 56 - if !ok { 57 - return nil, err 58 - } 59 - shoppinglist, err = rio.Cache.Get(ctx, legacyHash) 60 - if err != nil { 61 - return nil, err 62 - } 63 - slog.InfoContext(ctx, "serving legacy cached shoppingList by hash", "hash", hash, "cache_key", legacyHash) 52 + return nil, fmt.Errorf("error getting shopping list for hash %s: %w", hash, err) 53 + 64 54 } 65 55 defer func() { 66 56 if err := shoppinglist.Close(); err != nil { ··· 81 71 82 72 func (rio recipeio) ParamsFromCache(ctx context.Context, hash string) (*generatorParams, error) { 83 73 primaryKey := paramsCachePrefix + hash 74 + //have to convert legacy hashes because each recipe stored an origin hash and we didn't rewrite them 84 75 paramsReader, err := rio.Cache.Get(ctx, primaryKey) 85 76 if err != nil { 86 - if !errors.Is(err, cache.ErrNotFound) { 87 - return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 88 - } 89 - legacyHash, ok := legacyRecipeHash(hash) 90 - if !ok { 91 - return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 92 - } 93 - legacyKey := legacyHash + ".params" 94 - paramsReader, err = rio.Cache.Get(ctx, legacyKey) 95 - if err != nil { 96 - return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 97 - } 98 - slog.InfoContext(ctx, "serving legacy cached params by hash", "hash", hash, "cache_key", legacyKey) 77 + return nil, fmt.Errorf("error getting params for hash %s: %w", hash, err) 99 78 } 100 79 defer func() { 101 80 if err := paramsReader.Close(); err != nil { ··· 111 90 } 112 91 113 92 func (rio recipeio) IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) { 93 + //honor legacy hashes? I don't think so gets converted in server 114 94 primaryKey := ingredientsCachePrefix + hash 115 95 ingredientBlob, err := rio.Cache.Get(ctx, primaryKey) 116 96 if err != nil { 117 - if !errors.Is(err, cache.ErrNotFound) { 118 - return nil, err 119 - } 120 - legacyHash, ok := legacyLocationHash(hash) 121 - if !ok { 122 - return nil, err 123 - } 124 - ingredientBlob, err = rio.Cache.Get(ctx, legacyHash) 125 - if err != nil { 126 - return nil, err 127 - } 128 - slog.InfoContext(ctx, "serving legacy cached ingredients by hash", "hash", hash, "cache_key", legacyHash) 97 + return nil, fmt.Errorf("error getting ingredients for hash %s: %w", hash, err) 129 98 } 130 99 defer func() { 131 100 if err := ingredientBlob.Close(); err != nil {
-90
internal/recipes/io_test.go
··· 5 5 "careme/internal/cache" 6 6 "careme/internal/kroger" 7 7 "careme/internal/locations" 8 - "encoding/json" 9 8 "errors" 10 9 "os" 11 10 "path/filepath" ··· 146 145 t.Fatalf("IngredientsFromCache failed: %v", err) 147 146 } 148 147 if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Chicken Breast" { 149 - t.Fatalf("unexpected ingredients payload: %+v", got) 150 - } 151 - } 152 - 153 - func TestFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 154 - tmpDir := t.TempDir() 155 - cacheStore := cache.NewFileCache(tmpDir) 156 - rio := IO(cacheStore) 157 - 158 - p := DefaultParams(&locations.Location{ID: "loc-123", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 159 - hash := p.Hash() 160 - legacyHash, ok := legacyRecipeHash(hash) 161 - if !ok { 162 - t.Fatal("expected to derive legacy recipe hash") 163 - } 164 - 165 - list := ai.ShoppingList{ConversationID: "legacy-conversation"} 166 - listJSON, err := json.Marshal(list) 167 - if err != nil { 168 - t.Fatalf("failed to marshal shopping list: %v", err) 169 - } 170 - if err := cacheStore.Put(t.Context(), legacyHash, string(listJSON), cache.Unconditional()); err != nil { 171 - t.Fatalf("failed to store legacy shopping list key: %v", err) 172 - } 173 - 174 - got, err := rio.FromCache(t.Context(), hash) 175 - if err != nil { 176 - t.Fatalf("FromCache failed to read legacy hash key: %v", err) 177 - } 178 - if got.ConversationID != list.ConversationID { 179 - t.Fatalf("expected conversation id %q, got %q", list.ConversationID, got.ConversationID) 180 - } 181 - } 182 - 183 - func TestParamsFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 184 - tmpDir := t.TempDir() 185 - cacheStore := cache.NewFileCache(tmpDir) 186 - rio := IO(cacheStore) 187 - 188 - p := DefaultParams(&locations.Location{ID: "loc-321", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 189 - hash := p.Hash() 190 - legacyHash, ok := legacyRecipeHash(hash) 191 - if !ok { 192 - t.Fatal("expected to derive legacy recipe hash") 193 - } 194 - 195 - paramsJSON, err := json.Marshal(p) 196 - if err != nil { 197 - t.Fatalf("failed to marshal params: %v", err) 198 - } 199 - if err := cacheStore.Put(t.Context(), legacyHash+".params", string(paramsJSON), cache.Unconditional()); err != nil { 200 - t.Fatalf("failed to store legacy params key: %v", err) 201 - } 202 - 203 - got, err := rio.ParamsFromCache(t.Context(), hash) 204 - if err != nil { 205 - t.Fatalf("ParamsFromCache failed to read legacy hash key: %v", err) 206 - } 207 - if got.Location == nil || got.Location.ID != p.Location.ID { 208 - t.Fatalf("expected location id %q, got %+v", p.Location.ID, got.Location) 209 - } 210 - } 211 - 212 - func TestIngredientsFromCache_FallsBackToLegacyHashedKeyForCanonicalHash(t *testing.T) { 213 - tmpDir := t.TempDir() 214 - cacheStore := cache.NewFileCache(tmpDir) 215 - rio := IO(cacheStore) 216 - 217 - p := DefaultParams(&locations.Location{ID: "loc-777", Name: "Store"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) 218 - hash := p.LocationHash() 219 - legacyHash, ok := legacyLocationHash(hash) 220 - if !ok { 221 - t.Fatal("expected to derive legacy location hash") 222 - } 223 - 224 - ingredients := []kroger.Ingredient{{Description: loPtr("Legacy Chicken")}} 225 - ingredientsJSON, err := json.Marshal(ingredients) 226 - if err != nil { 227 - t.Fatalf("failed to marshal ingredients: %v", err) 228 - } 229 - if err := cacheStore.Put(t.Context(), legacyHash, string(ingredientsJSON), cache.Unconditional()); err != nil { 230 - t.Fatalf("failed to store legacy ingredients key: %v", err) 231 - } 232 - 233 - got, err := rio.IngredientsFromCache(t.Context(), hash) 234 - if err != nil { 235 - t.Fatalf("IngredientsFromCache failed to read legacy hash key: %v", err) 236 - } 237 - if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Legacy Chicken" { 238 148 t.Fatalf("unexpected ingredients payload: %+v", got) 239 149 } 240 150 }
+2 -29
internal/recipes/params.go
··· 86 86 return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 87 87 } 88 88 89 - func normalizeLegacyRecipeHash(hash string) (string, bool) { 90 - return legacyHashToCurrent(hash, legacyRecipeHashSeed) 91 - } 92 - 93 - func legacyRecipeHash(hash string) (string, bool) { 94 - return currentHashToLegacy(hash, legacyRecipeHashSeed) 95 - } 96 - 97 - func legacyLocationHash(hash string) (string, bool) { 98 - return currentHashToLegacy(hash, legacyIngredientsHashSeed) 99 - } 100 - 101 89 func legacyHashToCurrent(hash string, seed string) (string, bool) { 102 90 decoded, err := base64.URLEncoding.DecodeString(hash) 103 91 if err != nil { 104 - return "", false 92 + return hash, false 105 93 } 106 94 seedBytes := []byte(seed) 107 95 if !bytes.HasPrefix(decoded, seedBytes) || len(decoded) == len(seedBytes) { 108 - return "", false 96 + return hash, false 109 97 } 110 98 return base64.RawURLEncoding.EncodeToString(decoded[len(seedBytes):]), true 111 - } 112 - 113 - func currentHashToLegacy(hash string, seed string) (string, bool) { 114 - decoded, err := base64.RawURLEncoding.DecodeString(hash) 115 - if err != nil || len(decoded) == 0 { 116 - return "", false 117 - } 118 - seedBytes := []byte(seed) 119 - if bytes.HasPrefix(decoded, seedBytes) { 120 - return hash, false 121 - } 122 - legacyDecoded := make([]byte, 0, len(seedBytes)+len(decoded)) 123 - legacyDecoded = append(legacyDecoded, seedBytes...) 124 - legacyDecoded = append(legacyDecoded, decoded...) 125 - return base64.URLEncoding.EncodeToString(legacyDecoded), true 126 99 } 127 100 128 101 func (s *server) ParseQueryArgs(ctx context.Context, r *http.Request) (*generatorParams, error) {
+7 -2
internal/recipes/server.go
··· 108 108 FormatRecipeHTML(p, *recipe, signedIn, thread, *feedback, w) 109 109 return 110 110 } 111 - 111 + //we didn't go back and update old recipes's with new hash so have to handle that here. Could still backfill 112 + if normalizedHash, ok := legacyHashToCurrent(recipe.OriginHash, legacyRecipeHashSeed); ok { 113 + slog.InfoContext(ctx, "normalized legacy origin hash to current hash", "origin_hash", recipe.OriginHash, "hash", normalizedHash) 114 + recipe.OriginHash = normalizedHash 115 + //could resave to backfill but don't think we'll ever get them all without looping 116 + } 112 117 p, err := s.ParamsFromCache(ctx, recipe.OriginHash) 113 118 if err != nil { 114 119 slog.ErrorContext(ctx, "failed to load params for hash", "hash", recipe.OriginHash, "error", err) ··· 328 333 func (s *server) handleRecipes(w http.ResponseWriter, r *http.Request) { 329 334 ctx := r.Context() 330 335 if hashParam := r.URL.Query().Get(queryArgHash); hashParam != "" { 331 - if normalizedHash, ok := normalizeLegacyRecipeHash(hashParam); ok { 336 + if normalizedHash, ok := legacyHashToCurrent(hashParam, legacyRecipeHashSeed); ok { 332 337 slog.InfoContext(ctx, "redirecting legacy hash to canonical hash", "legacy_hash", hashParam, "hash", normalizedHash) 333 338 redirectToHash(w, r, normalizedHash, false /*useStart*/) 334 339 return
+127
internal/recipes/server_test.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "bytes" 4 5 "careme/internal/ai" 5 6 "careme/internal/auth" 6 7 "careme/internal/cache" 7 8 "careme/internal/locations" 8 9 "careme/internal/users" 9 10 "context" 11 + "encoding/base64" 10 12 "fmt" 11 13 "net/http" 12 14 "net/http/httptest" ··· 38 40 t.Errorf("handler returned wrong location: got %v want prefix %v", location, expectedLocation) 39 41 } 40 42 } 43 + func legacyRecipeHash(hash string) (string, bool) { 44 + return currentHashToLegacy(hash, legacyRecipeHashSeed) 45 + } 46 + 47 + func currentHashToLegacy(hash string, seed string) (string, bool) { 48 + decoded, err := base64.RawURLEncoding.DecodeString(hash) 49 + if err != nil || len(decoded) == 0 { 50 + return "", false 51 + } 52 + seedBytes := []byte(seed) 53 + if bytes.HasPrefix(decoded, seedBytes) { 54 + return hash, false 55 + } 56 + legacyDecoded := make([]byte, 0, len(seedBytes)+len(decoded)) 57 + legacyDecoded = append(legacyDecoded, seedBytes...) 58 + legacyDecoded = append(legacyDecoded, decoded...) 59 + return base64.URLEncoding.EncodeToString(legacyDecoded), true 60 + } 41 61 42 62 func TestHandleRecipes_RedirectsLegacyHashToCanonicalHash(t *testing.T) { 43 63 p := DefaultParams(&locations.Location{ID: "loc-123", Name: "Test"}, time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC)) ··· 90 110 } 91 111 if got := u.Query().Get("h"); got != hash { 92 112 t.Fatalf("expected redirect hash %q, got %q", hash, got) 113 + } 114 + } 115 + 116 + func TestHandleSingle_NormalizesLegacyOriginHashToCanonicalHash(t *testing.T) { 117 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 118 + s := &server{ 119 + recipeio: recipeio{Cache: cacheStore}, 120 + storage: users.NewStorage(cacheStore), 121 + clerk: auth.DefaultMock(), 122 + } 123 + 124 + p := DefaultParams( 125 + &locations.Location{ID: "loc-legacy-origin", Name: "Canonical Test Store"}, 126 + time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 127 + ) 128 + p.ConversationID = "conv-canonical" 129 + canonicalHash := p.Hash() 130 + legacyHash, ok := legacyRecipeHash(canonicalHash) 131 + if !ok { 132 + t.Fatal("expected to derive legacy recipe hash") 133 + } 134 + 135 + if err := s.SaveParams(t.Context(), p); err != nil { 136 + t.Fatalf("failed to save canonical params: %v", err) 137 + } 138 + 139 + recipe := ai.Recipe{ 140 + Title: "Sheet Pan Salmon", 141 + Description: "Simple weeknight salmon dinner.", 142 + Ingredients: []ai.Ingredient{{Name: "salmon", Quantity: "1 lb", Price: "$12"}}, 143 + Instructions: []string{"Roast salmon and vegetables until done."}, 144 + Health: "High protein", 145 + DrinkPairing: "Pinot Noir", 146 + } 147 + recipeHash := recipe.ComputeHash() 148 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, legacyHash); err != nil { 149 + t.Fatalf("failed to save recipe with legacy origin hash: %v", err) 150 + } 151 + 152 + req := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash, nil) 153 + req.SetPathValue("hash", recipeHash) 154 + rr := httptest.NewRecorder() 155 + 156 + s.handleSingle(rr, req) 157 + 158 + if rr.Code != http.StatusOK { 159 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 160 + } 161 + 162 + body := rr.Body.String() 163 + if !strings.Contains(body, "/recipes?h="+canonicalHash) { 164 + t.Fatalf("expected recipe page to link to canonical hash %q; body: %s", canonicalHash, body) 165 + } 166 + if strings.Contains(body, "/recipes?h="+legacyHash) { 167 + t.Fatalf("expected recipe page not to link to legacy hash %q; body: %s", legacyHash, body) 168 + } 169 + if !strings.Contains(body, "Canonical Test Store") { 170 + t.Fatalf("expected canonical params location to render, body: %s", body) 171 + } 172 + } 173 + 174 + func TestHandleSingle_LegacyOriginHashDoesNotFailWhenParamsMissing(t *testing.T) { 175 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 176 + s := &server{ 177 + recipeio: recipeio{Cache: cacheStore}, 178 + storage: users.NewStorage(cacheStore), 179 + clerk: auth.DefaultMock(), 180 + } 181 + 182 + p := DefaultParams( 183 + &locations.Location{ID: "loc-legacy-origin-missing-params", Name: "Ignored"}, 184 + time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 185 + ) 186 + canonicalHash := p.Hash() 187 + legacyHash, ok := legacyRecipeHash(canonicalHash) 188 + if !ok { 189 + t.Fatal("expected to derive legacy recipe hash") 190 + } 191 + 192 + recipe := ai.Recipe{ 193 + Title: "Legacy Hash Recipe", 194 + Description: "Recipe with legacy origin hash and no params record.", 195 + Ingredients: []ai.Ingredient{{Name: "chicken", Quantity: "1 lb", Price: "$8"}}, 196 + Instructions: []string{"Cook chicken until done."}, 197 + Health: "Protein rich", 198 + DrinkPairing: "Sparkling water", 199 + } 200 + recipeHash := recipe.ComputeHash() 201 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, legacyHash); err != nil { 202 + t.Fatalf("failed to save recipe with legacy origin hash: %v", err) 203 + } 204 + 205 + req := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash, nil) 206 + req.SetPathValue("hash", recipeHash) 207 + rr := httptest.NewRecorder() 208 + 209 + s.handleSingle(rr, req) 210 + 211 + if rr.Code != http.StatusOK { 212 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 213 + } 214 + body := rr.Body.String() 215 + if !strings.Contains(body, "/recipes?h="+canonicalHash) { 216 + t.Fatalf("expected canonical back-link hash %q in response body: %s", canonicalHash, body) 217 + } 218 + if !strings.Contains(body, "Unknown Location") { 219 + t.Fatalf("expected fallback params rendering with Unknown Location, body: %s", body) 93 220 } 94 221 } 95 222