fix: Code for category

This commit is contained in:
2026-06-27 19:35:53 +05:00
parent 46add88fd5
commit 148d1fb841
14 changed files with 127 additions and 64 deletions

View File

@@ -25,6 +25,7 @@ type CategoryRepository interface {
// CategoryInput carries the mutable fields of a category.
type CategoryInput struct {
ParentID *uuid.UUID
Code string
Name string
Description string
}
@@ -39,13 +40,17 @@ func NewCategoryService(repo CategoryRepository) *CategoryService {
return &CategoryService{repo: repo}
}
// Create validates the parent (if any) and stores a new category.
// Create validates the code and parent (if any) and stores a new category.
func (s *CategoryService) Create(ctx context.Context, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if err := s.ensureParentExists(ctx, in.ParentID); err != nil {
return domain.Category{}, err
}
return s.repo.Create(ctx, domain.Category{
ParentID: in.ParentID,
Code: in.Code,
Name: in.Name,
Description: in.Description,
})
@@ -61,8 +66,11 @@ func (s *CategoryService) List(ctx context.Context, parentID *uuid.UUID) ([]doma
return s.repo.List(ctx, parentID)
}
// Update validates the parent change (existence + no cycles) and stores it.
// Update validates the code and parent change (existence + no cycles) and stores it.
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if _, err := s.repo.GetByID(ctx, id); err != nil {
return domain.Category{}, err
}
@@ -75,6 +83,7 @@ func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryI
return s.repo.Update(ctx, domain.Category{
ID: id,
ParentID: in.ParentID,
Code: in.Code,
Name: in.Name,
Description: in.Description,
})

View File

@@ -54,7 +54,7 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("root category succeeds", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
got, err := svc.Create(ctx, CategoryInput{Name: "root"})
got, err := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -63,10 +63,19 @@ func TestCategoryService_Create(t *testing.T) {
}
})
t.Run("invalid code is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
for _, code := range []string{"", "Root", "with space", "-leading", "double--dash"} {
if _, err := svc.Create(ctx, CategoryInput{Code: code, Name: "x"}); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("code %q: want ErrValidation, got %v", code, err)
}
}
})
t.Run("missing parent is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
missing := uuid.New()
_, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &missing})
_, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &missing})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
@@ -75,9 +84,9 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("existing parent succeeds", func(t *testing.T) {
repo := newStubCategoryRepo()
svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID})
child, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -92,26 +101,26 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
repo := newStubCategoryRepo()
svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
child, _ := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID})
root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, _ := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
t.Run("category cannot be its own parent", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &root.ID})
_, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &root.ID})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("category cannot descend from its own child", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &child.ID})
_, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &child.ID})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("valid reparent succeeds", func(t *testing.T) {
other, _ := svc.Create(ctx, CategoryInput{Name: "other"})
updated, err := svc.Update(ctx, child.ID, CategoryInput{Name: "child", ParentID: &other.ID})
other, _ := svc.Create(ctx, CategoryInput{Code: "other", Name: "other"})
updated, err := svc.Update(ctx, child.ID, CategoryInput{Code: "child", Name: "child", ParentID: &other.ID})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -123,7 +132,7 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
func TestCategoryService_Update_MissingCategory(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Name: "x"})
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Code: "x", Name: "x"})
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}

View File

@@ -61,9 +61,11 @@ type ObjectStore interface {
Remove(ctx context.Context, key string) error
}
// categoryReader lets the dataset service verify a category exists before upload.
// categoryReader lets the dataset service verify a category exists before upload
// and resolve a category code to its id for list filtering.
type categoryReader interface {
GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error)
GetByCode(ctx context.Context, code string) (domain.Category, error)
}
// JobEnqueuer schedules asynchronous dataset jobs.
@@ -90,7 +92,6 @@ type RasterConverter interface {
// UploadInput carries everything needed to store a new dataset.
type UploadInput struct {
CategoryID uuid.UUID
Code string
Name string
Description *string
Unit *string
@@ -140,9 +141,6 @@ func NewDatasetService(
// Upload validates input, stores the object, and persists the dataset. If the
// database write fails after upload, the stored object is removed.
func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dataset, error) {
if in.Code == "" {
return domain.Dataset{}, fmt.Errorf("%w: code is required", domain.ErrValidation)
}
if !in.FileType.Valid() {
return domain.Dataset{}, fmt.Errorf("%w: unknown file_type %q", domain.ErrValidation, in.FileType)
}
@@ -193,7 +191,6 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
dataset, err := s.repo.Create(ctx, domain.Dataset{
CategoryID: in.CategoryID,
Code: in.Code,
Name: name,
Description: in.Description,
Unit: in.Unit,
@@ -588,8 +585,10 @@ func (s *DatasetService) WaitForStatus(ctx context.Context, id uuid.UUID, curren
}
// ListSummaries returns a page of dataset summaries, optionally filtered to a
// category. page is 1-based; page and pageSize are clamped to sane bounds.
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, page, pageSize int) (DatasetPage, error) {
// category by id and/or by code. page is 1-based; page and pageSize are clamped
// to sane bounds. When categoryCode is set it is resolved to its category id; an
// unknown code yields an empty page.
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, categoryCode *string, page, pageSize int) (DatasetPage, error) {
if page < 1 {
page = 1
}
@@ -600,6 +599,17 @@ func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUI
pageSize = MaxPageSize
}
if categoryCode != nil {
category, err := s.categories.GetByCode(ctx, *categoryCode)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return DatasetPage{Items: []domain.DatasetSummary{}, Page: page, PageSize: pageSize}, nil
}
return DatasetPage{}, err
}
categoryID = &category.ID
}
items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize)
if err != nil {
return DatasetPage{}, err

View File

@@ -254,11 +254,17 @@ func (s stubCategoryReader) GetByID(_ context.Context, id uuid.UUID) (domain.Cat
return domain.Category{ID: id}, nil
}
func (s stubCategoryReader) GetByCode(_ context.Context, code string) (domain.Category, error) {
if !s.exists {
return domain.Category{}, domain.ErrNotFound
}
return domain.Category{ID: uuid.New(), Code: code}, nil
}
func validUpload() UploadInput {
body := `{"type":"FeatureCollection","features":[]}`
return UploadInput{
CategoryID: uuid.New(),
Code: "POP",
Name: "Population",
Filename: "data.geojson",
FileType: domain.FileTypeVector,
@@ -278,7 +284,6 @@ func TestDatasetService_Upload_Validation(t *testing.T) {
name string
mutate func(*UploadInput)
}{
{"missing code", func(in *UploadInput) { in.Code = "" }},
{"invalid file type", func(in *UploadInput) { in.FileType = "bogus" }},
{"unknown extension", func(in *UploadInput) { in.Filename = "data.txt" }},
{"extension/type mismatch", func(in *UploadInput) { in.Filename = "data.tif" }}, // .tif is raster
@@ -732,7 +737,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
svc := newDatasetService(repo, &stubStore{}, true)
// page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0.
res, err := svc.ListSummaries(context.Background(), nil, 0, 10_000)
res, err := svc.ListSummaries(context.Background(), nil, nil, 0, 10_000)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -747,7 +752,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
}
// page 3, pageSize 20 -> offset 40.
if _, err := svc.ListSummaries(context.Background(), nil, 3, 20); err != nil {
if _, err := svc.ListSummaries(context.Background(), nil, nil, 3, 20); err != nil {
t.Fatal(err)
}
if repo.lastOffset != 40 || repo.lastLimit != 20 {