package store import ( "context" "errors" "testing" "github.com/urpi/go-bananas/internal/domain" ) func TestPreferencesDefaultToNoChoice(t *testing.T) { s := newTestStore(t) ctx := context.Background() // A subject nobody has saved anything for reads as empty rather than failing, // because empty is the documented "no choice" state. prefs, err := s.Preferences(ctx, "nobody") if err != nil { t.Fatalf("read preferences: %v", err) } if prefs.SelectedTheme != "" || prefs.SelectedAppearance != "" { t.Fatalf("preferences = %+v, want the zero value", prefs) } } func TestPreferencesRoundTrip(t *testing.T) { s := newTestStore(t) ctx := context.Background() published := publishTheme(t, s, "midnight") stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: published.Slug, SelectedAppearance: domain.AppearanceDark, }) if err != nil { t.Fatalf("set preferences: %v", err) } if stored.SelectedTheme != "midnight" || stored.SelectedAppearance != domain.AppearanceDark { t.Fatalf("stored = %+v", stored) } read, err := s.Preferences(ctx, "user-1") if err != nil { t.Fatalf("read preferences: %v", err) } if read != stored { t.Fatalf("read = %+v, want %+v", read, stored) } } func TestPreferencesCanBeClearedAndOverwritten(t *testing.T) { s := newTestStore(t) ctx := context.Background() publishTheme(t, s, "midnight") if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceLight, }); err != nil { t.Fatalf("set preferences: %v", err) } // Writing again replaces rather than accumulating. if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceDark, }); err != nil { t.Fatalf("overwrite preferences: %v", err) } read, err := s.Preferences(ctx, "user-1") if err != nil { t.Fatalf("read preferences: %v", err) } if read.SelectedAppearance != domain.AppearanceDark { t.Fatalf("appearance = %q, want dark", read.SelectedAppearance) } // An empty theme is the absence of a choice and is storable. if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{}); err != nil { t.Fatalf("clear by writing empty: %v", err) } read, err = s.Preferences(ctx, "user-1") if err != nil { t.Fatalf("read preferences: %v", err) } if read.SelectedTheme != "" { t.Fatalf("theme = %q, want empty", read.SelectedTheme) } removed, err := s.ClearPreferences(ctx, "user-1") if err != nil { t.Fatalf("clear preferences: %v", err) } if !removed { t.Error("clear reported no row removed") } } // TestPreferencesRejectUnknownAndDraftThemes covers the two ways a name can fail: // it does not exist, or it exists but runtime consumers cannot load it. func TestPreferencesRejectUnknownAndDraftThemes(t *testing.T) { s := newTestStore(t) ctx := context.Background() if err := s.Create(ctx, sampleTheme("draft-one"), ""); err != nil { t.Fatalf("create theme: %v", err) } for _, slug := range []string{"no-such-theme", "draft-one"} { _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: slug, SelectedAppearance: domain.AppearanceLight, }) if !errors.Is(err, domain.ErrInvalid) { t.Errorf("SetPreferences(%q) = %v, want ErrInvalid", slug, err) } } } // TestPreferencesFallBackWhenTheThemeDisappears is the reason Preferences resolves // the name on read: a theme that is later deleted must not leave somebody pinned to // a selection the UI cannot load. func TestPreferencesFallBackWhenTheThemeDisappears(t *testing.T) { s := newTestStore(t) ctx := context.Background() theme := publishTheme(t, s, "midnight") if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: theme.Slug, SelectedAppearance: domain.AppearanceDark, }); err != nil { t.Fatalf("set preferences: %v", err) } if err := s.Delete(ctx, theme.Slug); err != nil { t.Fatalf("delete theme: %v", err) } read, err := s.Preferences(ctx, "user-1") if err != nil { t.Fatalf("read preferences: %v", err) } if read.SelectedTheme != "" || read.SelectedAppearance != "" { t.Fatalf("preferences = %+v, want the zero value once the theme is gone", read) } } func TestPreferencesRejectMalformedInput(t *testing.T) { s := newTestStore(t) ctx := context.Background() publishTheme(t, s, "midnight") rejected := map[string]domain.UserPreferences{ "uppercase slug": {SelectedTheme: "Midnight"}, "slug with a space": {SelectedTheme: "mid night"}, "leading dash": {SelectedTheme: "-midnight"}, "unknown appearance": { SelectedTheme: "midnight", SelectedAppearance: "sepia", }, } for name, prefs := range rejected { t.Run(name, func(t *testing.T) { if _, err := s.SetPreferences(ctx, "user-1", prefs); !errors.Is(err, domain.ErrInvalid) { t.Fatalf("SetPreferences(%+v) = %v, want ErrInvalid", prefs, err) } }) } // Clearing with an empty theme is valid, and an appearance sent alongside it // is dropped rather than stored as a scheme with nothing to apply it to. stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: "", SelectedAppearance: domain.AppearanceDark, }) if err != nil { t.Fatalf("clearing with a stray appearance: %v", err) } if stored.SelectedAppearance != "" { t.Fatalf("stored = %+v, want the appearance dropped", stored) } if _, err := s.Preferences(ctx, ""); !errors.Is(err, domain.ErrInvalid) { t.Errorf("Preferences(\"\") = %v, want ErrInvalid", err) } if _, err := s.SetPreferences(ctx, "", domain.UserPreferences{}); !errors.Is(err, domain.ErrInvalid) { t.Errorf("SetPreferences with empty subject = %v, want ErrInvalid", err) } } // TestPreferencesSurviveLogin matters because UpsertUser rewrites the users row on // every login, which is exactly why preferences are not columns on it. func TestPreferencesSurviveLogin(t *testing.T) { s := newTestStore(t) ctx := context.Background() publishTheme(t, s, "midnight") if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceDark, }); err != nil { t.Fatalf("set preferences: %v", err) } for range 2 { if err := s.UpsertUser(ctx, &domain.Identity{ Subject: "user-1", Email: "u@example.test", Roles: []domain.Role{domain.RoleViewer}, }); err != nil { t.Fatalf("upsert user: %v", err) } } read, err := s.Preferences(ctx, "user-1") if err != nil { t.Fatalf("read preferences: %v", err) } if read.SelectedTheme != "midnight" || read.SelectedAppearance != domain.AppearanceDark { t.Fatalf("preferences = %+v, want them preserved across logins", read) } } func TestThemePreferenceCount(t *testing.T) { s := newTestStore(t) ctx := context.Background() publishTheme(t, s, "midnight") for _, subject := range []string{"a", "b"} { if _, err := s.SetPreferences(ctx, subject, domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceLight, }); err != nil { t.Fatalf("set preferences for %s: %v", subject, err) } } count, err := s.ThemePreferenceCount(ctx, "midnight") if err != nil { t.Fatalf("count: %v", err) } if count != 2 { t.Fatalf("count = %d, want 2", count) } if _, err := s.ThemePreferenceCount(ctx, "Not A Slug"); !errors.Is(err, domain.ErrInvalid) { t.Errorf("ThemePreferenceCount with a bad slug = %v, want ErrInvalid", err) } } // publishTheme creates a theme and publishes it, returning the stored value. // // StatusPublished is a constant rather than a variable, so the pointer // UpdateFields wants has to be taken from a local. func publishTheme(t *testing.T, s *Store, slug string) *domain.Theme { t.Helper() ctx := context.Background() if err := s.Create(ctx, sampleTheme(slug), ""); err != nil { t.Fatalf("create theme %s: %v", slug, err) } published := domain.StatusPublished theme, err := s.Update(ctx, slug, UpdateFields{Status: &published}) if err != nil { t.Fatalf("publish theme %s: %v", slug, err) } return theme }