Files
wanderer/db/util/category_test.go
slothful-vassal 3b8f00fd26 feat: Refine trail category model (#1059)
* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
2026-06-29 03:16:26 +02:00

1302 lines
38 KiB
Go

package util
import (
"reflect"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestNormalizeCategoryName(t *testing.T) {
t.Run("WhitespaceAndSeparators", func(t *testing.T) {
for _, value := range []string{"E-Bike", "E Bike", "e-bike"} {
if got := NormalizeCategoryName(value); got != "e bike" {
t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", value, got, "e bike")
}
}
if got := NormalizeCategoryName(" Mountain Biking "); got != "mountain biking" {
t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", " Mountain Biking ", got, "mountain biking")
}
})
t.Run("AccentFolding", func(t *testing.T) {
for _, value := range []string{"Canoë", "Canoe"} {
if got := NormalizeCategoryName(value); got != "canoe" {
t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", value, got, "canoe")
}
}
})
t.Run("SeparatorsNotRemoved", func(t *testing.T) {
if got := NormalizeCategoryName("EBike"); got != "ebike" {
t.Fatalf("NormalizeCategoryName(%q) = %q, want %q", "EBike", got, "ebike")
}
if NormalizeCategoryName("EBike") == NormalizeCategoryName("E Bike") {
t.Fatalf("expected %q and %q to normalize differently", "EBike", "E Bike")
}
})
}
func TestParseCategoryTranslations(t *testing.T) {
tests := []struct {
name string
input types.JSONRaw
wantErr bool
want map[string]CategoryTranslation
}{
{
name: "valid base locale",
input: types.JSONRaw(`{"de": {"name": "Wandern", "short_name": "WAND"}}`),
want: map[string]CategoryTranslation{"de": {Name: "Wandern", ShortName: "WAND"}},
},
{
name: "null is nil without error",
input: types.JSONRaw(`null`),
want: nil,
},
{
name: "region locale rejected",
input: types.JSONRaw(`{"pt-BR": {"name": "Caminhada"}}`),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseCategoryTranslations(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("ParseCategoryTranslations() error = nil, want error")
}
return
}
if err != nil {
t.Fatalf("ParseCategoryTranslations() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("ParseCategoryTranslations() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestDefaultCategoryTranslationsAreSupportedAndNonEmpty(t *testing.T) {
for _, category := range DefaultCategoryNames() {
translations, ok := defaultCategoryTranslations[category]
if !ok {
t.Fatalf("defaultCategoryTranslations missing category %q", category)
}
for locale, name := range translations {
if _, ok := supportedCategoryLocales[locale]; !ok {
t.Fatalf("defaultCategoryTranslations[%q] contains unsupported locale %q", category, locale)
}
if name == "" {
t.Fatalf("defaultCategoryTranslations[%q][%q] is empty", category, locale)
}
}
}
}
func TestDefaultCategoryIconsAreNonEmpty(t *testing.T) {
for _, category := range DefaultCategoryNames() {
icon, ok := defaultCategoryIcons[category]
if !ok {
t.Fatalf("defaultCategoryIcons missing category %q", category)
}
if strings.TrimSpace(icon) == "" {
t.Fatalf("defaultCategoryIcons[%q] is empty", category)
}
}
}
func TestSeedDefaultCategoriesAddsMissingDefaults(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
running := createTestCategory(t, app, "Running")
if err := SeedDefaultCategories(app); err != nil {
t.Fatalf("SeedDefaultCategories() error = %v", err)
}
categories, err := app.FindAllRecords("categories")
if err != nil {
t.Fatal(err)
}
byName := map[string]*core.Record{}
for _, category := range categories {
byName[category.GetString("name")] = category
}
for _, name := range DefaultCategoryNames() {
if byName[name] == nil {
t.Fatalf("default category %q was not seeded", name)
}
}
if byName["Running"].Id != running.Id {
t.Fatalf("existing Running category was replaced: got %q, want %q", byName["Running"].Id, running.Id)
}
if len(categories) != len(DefaultCategoryNames()) {
t.Fatalf("category count = %d, want %d", len(categories), len(DefaultCategoryNames()))
}
}
func TestMergeDefaultCategoryTranslations(t *testing.T) {
merged, changed := mergeDefaultCategoryTranslations(
map[string]string{
"de": "Wandern",
"en": "Hiking",
"fr": "",
},
map[string]CategoryTranslation{
"de": {
Name: "Custom Wandern",
ShortName: "WAND",
},
},
)
if !changed {
t.Fatal("mergeDefaultCategoryTranslations changed = false, want true")
}
want := map[string]CategoryTranslation{
"de": {
Name: "Custom Wandern",
ShortName: "WAND",
},
"en": {
Name: "Hiking",
},
}
if !reflect.DeepEqual(merged, want) {
t.Fatalf("mergeDefaultCategoryTranslations() = %#v, want %#v", merged, want)
}
}
func TestMergeDefaultSubcategoryTranslations(t *testing.T) {
merged, changed := mergeDefaultSubcategoryTranslations(
map[string]CategoryTranslation{
"de": {
Name: "Rennrad",
ShortName: "ROAD",
},
"en": {
Name: "Road",
ShortName: "ROAD",
},
},
map[string]CategoryTranslation{
"de": {
Name: "Custom Road",
ShortName: "ROADX",
},
"en": {
Name: "Road",
},
},
)
if !changed {
t.Fatal("mergeDefaultSubcategoryTranslations changed = false, want true")
}
want := map[string]CategoryTranslation{
"de": {
Name: "Custom Road",
ShortName: "ROADX",
},
"en": {
Name: "Road",
ShortName: "ROAD",
},
}
if !reflect.DeepEqual(merged, want) {
t.Fatalf("mergeDefaultSubcategoryTranslations() = %#v, want %#v", merged, want)
}
}
func TestPrepopulateDefaultCategoryIcons(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
hiking := createTestCategory(t, app, "Hiking")
walking := createTestCategory(t, app, "Walking")
canoeing := createTestCategory(t, app, "Canoeing")
walking.Set("icon", "custom-icon")
if err := app.Save(walking); err != nil {
t.Fatal(err)
}
canoeing.Set("icon", "ship")
if err := app.Save(canoeing); err != nil {
t.Fatal(err)
}
if err := PrepopulateDefaultCategoryIcons(app); err != nil {
t.Fatalf("PrepopulateDefaultCategoryIcons() error = %v", err)
}
hiking, err := app.FindRecordById("categories", hiking.Id)
if err != nil {
t.Fatal(err)
}
walking, err = app.FindRecordById("categories", walking.Id)
if err != nil {
t.Fatal(err)
}
canoeing, err = app.FindRecordById("categories", canoeing.Id)
if err != nil {
t.Fatal(err)
}
if got := hiking.GetString("icon"); got != defaultCategoryIcons["Hiking"] {
t.Fatalf("hiking icon = %q, want %q", got, defaultCategoryIcons["Hiking"])
}
if got := walking.GetString("icon"); got != "custom-icon" {
t.Fatalf("walking icon = %q, want custom icon", got)
}
if got := canoeing.GetString("icon"); got != "ship" {
t.Fatalf("canoeing icon = %q, want existing icon", got)
}
}
func TestDefaultSubcategories(t *testing.T) {
seen := map[string]struct{}{}
wantDefaults := map[string]struct{}{
"Hiking/Winter": {},
"Hiking/Alpine": {},
"Hiking/Long-distance": {},
"Hiking/Snowshoeing": {},
"Hiking/Family": {},
"Hiking/Pilgrimage": {},
"Biking/Touring": {},
"Running/Trail": {},
"Running/Road": {},
"Skiing/Cross-country": {},
"Skiing/Skating": {},
"Skiing/Backcountry": {},
}
wantBadgeIcons := map[string]string{
"Biking/MTB": "mountain",
"Biking/Road": "grip-lines-vertical",
"Biking/E-Bike": "bolt",
"Hiking/Winter": "snowflake",
"Hiking/Alpine": "mountain",
"Hiking/Snowshoeing": "snowflake",
"Hiking/Family": "child",
"Hiking/Pilgrimage": "cross",
"Running/Road": "grip-lines-vertical",
}
for _, subcategory := range defaultSubcategories {
if subcategory.parentCategory == "" {
t.Fatalf("default subcategory %q parent is empty", subcategory.name)
}
if subcategory.name == "" {
t.Fatal("default subcategory name is empty")
}
if subcategory.shortName == "" {
t.Fatalf("default subcategory %q shortName is empty", subcategory.name)
}
normalizedName := NormalizeCategoryName(subcategory.name)
key := subcategory.parentCategory + "/" + normalizedName
if _, ok := seen[key]; ok {
t.Fatalf("default subcategory %q duplicates normalized key %q", subcategory.name, key)
}
seen[key] = struct{}{}
if _, ok := wantDefaults[subcategory.parentCategory+"/"+subcategory.name]; ok {
delete(wantDefaults, subcategory.parentCategory+"/"+subcategory.name)
}
if wantBadgeIcon, ok := wantBadgeIcons[subcategory.parentCategory+"/"+subcategory.name]; ok {
if subcategory.badgeIcon != wantBadgeIcon {
t.Fatalf("%s/%s badgeIcon = %q, want %q", subcategory.parentCategory, subcategory.name, subcategory.badgeIcon, wantBadgeIcon)
}
}
if subcategory.parentCategory == "Hiking" && subcategory.name == "Winter" {
if subcategory.translations["de"].Name != "Winterwandern" {
t.Fatalf("Winter de translation = %q, want Winterwandern", subcategory.translations["de"].Name)
}
}
if subcategory.parentCategory == "Hiking" && subcategory.name == "Pilgrimage" {
if subcategory.translations["de"].Name != "Pilgern" {
t.Fatalf("Pilgrimage de translation = %q, want Pilgern", subcategory.translations["de"].Name)
}
}
if subcategory.parentCategory == "Biking" && subcategory.name == "Touring" {
if subcategory.translations["de"].Name != "Tourenrad" {
t.Fatalf("Touring de translation = %q, want Tourenrad", subcategory.translations["de"].Name)
}
}
}
if len(wantDefaults) > 0 {
t.Fatalf("default subcategories missing entries: %#v", wantDefaults)
}
}
func TestValidateSubcategoryRecord(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
hiking := createTestCategory(t, app, "Hiking")
createTestSubcategory(t, app, biking.Id, "MTB", "MTB")
subcategoriesCollection := mustFindTestCollection(t, app, "subcategories")
tests := []struct {
name string
category string
record string
wantError bool
}{
{
name: "requires parent category",
category: "",
record: "Road",
wantError: true,
},
{
name: "rejects normalized collision in same parent",
category: biking.Id,
record: "mtb",
wantError: true,
},
{
name: "allows same normalized name in different parent",
category: hiking.Id,
record: "mtb",
},
{
name: "allows unique name in same parent",
category: biking.Id,
record: "Road",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := core.NewRecord(subcategoriesCollection)
record.Set("category", tt.category)
record.Set("name", tt.record)
err := ValidateSubcategoryRecord(app, record)
if (err != nil) != tt.wantError {
t.Fatalf("ValidateSubcategoryRecord() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestValidateTrailSubcategoryRecord(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
hiking := createTestCategory(t, app, "Hiking")
mtb := createTestSubcategory(t, app, biking.Id, "MTB", "MTB")
trailsCollection := mustFindTestCollection(t, app, "trails")
missingSubcategoryID := "missing1234567"
tests := []struct {
name string
category string
subcategory string
subcategoryExplicit bool
wantSubcategory string
wantError bool
}{
{
name: "allows empty subcategory",
},
{
name: "requires category when subcategory is explicit",
subcategory: mtb.Id,
subcategoryExplicit: true,
wantSubcategory: mtb.Id,
wantError: true,
},
{
name: "clears subcategory without category when subcategory is implicit",
subcategory: mtb.Id,
wantSubcategory: "",
},
{
name: "allows matching parent category",
category: biking.Id,
subcategory: mtb.Id,
subcategoryExplicit: true,
wantSubcategory: mtb.Id,
},
{
name: "rejects mismatched explicit subcategory",
category: hiking.Id,
subcategory: mtb.Id,
subcategoryExplicit: true,
wantSubcategory: mtb.Id,
wantError: true,
},
{
name: "clears mismatched implicit subcategory",
category: hiking.Id,
subcategory: mtb.Id,
wantSubcategory: "",
},
{
name: "rejects missing explicit subcategory",
category: biking.Id,
subcategory: missingSubcategoryID,
subcategoryExplicit: true,
wantSubcategory: missingSubcategoryID,
wantError: true,
},
{
name: "clears missing implicit subcategory",
category: biking.Id,
subcategory: missingSubcategoryID,
wantSubcategory: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := core.NewRecord(trailsCollection)
record.Set("category", tt.category)
record.Set("subcategory", tt.subcategory)
err := ValidateTrailSubcategoryRecord(app, record, tt.subcategoryExplicit)
if (err != nil) != tt.wantError {
t.Fatalf("ValidateTrailSubcategoryRecord() error = %v, wantError %v", err, tt.wantError)
}
if got := record.GetString("subcategory"); got != tt.wantSubcategory {
t.Fatalf("record subcategory = %q, want %q", got, tt.wantSubcategory)
}
})
}
}
func TestSeedDefaultSubcategoriesSkipsNormalizedExisting(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
mtb := createTestSubcategory(t, app, biking.Id, "mtb", "CUSTOM")
if err := SeedDefaultSubcategories(app); err != nil {
t.Fatalf("SeedDefaultSubcategories() error = %v", err)
}
records, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category}",
"",
0,
0,
map[string]any{"category": biking.Id},
)
if err != nil {
t.Fatal(err)
}
var normalizedMTBCount int
for _, record := range records {
if NormalizeCategoryName(record.GetString("name")) == NormalizeCategoryName("MTB") {
normalizedMTBCount++
}
}
if normalizedMTBCount != 1 {
t.Fatalf("normalized MTB subcategory count = %d, want 1", normalizedMTBCount)
}
updatedMTB, err := app.FindRecordById("subcategories", mtb.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedMTB.GetString("name"); got != "mtb" {
t.Fatalf("existing MTB subcategory name = %q, want mtb", got)
}
if got := updatedMTB.GetString("short_name"); got != "CUSTOM" {
t.Fatalf("existing MTB short_name = %q, want CUSTOM", got)
}
defaultBikingSubcategories := 0
for _, subcategory := range defaultSubcategories {
if subcategory.parentCategory == "Biking" {
defaultBikingSubcategories++
}
}
if len(records) != defaultBikingSubcategories {
t.Fatalf("seeded Biking subcategory count = %d, want %d", len(records), defaultBikingSubcategories)
}
}
func TestResolveCategoryAndSubcategoryByNormalizedNames(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
mtb := createTestSubcategory(t, app, biking.Id, "E-Bike", "EBIKE")
hiking := createTestCategory(t, app, "Hiking")
createTestSubcategory(t, app, hiking.Id, "E-Bike", "EBIKE")
category, subcategory, err := ResolveCategoryAndSubcategoryByNormalizedNames(app, " biking ", "e bike")
if err != nil {
t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() error = %v", err)
}
if category == nil || category.Id != biking.Id {
t.Fatalf("category = %#v, want Biking", category)
}
if subcategory == nil || subcategory.Id != mtb.Id {
t.Fatalf("subcategory = %#v, want Biking/E-Bike", subcategory)
}
category, subcategory, err = ResolveCategoryAndSubcategoryByNormalizedNames(app, "Biking", "Bikepacking")
if err != nil {
t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() unknown subcategory error = %v", err)
}
if category == nil || category.Id != biking.Id {
t.Fatalf("unknown subcategory category = %#v, want Biking", category)
}
if subcategory != nil {
t.Fatalf("unknown subcategory = %#v, want nil", subcategory)
}
category, subcategory, err = ResolveCategoryAndSubcategoryByNormalizedNames(app, "Skydiving", "Wingsuit")
if err != nil {
t.Fatalf("ResolveCategoryAndSubcategoryByNormalizedNames() unknown category error = %v", err)
}
if category != nil || subcategory != nil {
t.Fatalf("unknown category resolved category=%#v subcategory=%#v, want nil/nil", category, subcategory)
}
}
func TestBackfillRemoteTrailCategoryBackfillsTargetCategoryOnly(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
gravel := createTestSubcategory(t, app, biking.Id, "Gravel", "GRVL")
matchingTrail := createTestTrail(t, app, "", "", "biking", "gravel")
otherTrail := createTestTrail(t, app, "", "", "Hiking", "gravel")
if err := BackfillRemoteTrailCategory(app, biking); err != nil {
t.Fatalf("BackfillRemoteTrailCategory() error = %v", err)
}
updatedMatchingTrail, err := app.FindRecordById("trails", matchingTrail.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedMatchingTrail.GetString("category"); got != biking.Id {
t.Fatalf("matching trail category = %q, want %q", got, biking.Id)
}
if got := updatedMatchingTrail.GetString("subcategory"); got != gravel.Id {
t.Fatalf("matching trail subcategory = %q, want %q", got, gravel.Id)
}
updatedOtherTrail, err := app.FindRecordById("trails", otherTrail.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedOtherTrail.GetString("category"); got != "" {
t.Fatalf("other trail category = %q, want empty", got)
}
if got := updatedOtherTrail.GetString("subcategory"); got != "" {
t.Fatalf("other trail subcategory = %q, want empty", got)
}
}
func TestBackfillRemoteTrailSubcategoryBackfillsWithinParentScope(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
hiking := createTestCategory(t, app, "Hiking")
bikingGravel := createTestSubcategory(t, app, biking.Id, "Gravel", "GRVL")
createTestSubcategory(t, app, hiking.Id, "Gravel", "GRVL")
existingParentTrail := createTestTrail(t, app, biking.Id, "", "Biking", "gravel")
emptyParentTrail := createTestTrail(t, app, "", "", "biking", "gravel")
wrongParentTrail := createTestTrail(t, app, hiking.Id, "", "Hiking", "gravel")
if err := BackfillRemoteTrailSubcategory(app, bikingGravel); err != nil {
t.Fatalf("BackfillRemoteTrailSubcategory() error = %v", err)
}
updatedExistingParentTrail, err := app.FindRecordById("trails", existingParentTrail.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedExistingParentTrail.GetString("category"); got != biking.Id {
t.Fatalf("existing parent trail category = %q, want %q", got, biking.Id)
}
if got := updatedExistingParentTrail.GetString("subcategory"); got != bikingGravel.Id {
t.Fatalf("existing parent trail subcategory = %q, want %q", got, bikingGravel.Id)
}
updatedEmptyParentTrail, err := app.FindRecordById("trails", emptyParentTrail.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedEmptyParentTrail.GetString("category"); got != biking.Id {
t.Fatalf("empty parent trail category = %q, want %q", got, biking.Id)
}
if got := updatedEmptyParentTrail.GetString("subcategory"); got != bikingGravel.Id {
t.Fatalf("empty parent trail subcategory = %q, want %q", got, bikingGravel.Id)
}
updatedWrongParentTrail, err := app.FindRecordById("trails", wrongParentTrail.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedWrongParentTrail.GetString("category"); got != hiking.Id {
t.Fatalf("wrong parent trail category = %q, want %q", got, hiking.Id)
}
if got := updatedWrongParentTrail.GetString("subcategory"); got != "" {
t.Fatalf("wrong parent trail subcategory = %q, want empty", got)
}
}
func TestSeedDefaultSubcategoriesRenamesLegacyDefault(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
hiking := createTestCategory(t, app, "Hiking")
winter := createTestSubcategory(t, app, hiking.Id, "Winter Hiking", "WINT")
if err := SeedDefaultSubcategories(app); err != nil {
t.Fatalf("SeedDefaultSubcategories() error = %v", err)
}
if err := SeedDefaultSubcategories(app); err != nil {
t.Fatalf("second SeedDefaultSubcategories() error = %v", err)
}
records, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category} && short_name = 'WINT'",
"",
0,
0,
map[string]any{"category": hiking.Id},
)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("Winter subcategory count = %d, want 1", len(records))
}
if got := records[0].GetString("name"); got != "Winter" {
t.Fatalf("Winter subcategory name = %q, want Winter", got)
}
if records[0].Id != winter.Id {
t.Fatalf("Winter subcategory id = %q, want %q", records[0].Id, winter.Id)
}
if got := records[0].GetString("badge_icon"); got != "snowflake" {
t.Fatalf("Winter badge_icon = %q, want snowflake", got)
}
}
func TestSeedDefaultSubcategoriesDoesNotOverwriteBadgeIcon(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
hiking := createTestCategory(t, app, "Hiking")
winter := createTestSubcategory(t, app, hiking.Id, "Winter", "WINT")
winter.Set("badge_icon", "custom")
if err := app.Save(winter); err != nil {
t.Fatal(err)
}
if err := SeedDefaultSubcategories(app); err != nil {
t.Fatalf("SeedDefaultSubcategories() error = %v", err)
}
updatedWinter, err := app.FindRecordById("subcategories", winter.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedWinter.GetString("badge_icon"); got != "custom" {
t.Fatalf("Winter badge_icon = %q, want custom", got)
}
}
func TestSeedDefaultSubcategoriesDoesNotRenameAliasWhenCanonicalExists(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
hiking := createTestCategory(t, app, "Hiking")
winter := createTestSubcategory(t, app, hiking.Id, "Winter", "WINT")
legacy := createTestSubcategory(t, app, hiking.Id, "Winter Hiking", "WHIKE")
if err := SeedDefaultSubcategories(app); err != nil {
t.Fatalf("SeedDefaultSubcategories() error = %v", err)
}
updatedWinter, err := app.FindRecordById("subcategories", winter.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedWinter.GetString("name"); got != "Winter" {
t.Fatalf("canonical subcategory name = %q, want Winter", got)
}
updatedLegacy, err := app.FindRecordById("subcategories", legacy.Id)
if err != nil {
t.Fatal(err)
}
if got := updatedLegacy.GetString("name"); got != "Winter Hiking" {
t.Fatalf("legacy subcategory name = %q, want Winter Hiking", got)
}
records, err := app.FindRecordsByFilter(
"subcategories",
"category = {:category} && name = 'Winter'",
"",
0,
0,
map[string]any{"category": hiking.Id},
)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("canonical Winter subcategory count = %d, want 1", len(records))
}
}
func TestValidateUserCategoryPreferenceRequest(t *testing.T) {
tests := []struct {
name string
priorityExplicit bool
wantError bool
}{
{
name: "allows requests without priority",
},
{
name: "rejects explicit priority",
priorityExplicit: true,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUserCategoryPreferenceRequest(tt.priorityExplicit)
if (err != nil) != tt.wantError {
t.Fatalf("ValidateUserCategoryPreferenceRequest() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestValidateUserSubcategoryPreferenceRequest(t *testing.T) {
tests := []struct {
name string
priorityExplicit bool
wantError bool
}{
{
name: "allows requests without priority",
},
{
name: "rejects explicit priority",
priorityExplicit: true,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUserSubcategoryPreferenceRequest(tt.priorityExplicit)
if (err != nil) != tt.wantError {
t.Fatalf("ValidateUserSubcategoryPreferenceRequest() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestReorderUserCategoryPreferencesValidation(t *testing.T) {
tests := []struct {
name string
userID string
categoryIDs func(categories []*core.Record) []string
wantError bool
}{
{
name: "requires authenticated user",
userID: "",
categoryIDs: func(categories []*core.Record) []string {
return []string{categories[0].Id, categories[1].Id}
},
wantError: true,
},
{
name: "rejects incomplete list",
userID: "user12345678901",
categoryIDs: func(categories []*core.Record) []string {
return []string{categories[0].Id}
},
wantError: true,
},
{
name: "rejects duplicate category",
userID: "user12345678901",
categoryIDs: func(categories []*core.Record) []string {
return []string{categories[0].Id, categories[0].Id}
},
wantError: true,
},
{
name: "rejects unknown category",
userID: "user12345678901",
categoryIDs: func(categories []*core.Record) []string {
return []string{categories[0].Id, "unknown12345678"}
},
wantError: true,
},
{
name: "allows complete category list",
userID: "user12345678901",
categoryIDs: func(categories []*core.Record) []string {
return []string{categories[1].Id, categories[0].Id}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
categories := []*core.Record{
createTestCategory(t, app, "Biking"),
createTestCategory(t, app, "Hiking"),
}
err := ReorderUserCategoryPreferences(app, tt.userID, tt.categoryIDs(categories))
if (err != nil) != tt.wantError {
t.Fatalf("ReorderUserCategoryPreferences() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestReorderUserSubcategoryPreferencesValidation(t *testing.T) {
tests := []struct {
name string
userID string
categoryID func(categories []*core.Record) string
subcategoryIDs func(subcategories []*core.Record) []string
wantError bool
}{
{
name: "requires authenticated user",
categoryID: func(categories []*core.Record) string {
return categories[0].Id
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[0].Id, subcategories[1].Id}
},
wantError: true,
},
{
name: "requires category",
userID: "user12345678901",
categoryID: func(categories []*core.Record) string {
return ""
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[0].Id, subcategories[1].Id}
},
wantError: true,
},
{
name: "rejects incomplete list",
userID: "user12345678901",
categoryID: func(categories []*core.Record) string {
return categories[0].Id
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[0].Id}
},
wantError: true,
},
{
name: "rejects duplicate subcategory",
userID: "user12345678901",
categoryID: func(categories []*core.Record) string {
return categories[0].Id
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[0].Id, subcategories[0].Id}
},
wantError: true,
},
{
name: "rejects subcategory from another category",
userID: "user12345678901",
categoryID: func(categories []*core.Record) string {
return categories[0].Id
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[0].Id, subcategories[2].Id}
},
wantError: true,
},
{
name: "allows complete subcategory list",
userID: "user12345678901",
categoryID: func(categories []*core.Record) string {
return categories[0].Id
},
subcategoryIDs: func(subcategories []*core.Record) []string {
return []string{subcategories[1].Id, subcategories[0].Id}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
categories := []*core.Record{
createTestCategory(t, app, "Hiking"),
createTestCategory(t, app, "Biking"),
}
subcategories := []*core.Record{
createTestSubcategory(t, app, categories[0].Id, "Mountain Hiking", "", ""),
createTestSubcategory(t, app, categories[0].Id, "Family Hiking", "", ""),
createTestSubcategory(t, app, categories[1].Id, "E-Bike", "", ""),
}
err := ReorderUserSubcategoryPreferences(app, tt.userID, tt.categoryID(categories), tt.subcategoryIDs(subcategories))
if (err != nil) != tt.wantError {
t.Fatalf("ReorderUserSubcategoryPreferences() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestEnsureUserCategoryPriorityDefaultsToHiking(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
createTestCategory(t, app, "Canoeing")
hiking := createTestCategory(t, app, "Hiking")
userID := "user12345678901"
if err := EnsureUserCategoryPriority(app, userID, ""); err != nil {
t.Fatalf("EnsureUserCategoryPriority() error = %v", err)
}
records, err := app.FindRecordsByFilter(
"user_category_preferences",
"user = {:user}",
"",
0,
0,
map[string]any{"user": userID},
)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 {
t.Fatalf("preference count = %d, want 1", len(records))
}
record := records[0]
if got := record.GetString("category"); got != hiking.Id {
t.Fatalf("category = %q, want Hiking id %q", got, hiking.Id)
}
if got := record.GetInt("priority"); got != 1 {
t.Fatalf("priority = %d, want 1", got)
}
if !record.GetBool("visible") {
t.Fatal("visible = false, want true")
}
}
func TestReorderUserCategoryPreferencesUpsertsAndRenumbers(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
biking := createTestCategory(t, app, "Biking")
hiking := createTestCategory(t, app, "Hiking")
walking := createTestCategory(t, app, "Walking")
userID := "user12345678901"
existing := core.NewRecord(mustFindTestCollection(t, app, "user_category_preferences"))
existing.Set("user", userID)
existing.Set("category", biking.Id)
existing.Set("visible", false)
existing.Set("priority", 99)
if err := app.SaveNoValidate(existing); err != nil {
t.Fatal(err)
}
if err := ReorderUserCategoryPreferences(app, userID, []string{walking.Id, biking.Id, hiking.Id}); err != nil {
t.Fatalf("ReorderUserCategoryPreferences() error = %v", err)
}
records, err := app.FindRecordsByFilter(
"user_category_preferences",
"user = {:user}",
"priority",
0,
0,
map[string]any{"user": userID},
)
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("preference count = %d, want 3", len(records))
}
wantPriorityByCategory := map[string]int{
walking.Id: 1,
biking.Id: 2,
hiking.Id: 3,
}
for _, record := range records {
categoryID := record.GetString("category")
if got := record.GetInt("priority"); got != wantPriorityByCategory[categoryID] {
t.Fatalf("priority for category %q = %d, want %d", categoryID, got, wantPriorityByCategory[categoryID])
}
if categoryID == biking.Id {
if record.Id != existing.Id {
t.Fatalf("biking preference id = %q, want existing id %q", record.Id, existing.Id)
}
if record.GetBool("visible") {
t.Fatal("existing preference visible = true, want false")
}
} else {
if !record.GetBool("visible") {
t.Fatalf("new preference for category %q visible = false, want true", categoryID)
}
}
}
}
func TestReorderUserSubcategoryPreferencesUpsertsAndRenumbers(t *testing.T) {
app := setupCategoryValidationTestApp(t)
defer app.Cleanup()
hiking := createTestCategory(t, app, "Hiking")
biking := createTestCategory(t, app, "Biking")
mountain := createTestSubcategory(t, app, hiking.Id, "Mountain Hiking", "", "")
family := createTestSubcategory(t, app, hiking.Id, "Family Hiking", "", "")
ebike := createTestSubcategory(t, app, biking.Id, "E-Bike", "", "")
userID := "user12345678901"
existing := core.NewRecord(mustFindTestCollection(t, app, "user_subcategory_preferences"))
existing.Set("user", userID)
existing.Set("subcategory", family.Id)
existing.Set("visible", false)
existing.Set("priority", 99)
if err := app.SaveNoValidate(existing); err != nil {
t.Fatal(err)
}
otherCategoryPreference := core.NewRecord(mustFindTestCollection(t, app, "user_subcategory_preferences"))
otherCategoryPreference.Set("user", userID)
otherCategoryPreference.Set("subcategory", ebike.Id)
otherCategoryPreference.Set("visible", true)
otherCategoryPreference.Set("priority", 12)
if err := app.SaveNoValidate(otherCategoryPreference); err != nil {
t.Fatal(err)
}
if err := ReorderUserSubcategoryPreferences(app, userID, hiking.Id, []string{family.Id, mountain.Id}); err != nil {
t.Fatalf("ReorderUserSubcategoryPreferences() error = %v", err)
}
records, err := app.FindRecordsByFilter(
"user_subcategory_preferences",
"user = {:user}",
"priority",
0,
0,
map[string]any{"user": userID},
)
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("preference count = %d, want 3", len(records))
}
wantPriorityBySubcategory := map[string]int{
family.Id: 1,
mountain.Id: 2,
ebike.Id: 12,
}
for _, record := range records {
subcategoryID := record.GetString("subcategory")
if got := record.GetInt("priority"); got != wantPriorityBySubcategory[subcategoryID] {
t.Fatalf("priority for subcategory %q = %d, want %d", subcategoryID, got, wantPriorityBySubcategory[subcategoryID])
}
if subcategoryID == family.Id {
if record.Id != existing.Id {
t.Fatalf("family preference id = %q, want existing id %q", record.Id, existing.Id)
}
if record.GetBool("visible") {
t.Fatal("existing preference visible = true, want false")
}
}
if subcategoryID == mountain.Id && !record.GetBool("visible") {
t.Fatal("new preference visible = false, want true")
}
if subcategoryID == ebike.Id && record.Id != otherCategoryPreference.Id {
t.Fatalf("other category preference id = %q, want existing id %q", record.Id, otherCategoryPreference.Id)
}
}
}
func setupCategoryValidationTestApp(t *testing.T) *pbtests.TestApp {
t.Helper()
app, err := pbtests.NewTestApp(t.TempDir())
if err != nil {
t.Fatal(err)
}
categories := core.NewBaseCollection("categories")
categories.Fields.Add(
&core.TextField{Name: "name", Required: true},
&core.TextField{Name: "short_name"},
&core.TextField{Name: "icon"},
&core.JSONField{Name: "translations"},
)
if err := app.Save(categories); err != nil {
app.Cleanup()
t.Fatal(err)
}
subcategories := core.NewBaseCollection("subcategories")
subcategories.Fields.Add(
&core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1, Required: true},
&core.TextField{Name: "name", Required: true},
&core.TextField{Name: "short_name"},
&core.TextField{Name: "icon"},
&core.TextField{Name: "badge_icon"},
&core.JSONField{Name: "translations"},
)
if err := app.Save(subcategories); err != nil {
app.Cleanup()
t.Fatal(err)
}
trails := core.NewBaseCollection("trails")
trails.Fields.Add(
&core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1},
&core.RelationField{Name: "subcategory", CollectionId: subcategories.Id, MaxSelect: 1},
&core.TextField{Name: "federated_category_name"},
&core.TextField{Name: "federated_subcategory_name"},
)
if err := app.Save(trails); err != nil {
app.Cleanup()
t.Fatal(err)
}
preferences := core.NewBaseCollection("user_category_preferences")
preferences.Fields.Add(
&core.RelationField{Name: "user", CollectionId: "_pb_users_auth_", MaxSelect: 1, Required: true},
&core.RelationField{Name: "category", CollectionId: categories.Id, MaxSelect: 1, Required: true},
&core.BoolField{Name: "visible"},
&core.NumberField{Name: "priority", OnlyInt: true},
)
if err := app.Save(preferences); err != nil {
app.Cleanup()
t.Fatal(err)
}
subcategoryPreferences := core.NewBaseCollection("user_subcategory_preferences")
subcategoryPreferences.Fields.Add(
&core.RelationField{Name: "user", CollectionId: "_pb_users_auth_", MaxSelect: 1, Required: true},
&core.RelationField{Name: "subcategory", CollectionId: subcategories.Id, MaxSelect: 1, Required: true},
&core.BoolField{Name: "visible"},
&core.NumberField{Name: "priority", OnlyInt: true},
)
if err := app.Save(subcategoryPreferences); err != nil {
app.Cleanup()
t.Fatal(err)
}
return app
}
func mustFindTestCollection(t *testing.T, app core.App, name string) *core.Collection {
t.Helper()
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
return collection
}
func createTestCategory(t *testing.T, app core.App, name string) *core.Record {
t.Helper()
record := core.NewRecord(mustFindTestCollection(t, app, "categories"))
record.Set("name", name)
if err := app.Save(record); err != nil {
t.Fatal(err)
}
return record
}
func createTestSubcategory(t *testing.T, app core.App, categoryID string, name string, shortName string, badgeIcon ...string) *core.Record {
t.Helper()
record := core.NewRecord(mustFindTestCollection(t, app, "subcategories"))
record.Set("category", categoryID)
record.Set("name", name)
record.Set("short_name", shortName)
if len(badgeIcon) > 0 {
record.Set("badge_icon", badgeIcon[0])
}
if err := app.Save(record); err != nil {
t.Fatal(err)
}
return record
}
func createTestTrail(t *testing.T, app core.App, categoryID string, subcategoryID string, federatedCategoryName string, federatedSubcategoryName string) *core.Record {
t.Helper()
record := core.NewRecord(mustFindTestCollection(t, app, "trails"))
record.Set("category", categoryID)
record.Set("subcategory", subcategoryID)
record.Set("federated_category_name", federatedCategoryName)
record.Set("federated_subcategory_name", federatedSubcategoryName)
if err := app.Save(record); err != nil {
t.Fatal(err)
}
return record
}