ai cooking
0
fork

Configure Feed

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

feedback into seperate pacakge (#387)

* feedback into seperate pacakge

* fumpt sucks

* pointers are for losers

* one mroe

* what?

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
03d440b9 4936dfc2

+154 -96
+1 -1
docs/cache-layout.md
··· 29 29 | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) | 30 30 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | 31 31 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 32 - | `recipe_feedback/` | JSON `RecipeFeedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 32 + | `recipe_feedback/` | JSON `feedback.Feedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) using `internal/recipes/feedback/model.go` (`Marshal`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) using `internal/recipes/feedback/model.go` (`Decode`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 33 33 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 34 34 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 35 35 | `aldi/stores/` | JSON `aldi.StoreSummary` keyed by prefixed ALDI location ID | `cmd/aldi` and `internal/aldi` cache helpers | `internal/aldi` location backend |
-48
internal/recipes/feedback.go
··· 1 - package recipes 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "log/slog" 7 - "time" 8 - 9 - "careme/internal/cache" 10 - 11 - "github.com/samber/lo" 12 - ) 13 - 14 - const recipeFeedbackPrefix = "recipe_feedback/" 15 - 16 - type RecipeFeedback struct { 17 - Cooked bool `json:"cooked"` 18 - Stars int `json:"stars,omitempty"` 19 - Comment string `json:"comment,omitempty"` 20 - UpdatedAt time.Time `json:"updated_at"` 21 - } 22 - 23 - func (rio recipeio) FeedbackFromCache(ctx context.Context, hash string) (*RecipeFeedback, error) { 24 - feedbackBlob, err := rio.Cache.Get(ctx, recipeFeedbackPrefix+hash) 25 - if err != nil { 26 - return nil, err 27 - } 28 - defer func() { 29 - if err := feedbackBlob.Close(); err != nil { 30 - slog.ErrorContext(ctx, "failed to close cached recipe feedback", "hash", hash, "error", err) 31 - } 32 - }() 33 - 34 - var feedback RecipeFeedback 35 - if err := json.NewDecoder(feedbackBlob).Decode(&feedback); err != nil { 36 - return nil, err 37 - } 38 - return &feedback, nil 39 - } 40 - 41 - func (rio recipeio) SaveFeedback(ctx context.Context, hash string, feedback RecipeFeedback) error { 42 - feedbackJSON := lo.Must(json.Marshal(feedback)) 43 - if err := rio.Cache.Put(ctx, recipeFeedbackPrefix+hash, string(feedbackJSON), cache.Unconditional()); err != nil { 44 - slog.ErrorContext(ctx, "failed to cache recipe feedback", "hash", hash, "error", err) 45 - return err 46 - } 47 - return nil 48 - }
+60
internal/recipes/feedback/model.go
··· 1 + package feedback 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "time" 8 + 9 + "careme/internal/cache" 10 + ) 11 + 12 + type Feedback struct { 13 + Cooked bool `json:"cooked"` 14 + Stars int `json:"stars,omitempty"` 15 + Comment string `json:"comment,omitempty"` 16 + UpdatedAt time.Time `json:"updated_at"` 17 + } 18 + 19 + type FeedbackIO struct { 20 + c cache.Cache 21 + } 22 + 23 + func NewIO(c cache.Cache) FeedbackIO { 24 + if c == nil { 25 + panic("cache cannot be nil") 26 + } 27 + return FeedbackIO{c: c} 28 + } 29 + 30 + const recipeFeedbackPrefix = "recipe_feedback/" 31 + 32 + func (fio FeedbackIO) FeedbackFromCache(ctx context.Context, hash string) (*Feedback, error) { 33 + feedbackBlob, err := fio.c.Get(ctx, recipeFeedbackPrefix+hash) 34 + if err != nil { 35 + return nil, err 36 + } 37 + defer func() { 38 + if err := feedbackBlob.Close(); err != nil { 39 + slog.ErrorContext(ctx, "failed to close cached recipe feedback", "hash", hash, "error", err) 40 + } 41 + }() 42 + 43 + var state Feedback 44 + if err := json.NewDecoder(feedbackBlob).Decode(&state); err != nil { 45 + return nil, err 46 + } 47 + return &state, nil 48 + } 49 + 50 + func (fio FeedbackIO) SaveFeedback(ctx context.Context, hash string, feedback Feedback) error { 51 + feedbackJSON, err := json.Marshal(feedback) 52 + if err != nil { 53 + return err 54 + } 55 + if err := fio.c.Put(ctx, recipeFeedbackPrefix+hash, string(feedbackJSON), cache.Unconditional()); err != nil { 56 + slog.ErrorContext(ctx, "failed to cache recipe feedback", "hash", hash, "error", err) 57 + return err 58 + } 59 + return nil 60 + }
+34
internal/recipes/feedback/model_test.go
··· 1 + package feedback 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + 7 + "careme/internal/cache" 8 + ) 9 + 10 + func TestMarshalAndDecodeRoundTrip(t *testing.T) { 11 + original := Feedback{ 12 + Cooked: true, 13 + Stars: 4, 14 + Comment: "Great flavor and easy cleanup.", 15 + UpdatedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC), 16 + } 17 + 18 + cache := cache.NewInMemoryCache() 19 + io := NewIO(cache) 20 + 21 + err := io.SaveFeedback(t.Context(), "foobar", original) 22 + if err != nil { 23 + t.Fatalf("Marshal failed: %v", err) 24 + } 25 + 26 + decoded, err := io.FeedbackFromCache(t.Context(), "foobar") 27 + if err != nil { 28 + t.Fatalf("Decode failed: %v", err) 29 + } 30 + 31 + if *decoded != original { 32 + t.Fatalf("round trip mismatch: got %#v want %#v", *decoded, original) 33 + } 34 + }
+4 -3
internal/recipes/html.go
··· 10 10 11 11 "careme/internal/ai" 12 12 "careme/internal/locations" 13 + "careme/internal/recipes/feedback" 13 14 "careme/internal/seasons" 14 15 "careme/internal/templates" 15 16 ) ··· 112 113 } 113 114 114 115 // FormatRecipeHTML renders a single recipe view with a browser session id for analytics. 115 - func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, thread []RecipeThreadEntry, feedback RecipeFeedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter) { 116 + func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, thread []RecipeThreadEntry, fb feedback.Feedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter) { 116 117 slices.SortFunc(thread, func(i, j RecipeThreadEntry) int { 117 118 return j.CreatedAt.Compare(i.CreatedAt) 118 119 }) ··· 127 128 ConversationID string 128 129 WineRecommendation *ai.WineSelection 129 130 Thread []RecipeThreadEntry 130 - Feedback RecipeFeedback 131 + Feedback feedback.Feedback 131 132 RecipeHash string 132 133 Style seasons.Style 133 134 ServerSignedIn bool ··· 142 143 ConversationID: p.ConversationID, 143 144 WineRecommendation: wineRecommendation, 144 145 Thread: thread, 145 - Feedback: feedback, 146 + Feedback: fb, 146 147 RecipeHash: recipe.ComputeHash(), 147 148 Style: seasons.GetCurrentStyle(), 148 149 ServerSignedIn: signedIn,
+4 -3
internal/recipes/html_test.go
··· 13 13 "careme/internal/config" 14 14 "careme/internal/locations" 15 15 "careme/internal/logsetup" 16 + "careme/internal/recipes/feedback" 16 17 "careme/internal/templates" 17 18 18 19 "golang.org/x/net/html" ··· 204 205 p := DefaultParams(&loc, time.Now()) 205 206 p.ConversationID = "convo123" 206 207 w := httptest.NewRecorder() 207 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, RecipeFeedback{}, nil, w) 208 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 208 209 html := w.Body.String() 209 210 210 211 isValidHTML(t, html) ··· 267 268 p := DefaultParams(&loc, time.Now()) 268 269 p.ConversationID = "convo123" 269 270 w := httptest.NewRecorder() 270 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, []RecipeThreadEntry{}, RecipeFeedback{}, nil, w) 271 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 271 272 html := w.Body.String() 272 273 273 274 isValidHTML(t, html) ··· 285 286 p := DefaultParams(&loc, time.Now()) 286 287 p.ConversationID = "convo123" 287 288 w := httptest.NewRecorder() 288 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, RecipeFeedback{}, &ai.WineSelection{ 289 + FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, []RecipeThreadEntry{}, feedback.Feedback{}, &ai.WineSelection{ 289 290 Wines: []ai.Ingredient{ 290 291 {Name: "Oregon Pinot Noir", Price: "$14.99"}, 291 292 {Name: "Backup Chardonnay", Price: "$11.99"},
+9 -4
internal/recipes/io.go
··· 10 10 "careme/internal/ai" 11 11 "careme/internal/cache" 12 12 "careme/internal/kroger" 13 + "careme/internal/recipes/feedback" 13 14 14 15 "github.com/samber/lo" 15 16 ) ··· 23 24 24 25 type recipeio struct { 25 26 Cache cache.Cache 27 + feedback.FeedbackIO 26 28 } 27 29 28 - func IO(c cache.Cache) *recipeio { 29 - return &recipeio{c} 30 + func IO(c cache.Cache) recipeio { 31 + return recipeio{ 32 + Cache: c, 33 + FeedbackIO: feedback.NewIO(c), 34 + } 30 35 } 31 36 32 37 func (rio recipeio) SingleFromCache(ctx context.Context, hash string) (*ai.Recipe, error) { ··· 143 148 144 149 var ErrAlreadyExists = errors.New("already exists") 145 150 146 - func (rio *recipeio) SaveParams(ctx context.Context, p *generatorParams) error { 151 + func (rio recipeio) SaveParams(ctx context.Context, p *generatorParams) error { 147 152 paramsJSON := lo.Must(json.Marshal(p)) 148 153 if err := rio.Cache.Put(ctx, paramsCachePrefix+p.Hash(), string(paramsJSON), cache.IfNoneMatch()); err != nil { 149 154 if errors.Is(err, cache.ErrAlreadyExists) { ··· 155 160 return nil 156 161 } 157 162 158 - func (rio *recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error { 163 + func (rio recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error { 159 164 // Save each recipe separately by its hash 160 165 if err := rio.SaveRecipes(ctx, shoppingList.Recipes, hash); err != nil { 161 166 return err
+5 -3
internal/recipes/server.go
··· 19 19 "careme/internal/cache" 20 20 "careme/internal/config" 21 21 "careme/internal/locations" 22 + "careme/internal/recipes/feedback" 22 23 "careme/internal/routing" 23 24 "careme/internal/seasons" 24 25 "careme/internal/templates" 25 26 "careme/internal/users" 27 + 26 28 utypes "careme/internal/users/types" 27 29 28 30 "github.com/samber/lo" ··· 59 61 // cache must be connected to generator or this will not work. Should we enfroce that by getting cache from generator? 60 62 func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache, clerkClient auth.AuthClient) *server { 61 63 return &server{ 62 - recipeio: recipeio{Cache: c}, 64 + recipeio: IO(c), 63 65 cache: c, 64 66 cfg: cfg, 65 67 storage: storage, ··· 100 102 } 101 103 _, err = s.clerk.GetUserIDFromRequest(r) 102 104 signedIn := !errors.Is(err, auth.ErrNoSession) 103 - feedback := RecipeFeedback{} 105 + feedback := feedback.Feedback{} 104 106 var thread []RecipeThreadEntry 105 107 var wineRecommendation *ai.WineSelection 106 108 var loadWG sync.WaitGroup ··· 337 339 return 338 340 } 339 341 340 - feedback := RecipeFeedback{} 342 + feedback := feedback.Feedback{} 341 343 existing, err := s.FeedbackFromCache(ctx, hash) 342 344 if err != nil { 343 345 if !errors.Is(err, cache.ErrNotFound) {
+37 -34
internal/recipes/server_test.go
··· 19 19 "careme/internal/auth" 20 20 "careme/internal/cache" 21 21 "careme/internal/locations" 22 + "careme/internal/recipes/feedback" 22 23 "careme/internal/routing" 23 24 "careme/internal/users" 25 + 24 26 utypes "careme/internal/users/types" 25 27 ) 26 28 ··· 131 133 ZipCode: "94105", 132 134 } 133 135 s := &server{ 134 - recipeio: recipeio{Cache: cacheStore}, 136 + recipeio: IO(cacheStore), 135 137 storage: storage, 136 138 clerk: auth.DefaultMock(), 137 139 generator: mock{}, ··· 203 205 ZipCode: "94105", 204 206 } 205 207 s := &server{ 206 - recipeio: recipeio{Cache: cacheStore}, 208 + recipeio: IO(cacheStore), 207 209 storage: storage, 208 210 clerk: auth.DefaultMock(), 209 211 generator: mock{}, ··· 267 269 func TestHandleSingle_NormalizesLegacyOriginHashToCanonicalHash(t *testing.T) { 268 270 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 269 271 s := &server{ 270 - recipeio: recipeio{Cache: cacheStore}, 272 + recipeio: IO(cacheStore), 271 273 storage: users.NewStorage(cacheStore), 272 274 clerk: auth.DefaultMock(), 273 275 } ··· 328 330 func TestHandleSingle_LegacyOriginHashDoesNotFailWhenParamsMissing(t *testing.T) { 329 331 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 330 332 s := &server{ 331 - recipeio: recipeio{Cache: cacheStore}, 333 + recipeio: IO(cacheStore), 332 334 storage: users.NewStorage(cacheStore), 333 335 clerk: auth.DefaultMock(), 334 336 } ··· 377 379 func TestHandleSingle_IncludesCachedWineRecommendation(t *testing.T) { 378 380 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 379 381 s := &server{ 380 - recipeio: recipeio{Cache: cacheStore}, 382 + recipeio: IO(cacheStore), 381 383 storage: users.NewStorage(cacheStore), 382 384 clerk: auth.DefaultMock(), 383 385 } ··· 454 456 func TestHandleQuestion_RequiresSignedInUser(t *testing.T) { 455 457 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 456 458 s := &server{ 457 - recipeio: recipeio{Cache: cacheStore}, 459 + recipeio: IO(cacheStore), 458 460 storage: users.NewStorage(cacheStore), 459 461 clerk: noSessionAuth{}, 460 462 } ··· 479 481 func TestHandleQuestion_RejectsNonHTMXRequest(t *testing.T) { 480 482 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 481 483 s := &server{ 482 - recipeio: recipeio{Cache: cacheStore}, 484 + recipeio: IO(cacheStore), 483 485 storage: users.NewStorage(cacheStore), 484 486 clerk: auth.DefaultMock(), 485 487 } ··· 555 557 storage := users.NewStorage(cacheStore) 556 558 generator := &captureKickgenerationGenerator{called: make(chan struct{}, 1)} 557 559 s := &server{ 558 - recipeio: recipeio{Cache: cacheStore}, 560 + recipeio: IO(cacheStore), 561 + clerk: auth.DefaultMock(), 559 562 storage: storage, 560 563 generator: generator, 561 564 } ··· 572 575 LastRecipes: []utypes.Recipe{cookedRecent, notCookedRecent, tooOldCooked}, 573 576 } 574 577 575 - if err := s.SaveFeedback(t.Context(), cookedRecent.Hash, RecipeFeedback{Cooked: true, UpdatedAt: now}); err != nil { 578 + if err := s.SaveFeedback(t.Context(), cookedRecent.Hash, feedback.Feedback{Cooked: true, UpdatedAt: now}); err != nil { 576 579 t.Fatalf("failed to seed cooked feedback: %v", err) 577 580 } 578 - if err := s.SaveFeedback(t.Context(), notCookedRecent.Hash, RecipeFeedback{Cooked: false, UpdatedAt: now}); err != nil { 581 + if err := s.SaveFeedback(t.Context(), notCookedRecent.Hash, feedback.Feedback{Cooked: false, UpdatedAt: now}); err != nil { 579 582 t.Fatalf("failed to seed uncooked feedback: %v", err) 580 583 } 581 - if err := s.SaveFeedback(t.Context(), tooOldCooked.Hash, RecipeFeedback{Cooked: true, UpdatedAt: now}); err != nil { 584 + if err := s.SaveFeedback(t.Context(), tooOldCooked.Hash, feedback.Feedback{Cooked: true, UpdatedAt: now}); err != nil { 582 585 t.Fatalf("failed to seed old cooked feedback: %v", err) 583 586 } 584 587 ··· 643 646 func TestHandleQuestion_HTMXReturnsThreadFragment(t *testing.T) { 644 647 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 645 648 s := &server{ 646 - recipeio: recipeio{Cache: cacheStore}, 649 + recipeio: IO(cacheStore), 647 650 storage: users.NewStorage(cacheStore), 648 651 clerk: auth.DefaultMock(), 649 652 generator: &captureQuestionGenerator{}, ··· 682 685 func TestHandleQuestion_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 683 686 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 684 687 s := &server{ 685 - recipeio: recipeio{Cache: cacheStore}, 688 + recipeio: IO(cacheStore), 686 689 storage: users.NewStorage(cacheStore), 687 690 clerk: noSessionAuth{}, 688 691 } ··· 711 714 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 712 715 g := &captureQuestionGenerator{} 713 716 s := &server{ 714 - recipeio: recipeio{Cache: cacheStore}, 717 + recipeio: IO(cacheStore), 715 718 storage: users.NewStorage(cacheStore), 716 719 clerk: auth.DefaultMock(), 717 720 generator: g, ··· 741 744 func TestHandleWine_RejectsNonHTMXRequest(t *testing.T) { 742 745 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 743 746 s := &server{ 744 - recipeio: recipeio{Cache: cacheStore}, 747 + recipeio: IO(cacheStore), 745 748 storage: users.NewStorage(cacheStore), 746 749 clerk: auth.DefaultMock(), 747 750 } ··· 761 764 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 762 765 g := &captureQuestionGenerator{} 763 766 s := &server{ 764 - recipeio: recipeio{Cache: cacheStore}, 767 + recipeio: IO(cacheStore), 765 768 storage: users.NewStorage(cacheStore), 766 769 clerk: auth.DefaultMock(), 767 770 generator: g, ··· 821 824 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 822 825 g := &captureQuestionGenerator{} 823 826 s := &server{ 824 - recipeio: recipeio{Cache: cacheStore}, 827 + recipeio: IO(cacheStore), 825 828 storage: users.NewStorage(cacheStore), 826 829 clerk: auth.DefaultMock(), 827 830 generator: g, ··· 875 878 cacheStore := cache.NewInMemoryCache() 876 879 g1 := &captureQuestionGenerator{wineRecommendation: "Try a crisp riesling."} 877 880 s1 := &server{ 878 - recipeio: recipeio{Cache: cacheStore}, 881 + recipeio: IO(cacheStore), 879 882 storage: users.NewStorage(cacheStore), 880 883 clerk: auth.DefaultMock(), 881 884 generator: g1, ··· 918 921 919 922 g2 := &captureQuestionGenerator{panicOnWine: true} 920 923 s2 := &server{ 921 - recipeio: recipeio{Cache: cacheStore}, 924 + recipeio: IO(cacheStore), 922 925 storage: users.NewStorage(cacheStore), 923 926 clerk: auth.DefaultMock(), 924 927 generator: g2, ··· 945 948 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 946 949 storage := users.NewStorage(cacheStore) 947 950 s := &server{ 948 - recipeio: recipeio{Cache: cacheStore}, 951 + recipeio: IO(cacheStore), 949 952 storage: storage, 950 953 clerk: auth.DefaultMock(), 951 954 } ··· 1015 1018 func TestHandleSaveRecipe_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 1016 1019 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1017 1020 s := &server{ 1018 - recipeio: recipeio{Cache: cacheStore}, 1021 + recipeio: IO(cacheStore), 1019 1022 storage: users.NewStorage(cacheStore), 1020 1023 clerk: noSessionAuth{}, 1021 1024 } ··· 1039 1042 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1040 1043 storage := users.NewStorage(cacheStore) 1041 1044 s := &server{ 1042 - recipeio: recipeio{Cache: cacheStore}, 1045 + recipeio: IO(cacheStore), 1043 1046 storage: storage, 1044 1047 clerk: auth.DefaultMock(), 1045 1048 } ··· 1095 1098 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1096 1099 storage := users.NewStorage(cacheStore) 1097 1100 s := &server{ 1098 - recipeio: recipeio{Cache: cacheStore}, 1101 + recipeio: IO(cacheStore), 1099 1102 storage: storage, 1100 1103 clerk: auth.DefaultMock(), 1101 1104 } ··· 1188 1191 func TestHandleDismissRecipe_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 1189 1192 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1190 1193 s := &server{ 1191 - recipeio: recipeio{Cache: cacheStore}, 1194 + recipeio: IO(cacheStore), 1192 1195 storage: users.NewStorage(cacheStore), 1193 1196 clerk: noSessionAuth{}, 1194 1197 } ··· 1212 1215 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1213 1216 storage := users.NewStorage(cacheStore) 1214 1217 s := &server{ 1215 - recipeio: recipeio{Cache: cacheStore}, 1218 + recipeio: IO(cacheStore), 1216 1219 storage: storage, 1217 1220 clerk: auth.DefaultMock(), 1218 1221 } ··· 1285 1288 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1286 1289 storage := users.NewStorage(cacheStore) 1287 1290 s := &server{ 1288 - recipeio: recipeio{Cache: cacheStore}, 1291 + recipeio: IO(cacheStore), 1289 1292 storage: storage, 1290 1293 clerk: auth.DefaultMock(), 1291 1294 generator: mock{}, ··· 1368 1371 storage := users.NewStorage(cacheStore) 1369 1372 generator := &captureKickgenerationGenerator{called: make(chan struct{}, 1)} 1370 1373 s := &server{ 1371 - recipeio: recipeio{Cache: cacheStore}, 1374 + recipeio: IO(cacheStore), 1372 1375 storage: storage, 1373 1376 clerk: auth.DefaultMock(), 1374 1377 generator: generator, ··· 1438 1441 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1439 1442 storage := users.NewStorage(cacheStore) 1440 1443 s := &server{ 1441 - recipeio: recipeio{Cache: cacheStore}, 1444 + recipeio: IO(cacheStore), 1442 1445 storage: storage, 1443 1446 clerk: auth.DefaultMock(), 1444 1447 } ··· 1506 1509 func TestParamsForAction_PreservesBaseSelectionWhenSelectionCacheEmpty(t *testing.T) { 1507 1510 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1508 1511 s := &server{ 1509 - recipeio: recipeio{Cache: cacheStore}, 1512 + recipeio: IO(cacheStore), 1510 1513 } 1511 1514 1512 1515 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} ··· 1544 1547 func TestParamsForAction_MergesSelectionAndRemovesOppositeRecipes(t *testing.T) { 1545 1548 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1546 1549 s := &server{ 1547 - recipeio: recipeio{Cache: cacheStore}, 1550 + recipeio: IO(cacheStore), 1548 1551 } 1549 1552 1550 1553 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} ··· 1586 1589 func TestHandleFeedback_CookedButtonSavesCookedState(t *testing.T) { 1587 1590 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1588 1591 s := &server{ 1589 - recipeio: recipeio{Cache: cacheStore}, 1592 + recipeio: IO(cacheStore), 1590 1593 storage: users.NewStorage(cacheStore), 1591 1594 clerk: auth.DefaultMock(), 1592 1595 } ··· 1627 1630 func TestHandleFeedback_SavesStarsAndComment(t *testing.T) { 1628 1631 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1629 1632 s := &server{ 1630 - recipeio: recipeio{Cache: cacheStore}, 1633 + recipeio: IO(cacheStore), 1631 1634 storage: users.NewStorage(cacheStore), 1632 1635 clerk: auth.DefaultMock(), 1633 1636 } ··· 1667 1670 func TestHandleFeedback_InvalidStarsRejected(t *testing.T) { 1668 1671 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1669 1672 s := &server{ 1670 - recipeio: recipeio{Cache: cacheStore}, 1673 + recipeio: IO(cacheStore), 1671 1674 storage: users.NewStorage(cacheStore), 1672 1675 clerk: auth.DefaultMock(), 1673 1676 } ··· 1692 1695 func TestHandleFeedback_RejectsNonHTMXRequest(t *testing.T) { 1693 1696 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1694 1697 s := &server{ 1695 - recipeio: recipeio{Cache: cacheStore}, 1698 + recipeio: IO(cacheStore), 1696 1699 storage: users.NewStorage(cacheStore), 1697 1700 clerk: auth.DefaultMock(), 1698 1701 }