fix: Code for category
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user