diff --git a/backend/internal/db/postgres/tag_repo.go b/backend/internal/db/postgres/tag_repo.go index 838b4b2..232c8ba 100644 --- a/backend/internal/db/postgres/tag_repo.go +++ b/backend/internal/db/postgres/tag_repo.go @@ -155,6 +155,13 @@ func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, catego } sortCol := tagSortColumn(params.Sort) + // When sorting by category, break ties within a category by the tag's own + // name (same direction), so tags are grouped by category then alphabetical. + secondarySort := "" + if params.Sort == "category_name" { + secondarySort = fmt.Sprintf("t.name %s, ", order) + } + args := []any{} n := 1 var conditions []string @@ -204,8 +211,8 @@ FROM data.tags t LEFT JOIN data.categories c ON c.id = t.category_id JOIN core.users u ON u.id = t.creator_id %s -ORDER BY %s %s NULLS LAST, t.id ASC -LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1) +ORDER BY %s %s NULLS LAST, %st.id ASC +LIMIT $%d OFFSET $%d`, where, sortCol, order, secondarySort, n, n+1) args = append(args, limit, offset) diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index be462c1..dcffdbf 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -807,6 +807,56 @@ func TestRecordTagUses(t *testing.T) { assert.Equal(t, 2, h.countTagUses(ctx), "pagination should not add tag_uses rows") } +// TestTagSortByCategoryThenName verifies the category_name sort groups tags by +// category and orders them by their own name within each category, with +// uncategorized tags last (NULLS LAST). +func TestTagSortByCategoryThenName(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + adminToken := h.login("admin", "admin") + + mkCategory := func(name string) string { + resp := h.doJSON("POST", "/categories", map[string]any{"name": name}, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + var c map[string]any + resp.decode(t, &c) + return c["id"].(string) + } + mkTag := func(name string, categoryID *string) { + body := map[string]any{"name": name} + if categoryID != nil { + body["category_id"] = *categoryID + } + resp := h.doJSON("POST", "/tags", body, adminToken) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + } + + alpha := mkCategory("Alpha") + bravo := mkCategory("Bravo") + + // Insert out of order to prove the sort, not insertion order, decides output. + mkTag("zebra", &alpha) + mkTag("mid", &bravo) + mkTag("solo", nil) // uncategorized + mkTag("ant", &alpha) + + resp := h.doJSON("GET", "/tags?sort=category_name&order=asc", nil, adminToken) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + var page map[string]any + resp.decode(t, &page) + + items := page["items"].([]any) + names := make([]string, len(items)) + for i, it := range items { + names[i] = it.(map[string]any)["name"].(string) + } + // Alpha (ant, zebra) → Bravo (mid) → uncategorized (solo) last. + assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names) +} + // TestBulkTagAutoRule verifies the bulk add path also applies then_tags. func TestBulkTagAutoRule(t *testing.T) { if testing.Short() {