diff --git a/database/attrs.go b/database/attrs.go index 2b9358f8..20f85e30 100644 --- a/database/attrs.go +++ b/database/attrs.go @@ -21,6 +21,7 @@ type CategoriesAttrs struct { Slug string Name string Description string + Sort int } type TagAttrs struct { diff --git a/database/infra/migrations/000004_categories_sort.down.sql b/database/infra/migrations/000004_categories_sort.down.sql new file mode 100644 index 00000000..cae1051e --- /dev/null +++ b/database/infra/migrations/000004_categories_sort.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_categories_sort; +ALTER TABLE categories + DROP COLUMN IF EXISTS sort; diff --git a/database/infra/migrations/000004_categories_sort.up.sql b/database/infra/migrations/000004_categories_sort.up.sql new file mode 100644 index 00000000..85a2d639 --- /dev/null +++ b/database/infra/migrations/000004_categories_sort.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS sort INT NOT NULL DEFAULT 0; + +ALTER TABLE categories + ALTER COLUMN sort DROP DEFAULT; + +CREATE INDEX IF NOT EXISTS idx_categories_sort ON categories (sort, name); diff --git a/database/model.go b/database/model.go index 8945e16b..f5413852 100644 --- a/database/model.go +++ b/database/model.go @@ -109,6 +109,7 @@ type Category struct { Name string `gorm:"type:varchar(255);unique;not null"` Slug string `gorm:"type:varchar(255);unique;not null"` Description string `gorm:"type:text"` + Sort int `gorm:"type:int;not null"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` DeletedAt gorm.DeletedAt diff --git a/database/repository/categories.go b/database/repository/categories.go index b841a15d..75f0f97c 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -20,6 +20,7 @@ func (c Categories) Get() ([]database.Category, error) { err := c.DB.Sql(). Model(&database.Category{}). Where("categories.deleted_at is null"). + Order("categories.sort asc, categories.name asc"). Find(&categories).Error if err != nil { @@ -53,7 +54,7 @@ func (c Categories) GetAll(paginate pagination.Paginate) (*pagination.Pagination Preload("Posts", "posts.deleted_at IS NULL AND posts.published_at IS NOT NULL"). Offset(offset). Limit(paginate.Limit). - Order("categories.name asc"). + Order("categories.sort asc, categories.name asc"). Group(group). Find(&categories).Error @@ -104,6 +105,7 @@ func (c Categories) CreateOrUpdate(post database.Post, attrs database.PostsAttrs Name: seed.Name, Slug: seed.Slug, Description: seed.Description, + Sort: seed.Sort, } if result := c.DB.Sql().Create(&category); model.HasDbIssues(result.Error) { diff --git a/database/repository/categories_postgres_test.go b/database/repository/categories_postgres_test.go index fa4e70af..a2185d8b 100644 --- a/database/repository/categories_postgres_test.go +++ b/database/repository/categories_postgres_test.go @@ -3,6 +3,7 @@ package repository_test import ( "testing" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/database/repository" ) @@ -10,7 +11,7 @@ import ( func TestCategoriesFindByPostgres(t *testing.T) { conn := newPostgresConnection(t, &database.Category{}) - category := seedCategory(t, conn, "news", "News") + category := seedCategory(t, conn, "news", "News", 1) repo := repository.Categories{DB: conn} @@ -22,3 +23,171 @@ func TestCategoriesFindByPostgres(t *testing.T) { t.Fatalf("expected missing category lookup to return nil") } } + +func TestCategoriesGetOrdersBySort(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + low := database.Category{ + UUID: uuid.NewString(), + Name: "Low", + Slug: "low", + Sort: 10, + } + + high := database.Category{ + UUID: uuid.NewString(), + Name: "High", + Slug: "high", + Sort: 20, + } + + if err := conn.Sql().Create(&high).Error; err != nil { + t.Fatalf("create high sort: %v", err) + } + + if err := conn.Sql().Create(&low).Error; err != nil { + t.Fatalf("create low sort: %v", err) + } + + items, err := repo.Get() + if err != nil { + t.Fatalf("get: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 categories, got %d", len(items)) + } + + if items[0].Slug != "low" || items[1].Slug != "high" { + t.Fatalf("expected sort ordering, got %+v", items) + } +} + +func TestCategoriesGetOrdersBySortAndName(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + alpha := database.Category{ + UUID: uuid.NewString(), + Name: "Alpha", + Slug: "alpha", + Sort: 10, + } + + bravo := database.Category{ + UUID: uuid.NewString(), + Name: "Bravo", + Slug: "bravo", + Sort: 10, + } + + if err := conn.Sql().Create(&bravo).Error; err != nil { + t.Fatalf("create bravo: %v", err) + } + + if err := conn.Sql().Create(&alpha).Error; err != nil { + t.Fatalf("create alpha: %v", err) + } + + items, err := repo.Get() + if err != nil { + t.Fatalf("get: %v", err) + } + + if len(items) != 2 { + t.Fatalf("expected 2 categories, got %d", len(items)) + } + + if items[0].Slug != "alpha" || items[1].Slug != "bravo" { + t.Fatalf("expected secondary name ordering, got %+v", items) + } +} + +func TestCategoriesExistOrUpdatePreservesSortWhenZero(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + original := database.Category{ + UUID: uuid.NewString(), + Name: "Original", + Slug: "original", + Sort: 25, + } + + if err := conn.Sql().Create(&original).Error; err != nil { + t.Fatalf("create original: %v", err) + } + + existed, err := repo.ExistOrUpdate(database.CategoriesAttrs{ + Slug: "original", + Name: "Renamed", + Sort: 0, + }) + if err != nil { + t.Fatalf("exist or update: %v", err) + } + + if !existed { + t.Fatalf("expected category to exist") + } + + var updated database.Category + if err := conn.Sql().Where("id = ?", original.ID).First(&updated).Error; err != nil { + t.Fatalf("reload category: %v", err) + } + + if updated.Sort != original.Sort { + t.Fatalf("expected sort to remain %d, got %d", original.Sort, updated.Sort) + } + + if updated.Name != "Renamed" { + t.Fatalf("expected name to be updated, got %s", updated.Name) + } +} + +func TestCategoriesExistOrUpdateUpdatesSortWhenNonZero(t *testing.T) { + conn := newPostgresConnection(t, &database.Category{}) + + repo := repository.Categories{DB: conn} + + original := database.Category{ + UUID: uuid.NewString(), + Name: "Original", + Slug: "original", + Sort: 25, + } + + if err := conn.Sql().Create(&original).Error; err != nil { + t.Fatalf("create original: %v", err) + } + + existed, err := repo.ExistOrUpdate(database.CategoriesAttrs{ + Slug: "original", + Name: "Renamed", + Sort: 30, + }) + if err != nil { + t.Fatalf("exist or update: %v", err) + } + + if !existed { + t.Fatalf("expected category to exist") + } + + var updated database.Category + if err := conn.Sql().Where("id = ?", original.ID).First(&updated).Error; err != nil { + t.Fatalf("reload category: %v", err) + } + + if updated.Sort != 30 { + t.Fatalf("expected sort to be updated to 30, got %d", updated.Sort) + } + + if updated.Name != "Renamed" { + t.Fatalf("expected name to be updated, got %s", updated.Name) + } +} diff --git a/database/repository/posts_postgres_test.go b/database/repository/posts_postgres_test.go index 5a688025..60ddda07 100644 --- a/database/repository/posts_postgres_test.go +++ b/database/repository/posts_postgres_test.go @@ -21,7 +21,7 @@ func TestPostsCreateLinksAssociationsPostgres(t *testing.T) { ) user := seedUser(t, conn, "Alice", "Smith", "alice") - category := seedCategory(t, conn, "tech", "Tech") + category := seedCategory(t, conn, "tech", "Tech", 1) tag := seedTag(t, conn, "go", "Go") postsRepo := repository.Posts{ @@ -89,7 +89,7 @@ func TestPostsFindByLoadsAssociationsPostgres(t *testing.T) { ) user := seedUser(t, conn, "Bob", "Jones", "bobj") - category := seedCategory(t, conn, "career", "Career") + category := seedCategory(t, conn, "career", "Career", 1) tag := seedTag(t, conn, "work", "Work") post := seedPost(t, conn, user, category, tag, "career-path", "Career Path", true) @@ -138,7 +138,7 @@ func TestPostsGetAllFiltersPublishedRecordsPostgres(t *testing.T) { authorOne := seedUser(t, conn, "Carol", "One", "carol") authorTwo := seedUser(t, conn, "Dave", "Two", "dave") - category := seedCategory(t, conn, "engineering", "Engineering") + category := seedCategory(t, conn, "engineering", "Engineering", 1) tag := seedTag(t, conn, "backend", "Backend") otherTag := seedTag(t, conn, "frontend", "Frontend") @@ -196,8 +196,8 @@ func TestPostsGetAllDeduplicatesResultsPostgres(t *testing.T) { author := seedUser(t, conn, "Eve", "Duplicates", "eve") - primaryCategory := seedCategory(t, conn, "engineering", "Engineering") - secondaryCategory := seedCategory(t, conn, "engagement", "Engagement") + primaryCategory := seedCategory(t, conn, "engineering", "Engineering", 1) + secondaryCategory := seedCategory(t, conn, "engagement", "Engagement", 2) primaryTag := seedTag(t, conn, "eng-backend", "Eng Backend") secondaryTag := seedTag(t, conn, "eng-frontend", "Eng Frontend") @@ -240,7 +240,7 @@ func TestPostsGetAllDeduplicatesResultsPostgres(t *testing.T) { func TestPostsFindCategoryByDelegatesPostgres(t *testing.T) { conn := newPostgresConnection(t, &database.Category{}) - category := seedCategory(t, conn, "lifestyle", "Lifestyle") + category := seedCategory(t, conn, "lifestyle", "Lifestyle", 1) postsRepo := repository.Posts{ DB: conn, diff --git a/database/repository/repository_test.go b/database/repository/repository_test.go index 9c4ace4b..0429fafb 100644 --- a/database/repository/repository_test.go +++ b/database/repository/repository_test.go @@ -149,6 +149,7 @@ func TestCategoriesFindBy(t *testing.T) { UUID: uuid.NewString(), Name: "News", Slug: "news", + Sort: 1, } if err := conn.Sql().Create(&c).Error; err != nil { @@ -192,6 +193,7 @@ func TestPostsCreateAndFind(t *testing.T) { UUID: uuid.NewString(), Name: "Tech", Slug: "tech", + Sort: 1, } if err := conn.Sql().Create(&cat).Error; err != nil { diff --git a/database/repository/testhelpers_test.go b/database/repository/testhelpers_test.go index 361d4c2b..24f6b5fe 100644 --- a/database/repository/testhelpers_test.go +++ b/database/repository/testhelpers_test.go @@ -106,13 +106,14 @@ func seedUser(t *testing.T, conn *database.Connection, first, last, username str return user } -func seedCategory(t *testing.T, conn *database.Connection, slug, name string) database.Category { +func seedCategory(t *testing.T, conn *database.Connection, slug, name string, sort int) database.Category { t.Helper() category := database.Category{ UUID: uuid.NewString(), Slug: slug, Name: name, + Sort: sort, } if err := conn.Sql().Create(&category).Error; err != nil { diff --git a/database/seeder/importer/runner_test.go b/database/seeder/importer/runner_test.go index e0d28cec..c91286d0 100644 --- a/database/seeder/importer/runner_test.go +++ b/database/seeder/importer/runner_test.go @@ -500,8 +500,8 @@ func TestSeedFromFileRunsMigrations(t *testing.T) { t.Cleanup(cleanup) contents := strings.Join([]string{ - "INSERT INTO categories (uuid, name, slug)", - "VALUES ('00000000-0000-0000-0000-00000000c001', 'Tech', 'tech');", + "INSERT INTO categories (uuid, name, slug, sort)", + "VALUES ('00000000-0000-0000-0000-00000000c001', 'Tech', 'tech', 1);", }, "\n") fileName := writeStorageFile(t, withSuffix(t, ".sql"), contents) diff --git a/database/seeder/seeds/categories.go b/database/seeder/seeds/categories.go index cf4dc63d..1ce3615e 100644 --- a/database/seeder/seeds/categories.go +++ b/database/seeder/seeds/categories.go @@ -26,12 +26,14 @@ func (s CategoriesSeed) Create(attrs database.CategoriesAttrs) ([]database.Categ "Cloud", "Data", "DevOps", "ML", "Startups", "Engineering", } - for _, seed := range seeds { + for index, seed := range seeds { + sort := index + 1 categories = append(categories, database.Category{ UUID: uuid.NewString(), Name: seed, Slug: strings.ToLower(seed), Description: attrs.Description, + Sort: sort, }) } diff --git a/handler/categories.go b/handler/categories.go index 8e636405..81737c64 100644 --- a/handler/categories.go +++ b/handler/categories.go @@ -41,6 +41,7 @@ func (h *CategoriesHandler) Index(w http.ResponseWriter, r *http.Request) *endpo Name: s.Name, Slug: s.Slug, Description: s.Description, + Sort: s.Sort, } }, ) diff --git a/handler/categories_test.go b/handler/categories_test.go index 6e5e898f..3f168306 100644 --- a/handler/categories_test.go +++ b/handler/categories_test.go @@ -34,24 +34,46 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("create post: %v", err) } - cat := database.Category{ + catB := database.Category{ UUID: uuid.NewString(), - Name: "Cat", - Slug: "cat", + Name: "Beta", + Slug: "beta", Description: "desc", + Sort: 10, } - if err := conn.Sql().Create(&cat).Error; err != nil { - t.Fatalf("create category: %v", err) + if err := conn.Sql().Create(&catB).Error; err != nil { + t.Fatalf("create category beta: %v", err) } - link := database.PostCategory{ + catA := database.Category{ + UUID: uuid.NewString(), + Name: "Alpha", + Slug: "alpha", + Description: "desc", + Sort: 10, + } + + if err := conn.Sql().Create(&catA).Error; err != nil { + t.Fatalf("create category alpha: %v", err) + } + + linkBeta := database.PostCategory{ + PostID: post.ID, + CategoryID: catB.ID, + } + + if err := conn.Sql().Create(&linkBeta).Error; err != nil { + t.Fatalf("create beta link: %v", err) + } + + linkAlpha := database.PostCategory{ PostID: post.ID, - CategoryID: cat.ID, + CategoryID: catA.ID, } - if err := conn.Sql().Create(&link).Error; err != nil { - t.Fatalf("create link: %v", err) + if err := conn.Sql().Create(&linkAlpha).Error; err != nil { + t.Fatalf("create alpha link: %v", err) } h := NewCategoriesHandler(&repository.Categories{ @@ -75,7 +97,96 @@ func TestCategoriesHandlerIndex_Success(t *testing.T) { t.Fatalf("decode: %v", err) } - if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" { - t.Fatalf("unexpected data: %+v", resp.Data) + if len(resp.Data) != 2 { + t.Fatalf("unexpected data length: %+v", resp.Data) + } + + if resp.Data[0].Name != "Alpha" || resp.Data[0].Slug != "alpha" || resp.Data[0].Sort != 10 { + t.Fatalf("unexpected first category: %+v", resp.Data[0]) + } + + if resp.Data[1].Name != "Beta" || resp.Data[1].Slug != "beta" || resp.Data[1].Sort != 10 { + t.Fatalf("unexpected second category: %+v", resp.Data[1]) + } +} + +func TestCategoriesHandlerIndex_SortOrdering(t *testing.T) { + conn, author := handlertests.NewTestDB(t) + + published := time.Now() + + post := database.Post{ + UUID: uuid.NewString(), + AuthorID: author.ID, + Slug: "hello", + Title: "Hello", + Excerpt: "Ex", + Content: "Body", + PublishedAt: &published, + } + + if err := conn.Sql().Create(&post).Error; err != nil { + t.Fatalf("create post: %v", err) + } + + catLow := database.Category{ + UUID: uuid.NewString(), + Name: "Zebra", + Slug: "zebra", + Description: "desc", + Sort: 5, + } + + if err := conn.Sql().Create(&catLow).Error; err != nil { + t.Fatalf("create low sort category: %v", err) + } + + catHigh := database.Category{ + UUID: uuid.NewString(), + Name: "Apple", + Slug: "apple", + Description: "desc", + Sort: 20, + } + + if err := conn.Sql().Create(&catHigh).Error; err != nil { + t.Fatalf("create high sort category: %v", err) + } + + for _, catID := range []uint64{catLow.ID, catHigh.ID} { + link := database.PostCategory{PostID: post.ID, CategoryID: catID} + if err := conn.Sql().Create(&link).Error; err != nil { + t.Fatalf("create link: %v", err) + } + } + + h := NewCategoriesHandler(&repository.Categories{DB: conn}) + req := httptest.NewRequest("GET", "/categories", nil) + rec := httptest.NewRecorder() + + if err := h.Index(rec, req); err != nil { + t.Fatalf("index err: %v", err) + } + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp pagination.Pagination[payload.CategoryResponse] + + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + if len(resp.Data) != 2 { + t.Fatalf("unexpected data length: %+v", resp.Data) + } + + if resp.Data[0].Name != "Zebra" || resp.Data[0].Sort != 5 { + t.Fatalf("unexpected first category: %+v", resp.Data[0]) + } + + if resp.Data[1].Name != "Apple" || resp.Data[1].Sort != 20 { + t.Fatalf("unexpected second category: %+v", resp.Data[1]) } } diff --git a/handler/payload/categories.go b/handler/payload/categories.go index fedb7380..c9e57c4f 100644 --- a/handler/payload/categories.go +++ b/handler/payload/categories.go @@ -7,6 +7,7 @@ type CategoryResponse struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` + Sort int `json:"sort"` } func GetCategoriesResponse(categories []database.Category) []CategoryResponse { @@ -18,6 +19,7 @@ func GetCategoriesResponse(categories []database.Category) []CategoryResponse { Name: category.Name, Slug: category.Slug, Description: category.Description, + Sort: category.Sort, }) } diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go index 25fc4e6c..964c8872 100644 --- a/handler/payload/categories_test.go +++ b/handler/payload/categories_test.go @@ -13,12 +13,13 @@ func TestGetCategoriesResponse(t *testing.T) { Name: "n", Slug: "s", Description: "d", + Sort: 2, }, } r := GetCategoriesResponse(cats) - if len(r) != 1 || r[0].Slug != "s" { + if len(r) != 1 || r[0].Slug != "s" || r[0].Sort != 2 { t.Fatalf("unexpected %#v", r) } } diff --git a/handler/payload/posts_response_test.go b/handler/payload/posts_response_test.go index 1ca1e4f8..94f196cd 100644 --- a/handler/payload/posts_response_test.go +++ b/handler/payload/posts_response_test.go @@ -25,6 +25,7 @@ func TestGetPostsResponse(t *testing.T) { Name: "cn", Slug: "cs", Description: "cd", + Sort: 3, }, }, Tags: []database.Tag{ @@ -50,7 +51,7 @@ func TestGetPostsResponse(t *testing.T) { r := GetPostsResponse(p) - if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 { + if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 || r.Categories[0].Sort != 3 { t.Fatalf("unexpected response: %+v", r) } } diff --git a/metal/cli/posts/handler_test.go b/metal/cli/posts/handler_test.go index 00991d73..bd1961ef 100644 --- a/metal/cli/posts/handler_test.go +++ b/metal/cli/posts/handler_test.go @@ -46,6 +46,7 @@ func setupPostsHandler(t *testing.T) (*Handler, *database.Connection) { UUID: uuid.NewString(), Name: "Tech", Slug: "tech", + Sort: 1, }) conn.Sql().Create(&database.Tag{ UUID: uuid.NewString(), diff --git a/metal/cli/seo/testhelpers_test.go b/metal/cli/seo/testhelpers_test.go index 79a421d3..dd054a10 100644 --- a/metal/cli/seo/testhelpers_test.go +++ b/metal/cli/seo/testhelpers_test.go @@ -131,6 +131,7 @@ func seedCategory(t *testing.T, conn *database.Connection, slug, name string) da UUID: uuid.NewString(), Slug: slug, Name: name, + Sort: 1, } if err := conn.Sql().Create(&category).Error; err != nil {