Second checkpoint
This commit is contained in:
116
internal/service/category.go
Normal file
116
internal/service/category.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package service holds the application's business logic. Services depend on
|
||||
// repository and storage interfaces (declared here) rather than concrete types,
|
||||
// and they translate between transport input and domain entities.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gis/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CategoryRepository is the persistence behaviour CategoryService needs.
|
||||
type CategoryRepository interface {
|
||||
Create(ctx context.Context, c domain.Category) (domain.Category, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error)
|
||||
List(ctx context.Context, parentID *uuid.UUID) ([]domain.Category, error)
|
||||
Update(ctx context.Context, c domain.Category) (domain.Category, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
// CategoryInput carries the mutable fields of a category.
|
||||
type CategoryInput struct {
|
||||
ParentID *uuid.UUID
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// CategoryService implements category business rules.
|
||||
type CategoryService struct {
|
||||
repo CategoryRepository
|
||||
}
|
||||
|
||||
// NewCategoryService returns a CategoryService backed by repo.
|
||||
func NewCategoryService(repo CategoryRepository) *CategoryService {
|
||||
return &CategoryService{repo: repo}
|
||||
}
|
||||
|
||||
// Create validates the parent (if any) and stores a new category.
|
||||
func (s *CategoryService) Create(ctx context.Context, in CategoryInput) (domain.Category, error) {
|
||||
if err := s.ensureParentExists(ctx, in.ParentID); err != nil {
|
||||
return domain.Category{}, err
|
||||
}
|
||||
return s.repo.Create(ctx, domain.Category{
|
||||
ParentID: in.ParentID,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a category by id.
|
||||
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (domain.Category, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// List returns categories, optionally filtered to a parent's direct children.
|
||||
func (s *CategoryService) List(ctx context.Context, parentID *uuid.UUID) ([]domain.Category, error) {
|
||||
return s.repo.List(ctx, parentID)
|
||||
}
|
||||
|
||||
// Update validates the parent change (existence + no cycles) and stores it.
|
||||
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryInput) (domain.Category, error) {
|
||||
if _, err := s.repo.GetByID(ctx, id); err != nil {
|
||||
return domain.Category{}, err
|
||||
}
|
||||
if err := s.ensureParentExists(ctx, in.ParentID); err != nil {
|
||||
return domain.Category{}, err
|
||||
}
|
||||
if err := s.ensureNoCycle(ctx, id, in.ParentID); err != nil {
|
||||
return domain.Category{}, err
|
||||
}
|
||||
return s.repo.Update(ctx, domain.Category{
|
||||
ID: id,
|
||||
ParentID: in.ParentID,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a category.
|
||||
func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *CategoryService) ensureParentExists(ctx context.Context, parentID *uuid.UUID) error {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.repo.GetByID(ctx, *parentID); err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return fmt.Errorf("%w: parent category does not exist", domain.ErrValidation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureNoCycle walks up the proposed parent's ancestry; if it reaches id, the
|
||||
// move would create a cycle.
|
||||
func (s *CategoryService) ensureNoCycle(ctx context.Context, id uuid.UUID, parentID *uuid.UUID) error {
|
||||
cursor := parentID
|
||||
for cursor != nil {
|
||||
if *cursor == id {
|
||||
return fmt.Errorf("%w: category cannot be its own ancestor", domain.ErrValidation)
|
||||
}
|
||||
parent, err := s.repo.GetByID(ctx, *cursor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cursor = parent.ParentID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
130
internal/service/category_test.go
Normal file
130
internal/service/category_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gis/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// stubCategoryRepo is an in-memory CategoryRepository for tests.
|
||||
type stubCategoryRepo struct {
|
||||
store map[uuid.UUID]domain.Category
|
||||
}
|
||||
|
||||
func newStubCategoryRepo() *stubCategoryRepo {
|
||||
return &stubCategoryRepo{store: map[uuid.UUID]domain.Category{}}
|
||||
}
|
||||
|
||||
func (r *stubCategoryRepo) Create(_ context.Context, c domain.Category) (domain.Category, error) {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
r.store[c.ID] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *stubCategoryRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Category, error) {
|
||||
c, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.Category{}, domain.ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *stubCategoryRepo) List(_ context.Context, _ *uuid.UUID) ([]domain.Category, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubCategoryRepo) Update(_ context.Context, c domain.Category) (domain.Category, error) {
|
||||
r.store[c.ID] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *stubCategoryRepo) Delete(_ context.Context, id uuid.UUID) error {
|
||||
delete(r.store, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCategoryService_Create(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("root category succeeds", func(t *testing.T) {
|
||||
svc := NewCategoryService(newStubCategoryRepo())
|
||||
got, err := svc.Create(ctx, CategoryInput{Name: "root"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "root" || got.ParentID != nil {
|
||||
t.Fatalf("unexpected category: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
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})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing parent succeeds", func(t *testing.T) {
|
||||
repo := newStubCategoryRepo()
|
||||
svc := NewCategoryService(repo)
|
||||
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
|
||||
|
||||
child, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if child.ParentID == nil || *child.ParentID != root.ID {
|
||||
t.Fatalf("child not linked to parent: %+v", child)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCategoryService_Update_PreventsCycles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newStubCategoryRepo()
|
||||
svc := NewCategoryService(repo)
|
||||
|
||||
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
|
||||
child, _ := svc.Create(ctx, CategoryInput{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})
|
||||
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})
|
||||
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})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.ParentID == nil || *updated.ParentID != other.ID {
|
||||
t.Fatalf("reparent failed: %+v", updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCategoryService_Update_MissingCategory(t *testing.T) {
|
||||
svc := NewCategoryService(newStubCategoryRepo())
|
||||
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Name: "x"})
|
||||
if !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
642
internal/service/dataset.go
Normal file
642
internal/service/dataset.go
Normal file
@@ -0,0 +1,642 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gis/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// maxParseBytes caps how much of a file is read into memory for parsing.
|
||||
const maxParseBytes = 256 << 20 // 256 MiB
|
||||
|
||||
// DatasetRepository is the persistence behaviour DatasetService needs.
|
||||
type DatasetRepository interface {
|
||||
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error)
|
||||
ListSummaries(ctx context.Context, categoryID *uuid.UUID, limit, offset int) ([]domain.DatasetSummary, error)
|
||||
Count(ctx context.Context, categoryID *uuid.UUID) (int, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error
|
||||
MarkParseFailed(ctx context.Context, id uuid.UUID, reason string) error
|
||||
MarkReady(ctx context.Context, id uuid.UUID) error
|
||||
MarkConverted(ctx context.Context, id uuid.UUID, cogKey string, footprint []byte) error
|
||||
SetProperties(ctx context.Context, id uuid.UUID, properties []byte) error
|
||||
SaveMapping(ctx context.Context, id uuid.UUID, katoColumn string, years []domain.YearColumn) (domain.Dataset, error)
|
||||
ReplaceObservations(ctx context.Context, datasetID uuid.UUID, obs []domain.Observation) error
|
||||
ListObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string, limit, offset int) ([]domain.Observation, error)
|
||||
CountObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string) (int, error)
|
||||
}
|
||||
|
||||
// Pagination defaults for dataset listings.
|
||||
const (
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
)
|
||||
|
||||
// DatasetPage is a page of dataset summaries with pagination metadata.
|
||||
type DatasetPage struct {
|
||||
Items []domain.DatasetSummary
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
}
|
||||
|
||||
// ObjectStore is the object-storage behaviour DatasetService needs.
|
||||
type ObjectStore interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
Remove(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// categoryReader lets the dataset service verify a category exists before upload.
|
||||
type categoryReader interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error)
|
||||
}
|
||||
|
||||
// JobEnqueuer schedules asynchronous dataset jobs.
|
||||
type JobEnqueuer interface {
|
||||
EnqueueParse(ctx context.Context, datasetID uuid.UUID) error
|
||||
EnqueueProperties(ctx context.Context, datasetID uuid.UUID) error
|
||||
EnqueueExtract(ctx context.Context, datasetID uuid.UUID) error
|
||||
EnqueueConvert(ctx context.Context, datasetID uuid.UUID) error
|
||||
}
|
||||
|
||||
// ColumnParser detects attribute columns from a file's raw bytes.
|
||||
type ColumnParser func(filename string, data []byte) ([]domain.AttributeColumn, error)
|
||||
|
||||
// RowParser reads every attribute row from a file's raw bytes as name->value maps.
|
||||
type RowParser func(filename string, data []byte) ([]map[string]string, error)
|
||||
|
||||
// RasterConverter converts a raster file to a Cloud-Optimized GeoTIFF and reads
|
||||
// its footprint. It operates on local file paths.
|
||||
type RasterConverter interface {
|
||||
ToCOG(ctx context.Context, srcPath, dstPath string) error
|
||||
Footprint(ctx context.Context, srcPath string) ([]byte, error)
|
||||
}
|
||||
|
||||
// UploadInput carries everything needed to store a new dataset.
|
||||
type UploadInput struct {
|
||||
CategoryID uuid.UUID
|
||||
Code string
|
||||
Name string
|
||||
Description *string
|
||||
Unit *string
|
||||
Meta json.RawMessage
|
||||
Automated bool
|
||||
Filename string
|
||||
FileType domain.FileType
|
||||
ContentType string
|
||||
Size int64
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// DatasetService implements dataset business rules and object storage handling.
|
||||
type DatasetService struct {
|
||||
repo DatasetRepository
|
||||
store ObjectStore
|
||||
categories categoryReader
|
||||
jobs JobEnqueuer
|
||||
parseColumns ColumnParser
|
||||
parseRows RowParser
|
||||
converter RasterConverter
|
||||
}
|
||||
|
||||
// NewDatasetService wires the dataset repository, object store, category reader
|
||||
// (for parent validation), the job enqueuer, the column/row parsers, and the
|
||||
// raster converter.
|
||||
func NewDatasetService(
|
||||
repo DatasetRepository,
|
||||
store ObjectStore,
|
||||
categories categoryReader,
|
||||
jobs JobEnqueuer,
|
||||
parseColumns ColumnParser,
|
||||
parseRows RowParser,
|
||||
converter RasterConverter,
|
||||
) *DatasetService {
|
||||
return &DatasetService{
|
||||
repo: repo,
|
||||
store: store,
|
||||
categories: categories,
|
||||
jobs: jobs,
|
||||
parseColumns: parseColumns,
|
||||
parseRows: parseRows,
|
||||
converter: converter,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
if !domain.ExtensionAllowedFor(in.FileType, ext) {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: extension %q is not allowed for file_type %q (allowed: %s)",
|
||||
domain.ErrValidation, ext, in.FileType, strings.Join(domain.AllowedExtensions(in.FileType), ", "))
|
||||
}
|
||||
|
||||
// Sniff the file's leading bytes to reject mislabeled uploads up front, then
|
||||
// reconstruct the full stream for storage.
|
||||
head := make([]byte, 512)
|
||||
n, err := io.ReadFull(in.Reader, head)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return domain.Dataset{}, fmt.Errorf("read upload: %w", err)
|
||||
}
|
||||
head = head[:n]
|
||||
if err := domain.ValidateFileContent(ext, head); err != nil {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: %s", domain.ErrValidation, err)
|
||||
}
|
||||
content := io.MultiReader(bytes.NewReader(head), in.Reader)
|
||||
|
||||
if _, err := s.categories.GetByID(ctx, in.CategoryID); err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: category does not exist", domain.ErrValidation)
|
||||
}
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
name := in.Name
|
||||
if name == "" {
|
||||
name = in.Filename
|
||||
}
|
||||
|
||||
// Every uploaded file is processed asynchronously: vector_with_kato is parsed
|
||||
// for column selection; plain vector has its attribute table extracted into
|
||||
// properties; raster is converted to a COG.
|
||||
status := domain.DatasetStatusProcessing
|
||||
if in.FileType == domain.FileTypeVectorWithKato {
|
||||
status = domain.DatasetStatusParsing
|
||||
}
|
||||
|
||||
storageKey := fmt.Sprintf("%s/%s", uuid.New().String(), in.Filename)
|
||||
if err := s.store.Put(ctx, storageKey, content, in.Size, in.ContentType); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
dataset, err := s.repo.Create(ctx, domain.Dataset{
|
||||
CategoryID: in.CategoryID,
|
||||
Code: in.Code,
|
||||
Name: name,
|
||||
Description: in.Description,
|
||||
Unit: in.Unit,
|
||||
Meta: in.Meta,
|
||||
Automated: in.Automated,
|
||||
Status: status,
|
||||
Filename: in.Filename,
|
||||
StorageKey: storageKey,
|
||||
FileType: in.FileType,
|
||||
SizeBytes: in.Size,
|
||||
ContentType: in.ContentType,
|
||||
})
|
||||
if err != nil {
|
||||
// Compensate: the row was not written, so the object would be orphaned.
|
||||
_ = s.store.Remove(ctx, storageKey)
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
// Kick off the appropriate async job per file type. If enqueueing fails the
|
||||
// row exists, so record the failure rather than leaving it stuck.
|
||||
var enqueueErr error
|
||||
switch in.FileType {
|
||||
case domain.FileTypeVectorWithKato:
|
||||
enqueueErr = s.jobs.EnqueueParse(ctx, dataset.ID)
|
||||
case domain.FileTypeVector:
|
||||
enqueueErr = s.jobs.EnqueueProperties(ctx, dataset.ID)
|
||||
case domain.FileTypeRaster:
|
||||
enqueueErr = s.jobs.EnqueueConvert(ctx, dataset.ID)
|
||||
}
|
||||
if enqueueErr != nil {
|
||||
_ = s.repo.MarkParseFailed(ctx, dataset.ID, "failed to enqueue processing: "+enqueueErr.Error())
|
||||
return domain.Dataset{}, fmt.Errorf("enqueue processing: %w", enqueueErr)
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
// ExtractProperties reads a plain vector dataset's attribute table and stores it
|
||||
// (as a JSON array of row objects) in the properties column, then marks the
|
||||
// dataset ready. Invoked by the worker. Parse failures are recorded; storage
|
||||
// failures are returned for retry.
|
||||
func (s *DatasetService) ExtractProperties(ctx context.Context, id uuid.UUID) error {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dataset.FileType != domain.FileTypeVector {
|
||||
return nil // only plain vector populates properties
|
||||
}
|
||||
|
||||
data, err := s.fetchObject(ctx, dataset.StorageKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dataset %s: %w", id, err) // transient
|
||||
}
|
||||
|
||||
rows, err := s.parseRows(dataset.Filename, data)
|
||||
if err != nil {
|
||||
return s.repo.MarkParseFailed(ctx, id, err.Error()) // permanent
|
||||
}
|
||||
|
||||
var properties []byte
|
||||
if hasAttributeData(rows) {
|
||||
if properties, err = json.Marshal(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.repo.SetProperties(ctx, id, properties)
|
||||
}
|
||||
|
||||
// hasAttributeData reports whether any row carries at least one attribute.
|
||||
func hasAttributeData(rows []map[string]string) bool {
|
||||
for _, row := range rows {
|
||||
if len(row) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ConvertToCOG converts a raster dataset to a Cloud-Optimized GeoTIFF, stores it
|
||||
// under a new key, records the footprint geometry, and marks the dataset ready.
|
||||
// Invoked by the worker. Conversion failures are recorded; storage failures are
|
||||
// returned for retry.
|
||||
func (s *DatasetService) ConvertToCOG(ctx context.Context, id uuid.UUID) error {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dataset.FileType != domain.FileTypeRaster {
|
||||
return nil // nothing to convert
|
||||
}
|
||||
|
||||
srcPath, cleanupSrc, err := s.downloadToTemp(ctx, dataset.StorageKey, "gis-src-*.tif")
|
||||
if err != nil {
|
||||
return fmt.Errorf("download raster %s: %w", id, err) // transient
|
||||
}
|
||||
defer cleanupSrc()
|
||||
|
||||
dstPath := srcPath + ".cog.tif"
|
||||
defer os.Remove(dstPath)
|
||||
|
||||
footprint, _ := s.converter.Footprint(ctx, srcPath) // best-effort
|
||||
|
||||
if err := s.converter.ToCOG(ctx, srcPath, dstPath); err != nil {
|
||||
return s.repo.MarkParseFailed(ctx, id, err.Error()) // permanent
|
||||
}
|
||||
|
||||
cogKey := deriveCOGKey(dataset.StorageKey)
|
||||
if err := s.uploadFile(ctx, cogKey, dstPath, "image/tiff"); err != nil {
|
||||
return fmt.Errorf("upload cog %s: %w", id, err) // transient
|
||||
}
|
||||
return s.repo.MarkConverted(ctx, id, cogKey, footprint)
|
||||
}
|
||||
|
||||
// downloadToTemp streams an object to a temp file and returns its path and a
|
||||
// cleanup func.
|
||||
func (s *DatasetService) downloadToTemp(ctx context.Context, key, pattern string) (string, func(), error) {
|
||||
obj, err := s.store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := io.Copy(f, obj); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", nil, err
|
||||
}
|
||||
return f.Name(), func() { os.Remove(f.Name()) }, nil
|
||||
}
|
||||
|
||||
// uploadFile streams a local file to the object store.
|
||||
func (s *DatasetService) uploadFile(ctx context.Context, key, filePath, contentType string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Put(ctx, key, f, info.Size(), contentType)
|
||||
}
|
||||
|
||||
// deriveCOGKey places the COG alongside the original under a cog/ prefix.
|
||||
func deriveCOGKey(storageKey string) string {
|
||||
return path.Join(path.Dir(storageKey), "cog", path.Base(storageKey))
|
||||
}
|
||||
|
||||
// Parse reads a vector_with_kato dataset's file, detects its attribute columns,
|
||||
// and moves it to awaiting_mapping. It is invoked by the worker. Permanent
|
||||
// parse failures are recorded on the dataset (and not retried); transient
|
||||
// failures are returned to the caller.
|
||||
func (s *DatasetService) Parse(ctx context.Context, id uuid.UUID) error {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dataset.FileType != domain.FileTypeVectorWithKato {
|
||||
return nil // nothing to parse
|
||||
}
|
||||
|
||||
data, err := s.fetchObject(ctx, dataset.StorageKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dataset %s: %w", id, err) // transient; allow retry
|
||||
}
|
||||
|
||||
cols, err := s.parseColumns(dataset.Filename, data)
|
||||
if err != nil {
|
||||
// Permanent: the file could not be parsed. Record and stop.
|
||||
return s.repo.MarkParseFailed(ctx, id, err.Error())
|
||||
}
|
||||
return s.repo.MarkParsed(ctx, id, cols)
|
||||
}
|
||||
|
||||
func (s *DatasetService) fetchObject(ctx context.Context, key string) ([]byte, error) {
|
||||
obj, err := s.store.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
return io.ReadAll(io.LimitReader(obj, maxParseBytes))
|
||||
}
|
||||
|
||||
// MappingInput carries the user's KATO column choice and year-column mapping.
|
||||
type MappingInput struct {
|
||||
KatoColumn string
|
||||
YearColumns []domain.YearColumn
|
||||
}
|
||||
|
||||
// SaveMapping validates the KATO column and year mapping against the dataset's
|
||||
// detected columns and marks the dataset ready.
|
||||
func (s *DatasetService) SaveMapping(ctx context.Context, id uuid.UUID, in MappingInput) (domain.Dataset, error) {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
if dataset.FileType != domain.FileTypeVectorWithKato {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: mapping only applies to vector_with_kato datasets", domain.ErrValidation)
|
||||
}
|
||||
if dataset.Status != domain.DatasetStatusAwaitingMapping && dataset.Status != domain.DatasetStatusReady {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: dataset is not ready for mapping (status %q)", domain.ErrConflict, dataset.Status)
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(dataset.AttributeColumns))
|
||||
for _, c := range dataset.AttributeColumns {
|
||||
known[c.Name] = struct{}{}
|
||||
}
|
||||
if _, ok := known[in.KatoColumn]; !ok {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: kato_column %q is not among the detected columns", domain.ErrValidation, in.KatoColumn)
|
||||
}
|
||||
if len(in.YearColumns) == 0 {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: at least one year column mapping is required", domain.ErrValidation)
|
||||
}
|
||||
for _, yc := range in.YearColumns {
|
||||
if _, ok := known[yc.Column]; !ok {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: year column %q is not among the detected columns", domain.ErrValidation, yc.Column)
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", yc.Date); err != nil {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: invalid date %q for column %q (want YYYY-MM-DD)", domain.ErrValidation, yc.Date, yc.Column)
|
||||
}
|
||||
}
|
||||
|
||||
dataset, err = s.repo.SaveMapping(ctx, id, in.KatoColumn, in.YearColumns)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
if err := s.jobs.EnqueueExtract(ctx, id); err != nil {
|
||||
_ = s.repo.MarkParseFailed(ctx, id, "failed to enqueue extraction: "+err.Error())
|
||||
return domain.Dataset{}, fmt.Errorf("enqueue extract: %w", err)
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
// Extract reads a mapped dataset's file, unpivots its attribute table into
|
||||
// observations keyed by KATO code and date, and marks the dataset ready. It is
|
||||
// invoked by the worker. Permanent failures (unparsable file) are recorded;
|
||||
// transient failures (storage/DB) are returned for retry.
|
||||
func (s *DatasetService) Extract(ctx context.Context, id uuid.UUID) error {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dataset.KatoColumn == nil || len(dataset.YearColumns) == 0 {
|
||||
return fmt.Errorf("dataset %s has no mapping to extract", id)
|
||||
}
|
||||
|
||||
data, err := s.fetchObject(ctx, dataset.StorageKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dataset %s: %w", id, err) // transient
|
||||
}
|
||||
|
||||
rows, err := s.parseRows(dataset.Filename, data)
|
||||
if err != nil {
|
||||
return s.repo.MarkParseFailed(ctx, id, err.Error()) // permanent
|
||||
}
|
||||
|
||||
obs := buildObservations(id, *dataset.KatoColumn, dataset.YearColumns, rows)
|
||||
if err := s.repo.ReplaceObservations(ctx, id, obs); err != nil {
|
||||
return err // transient
|
||||
}
|
||||
return s.repo.MarkReady(ctx, id)
|
||||
}
|
||||
|
||||
// buildObservations unpivots rows into observations. Rows without a KATO code
|
||||
// are skipped; duplicate (kato, date) pairs keep the last value. Numeric cells
|
||||
// populate Value, others ValueText.
|
||||
func buildObservations(datasetID uuid.UUID, katoColumn string, years []domain.YearColumn, rows []map[string]string) []domain.Observation {
|
||||
obs := make([]domain.Observation, 0, len(rows)*len(years))
|
||||
index := make(map[string]int)
|
||||
|
||||
for _, row := range rows {
|
||||
kato := strings.TrimSpace(row[katoColumn])
|
||||
if kato == "" {
|
||||
continue
|
||||
}
|
||||
for _, yc := range years {
|
||||
o := domain.Observation{DatasetID: datasetID, KatoCode: kato, Date: yc.Date}
|
||||
if raw := strings.TrimSpace(row[yc.Column]); raw != "" {
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
o.Value = &f
|
||||
} else {
|
||||
o.ValueText = &raw
|
||||
}
|
||||
}
|
||||
key := kato + "\x00" + yc.Date
|
||||
if i, ok := index[key]; ok {
|
||||
obs[i] = o
|
||||
} else {
|
||||
index[key] = len(obs)
|
||||
obs = append(obs, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
return obs
|
||||
}
|
||||
|
||||
// ObservationPage is a page of observations with pagination metadata.
|
||||
type ObservationPage struct {
|
||||
Items []domain.Observation
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
}
|
||||
|
||||
// ListObservations returns a page of a dataset's observations, optionally
|
||||
// filtered by KATO code.
|
||||
func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, katoCode *string, page, pageSize int) (ObservationPage, error) {
|
||||
if _, err := s.repo.GetByID(ctx, id); err != nil {
|
||||
return ObservationPage{}, err
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaxPageSize {
|
||||
pageSize = MaxPageSize
|
||||
}
|
||||
|
||||
items, err := s.repo.ListObservations(ctx, id, katoCode, pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return ObservationPage{}, err
|
||||
}
|
||||
total, err := s.repo.CountObservations(ctx, id, katoCode)
|
||||
if err != nil {
|
||||
return ObservationPage{}, err
|
||||
}
|
||||
return ObservationPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// Get returns a dataset by id.
|
||||
func (s *DatasetService) Get(ctx context.Context, id uuid.UUID) (domain.Dataset, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Status-wait bounds and polling cadence for long polling.
|
||||
const (
|
||||
DefaultStatusWait = 25 * time.Second
|
||||
MaxStatusWait = 60 * time.Second
|
||||
statusPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// DatasetStatusInfo is the minimal status view returned by long polling.
|
||||
type DatasetStatusInfo struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ParseError *string `json:"parse_error"`
|
||||
}
|
||||
|
||||
// WaitForStatus implements long polling. If current is empty or already differs
|
||||
// from the dataset's status it returns immediately; otherwise it waits (up to
|
||||
// wait, clamped to MaxStatusWait) for the status to change, returning the latest
|
||||
// status on change or on timeout.
|
||||
func (s *DatasetService) WaitForStatus(ctx context.Context, id uuid.UUID, current string, wait time.Duration) (DatasetStatusInfo, error) {
|
||||
if wait <= 0 {
|
||||
wait = DefaultStatusWait
|
||||
}
|
||||
if wait > MaxStatusWait {
|
||||
wait = MaxStatusWait
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
|
||||
for {
|
||||
d, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return DatasetStatusInfo{}, err
|
||||
}
|
||||
if current == "" || d.Status != current || !time.Now().Before(deadline) {
|
||||
return DatasetStatusInfo{ID: d.ID, Status: d.Status, ParseError: d.ParseError}, nil
|
||||
}
|
||||
|
||||
sleep := statusPollInterval
|
||||
if rem := time.Until(deadline); rem < sleep {
|
||||
sleep = rem
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return DatasetStatusInfo{}, ctx.Err()
|
||||
case <-time.After(sleep):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaxPageSize {
|
||||
pageSize = MaxPageSize
|
||||
}
|
||||
|
||||
items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return DatasetPage{}, err
|
||||
}
|
||||
total, err := s.repo.Count(ctx, categoryID)
|
||||
if err != nil {
|
||||
return DatasetPage{}, err
|
||||
}
|
||||
return DatasetPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// Download returns the dataset metadata and a reader for its stored object. The
|
||||
// caller must close the reader.
|
||||
func (s *DatasetService) Download(ctx context.Context, id uuid.UUID) (domain.Dataset, io.ReadCloser, error) {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, nil, err
|
||||
}
|
||||
obj, err := s.store.Get(ctx, dataset.StorageKey)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, nil, err
|
||||
}
|
||||
return dataset, obj, nil
|
||||
}
|
||||
|
||||
// Delete removes the dataset row and its stored object.
|
||||
func (s *DatasetService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Remove(ctx, dataset.StorageKey); err != nil {
|
||||
// The row is already gone; surface the object-store failure to the caller.
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
823
internal/service/dataset_test.go
Normal file
823
internal/service/dataset_test.go
Normal file
@@ -0,0 +1,823 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gis/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// stubDatasetRepo is an in-memory DatasetRepository for tests.
|
||||
type stubDatasetRepo struct {
|
||||
store map[uuid.UUID]domain.Dataset
|
||||
observations map[uuid.UUID][]domain.Observation
|
||||
createErr error
|
||||
deleted []uuid.UUID
|
||||
lastLimit, lastOffset int
|
||||
}
|
||||
|
||||
func newStubDatasetRepo() *stubDatasetRepo {
|
||||
return &stubDatasetRepo{
|
||||
store: map[uuid.UUID]domain.Dataset{},
|
||||
observations: map[uuid.UUID][]domain.Observation{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) Create(_ context.Context, d domain.Dataset) (domain.Dataset, error) {
|
||||
if r.createErr != nil {
|
||||
return domain.Dataset{}, r.createErr
|
||||
}
|
||||
if d.ID == uuid.Nil {
|
||||
d.ID = uuid.New()
|
||||
}
|
||||
r.store[d.ID] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Dataset, error) {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.Dataset{}, domain.ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ListSummaries(_ context.Context, _ *uuid.UUID, limit, offset int) ([]domain.DatasetSummary, error) {
|
||||
r.lastLimit = limit
|
||||
r.lastOffset = offset
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) Count(_ context.Context, _ *uuid.UUID) (int, error) {
|
||||
return len(r.store), nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) Delete(_ context.Context, id uuid.UUID) error {
|
||||
r.deleted = append(r.deleted, id)
|
||||
delete(r.store, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) MarkParsed(_ context.Context, id uuid.UUID, cols []domain.AttributeColumn) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.AttributeColumns = cols
|
||||
d.Status = domain.DatasetStatusAwaitingMapping
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) MarkParseFailed(_ context.Context, id uuid.UUID, reason string) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.Status = domain.DatasetStatusFailed
|
||||
d.ParseError = &reason
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) SaveMapping(_ context.Context, id uuid.UUID, kato string, years []domain.YearColumn) (domain.Dataset, error) {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.Dataset{}, domain.ErrNotFound
|
||||
}
|
||||
d.KatoColumn = &kato
|
||||
d.YearColumns = years
|
||||
d.Status = domain.DatasetStatusExtracting
|
||||
r.store[id] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) MarkReady(_ context.Context, id uuid.UUID) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.Status = domain.DatasetStatusReady
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) MarkConverted(_ context.Context, id uuid.UUID, cogKey string, footprint []byte) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.CogStorageKey = &cogKey
|
||||
if len(footprint) > 0 {
|
||||
d.Geometry = footprint
|
||||
}
|
||||
d.Status = domain.DatasetStatusReady
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) SetProperties(_ context.Context, id uuid.UUID, properties []byte) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
d.Properties = properties
|
||||
d.Status = domain.DatasetStatusReady
|
||||
r.store[id] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ReplaceObservations(_ context.Context, id uuid.UUID, obs []domain.Observation) error {
|
||||
r.observations[id] = obs
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ListObservations(_ context.Context, id uuid.UUID, _ *string, _, _ int) ([]domain.Observation, error) {
|
||||
return r.observations[id], nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) CountObservations(_ context.Context, id uuid.UUID, _ *string) (int, error) {
|
||||
return len(r.observations[id]), nil
|
||||
}
|
||||
|
||||
// stubEnqueuer records parse, properties, extract, and convert enqueues.
|
||||
type stubEnqueuer struct {
|
||||
enqueued []uuid.UUID
|
||||
properties []uuid.UUID
|
||||
extracted []uuid.UUID
|
||||
converted []uuid.UUID
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubEnqueuer) EnqueueParse(_ context.Context, id uuid.UUID) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.enqueued = append(s.enqueued, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEnqueuer) EnqueueProperties(_ context.Context, id uuid.UUID) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.properties = append(s.properties, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEnqueuer) EnqueueExtract(_ context.Context, id uuid.UUID) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.extracted = append(s.extracted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEnqueuer) EnqueueConvert(_ context.Context, id uuid.UUID) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.converted = append(s.converted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubConverter records raster conversions.
|
||||
type stubConverter struct {
|
||||
cogCalls int
|
||||
toCOGErr error
|
||||
footprint []byte
|
||||
footprintFn func(src string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (c *stubConverter) ToCOG(_ context.Context, _, dst string) error {
|
||||
c.cogCalls++
|
||||
if c.toCOGErr != nil {
|
||||
return c.toCOGErr
|
||||
}
|
||||
return os.WriteFile(dst, []byte("COG"), 0o600)
|
||||
}
|
||||
|
||||
func (c *stubConverter) Footprint(_ context.Context, src string) ([]byte, error) {
|
||||
if c.footprintFn != nil {
|
||||
return c.footprintFn(src)
|
||||
}
|
||||
return c.footprint, nil
|
||||
}
|
||||
|
||||
var (
|
||||
noopParser ColumnParser = func(string, []byte) ([]domain.AttributeColumn, error) { return nil, nil }
|
||||
noopRowParser RowParser = func(string, []byte) ([]map[string]string, error) { return nil, nil }
|
||||
)
|
||||
|
||||
// stubStore records object-store interactions.
|
||||
type stubStore struct {
|
||||
put []string
|
||||
removed []string
|
||||
putErr error
|
||||
}
|
||||
|
||||
func (s *stubStore) Put(_ context.Context, key string, _ io.Reader, _ int64, _ string) error {
|
||||
if s.putErr != nil {
|
||||
return s.putErr
|
||||
}
|
||||
s.put = append(s.put, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) Get(_ context.Context, _ string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
|
||||
func (s *stubStore) Remove(_ context.Context, key string) error {
|
||||
s.removed = append(s.removed, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubCategoryReader satisfies the unexported categoryReader dependency.
|
||||
type stubCategoryReader struct {
|
||||
exists bool
|
||||
}
|
||||
|
||||
func (s stubCategoryReader) GetByID(_ context.Context, id uuid.UUID) (domain.Category, error) {
|
||||
if !s.exists {
|
||||
return domain.Category{}, domain.ErrNotFound
|
||||
}
|
||||
return domain.Category{ID: id}, nil
|
||||
}
|
||||
|
||||
func validUpload() UploadInput {
|
||||
body := `{"type":"FeatureCollection","features":[]}`
|
||||
return UploadInput{
|
||||
CategoryID: uuid.New(),
|
||||
Code: "POP",
|
||||
Name: "Population",
|
||||
Filename: "data.geojson",
|
||||
FileType: domain.FileTypeVector,
|
||||
Size: int64(len(body)),
|
||||
Reader: strings.NewReader(body),
|
||||
}
|
||||
}
|
||||
|
||||
func newDatasetService(repo *stubDatasetRepo, store *stubStore, catExists bool) *DatasetService {
|
||||
return NewDatasetService(repo, store, stubCategoryReader{exists: catExists}, &stubEnqueuer{}, noopParser, noopRowParser, &stubConverter{})
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_Validation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
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
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
in := validUpload()
|
||||
tt.mutate(&in)
|
||||
|
||||
_, err := svc.Upload(ctx, in)
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
if len(store.put) != 0 {
|
||||
t.Fatalf("nothing should be uploaded on validation failure, got %v", store.put)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_RejectsMismatchedContent(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
// Declared raster .tif but the bytes are JSON, not a TIFF.
|
||||
in := validUpload()
|
||||
in.FileType = domain.FileTypeRaster
|
||||
in.Filename = "fake.tif"
|
||||
in.Reader = strings.NewReader(`{"type":"FeatureCollection"}`)
|
||||
|
||||
_, err := svc.Upload(context.Background(), in)
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
if len(store.put) != 0 {
|
||||
t.Fatalf("mismatched file should not be stored, got %v", store.put)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_MissingCategory(t *testing.T) {
|
||||
svc := newDatasetService(newStubDatasetRepo(), &stubStore{}, false)
|
||||
_, err := svc.Upload(context.Background(), validUpload())
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_Success(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
in := validUpload()
|
||||
in.Name = "" // should fall back to filename
|
||||
|
||||
got, err := svc.Upload(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != in.Filename {
|
||||
t.Fatalf("name should default to filename, got %q", got.Name)
|
||||
}
|
||||
if len(store.put) != 1 {
|
||||
t.Fatalf("want one stored object, got %v", store.put)
|
||||
}
|
||||
if got.StorageKey != store.put[0] {
|
||||
t.Fatalf("dataset storage key %q != stored key %q", got.StorageKey, store.put[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_CompensatesOnDBFailure(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.createErr = errors.New("insert failed")
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
_, err := svc.Upload(context.Background(), validUpload())
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if len(store.put) != 1 || len(store.removed) != 1 {
|
||||
t.Fatalf("orphaned object not cleaned up: put=%v removed=%v", store.put, store.removed)
|
||||
}
|
||||
if store.put[0] != store.removed[0] {
|
||||
t.Fatalf("removed key %q != stored key %q", store.removed[0], store.put[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_VectorWithKato_EnqueuesParse(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
enq := &stubEnqueuer{}
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, enq, noopParser, noopRowParser, &stubConverter{})
|
||||
|
||||
in := validUpload()
|
||||
in.FileType = domain.FileTypeVectorWithKato
|
||||
in.Filename = "regions.geojson"
|
||||
|
||||
got, err := svc.Upload(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Status != domain.DatasetStatusParsing {
|
||||
t.Fatalf("want status parsing, got %q", got.Status)
|
||||
}
|
||||
if len(enq.enqueued) != 1 || enq.enqueued[0] != got.ID {
|
||||
t.Fatalf("parse not enqueued for dataset: %v", enq.enqueued)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_Vector_EnqueuesProperties(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
enq := &stubEnqueuer{}
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, enq, noopParser, noopRowParser, &stubConverter{})
|
||||
|
||||
got, err := svc.Upload(context.Background(), validUpload()) // plain vector .geojson
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Status != domain.DatasetStatusProcessing {
|
||||
t.Fatalf("want status processing, got %q", got.Status)
|
||||
}
|
||||
if len(enq.properties) != 1 || enq.properties[0] != got.ID {
|
||||
t.Fatalf("properties extraction not enqueued: %v", enq.properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ExtractProperties(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector,
|
||||
Filename: "d.geojson", StorageKey: "k", Status: domain.DatasetStatusProcessing,
|
||||
}
|
||||
rows := []map[string]string{
|
||||
{"name": "Astana", "pop": "1000"},
|
||||
{"name": "Almaty", "pop": "2000"},
|
||||
}
|
||||
rp := RowParser(func(string, []byte) ([]map[string]string, error) { return rows, nil })
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, noopParser, rp, &stubConverter{})
|
||||
|
||||
if err := svc.ExtractProperties(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := repo.store[id]
|
||||
if got.Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("want ready, got %q", got.Status)
|
||||
}
|
||||
var parsed []map[string]string
|
||||
if err := json.Unmarshal(got.Properties, &parsed); err != nil {
|
||||
t.Fatalf("properties not valid JSON: %v (%s)", err, got.Properties)
|
||||
}
|
||||
if len(parsed) != 2 {
|
||||
t.Fatalf("want 2 rows in properties, got %d", len(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ExtractProperties_NoTable(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector,
|
||||
Filename: "d.geojson", StorageKey: "k", Status: domain.DatasetStatusProcessing,
|
||||
}
|
||||
// features with no attributes
|
||||
rp := RowParser(func(string, []byte) ([]map[string]string, error) {
|
||||
return []map[string]string{{}, {}}, nil
|
||||
})
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, noopParser, rp, &stubConverter{})
|
||||
|
||||
if err := svc.ExtractProperties(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := repo.store[id]
|
||||
if got.Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("want ready, got %q", got.Status)
|
||||
}
|
||||
if got.Properties != nil {
|
||||
t.Fatalf("expected nil properties for empty table, got %s", got.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Upload_Raster_EnqueuesConvert(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
enq := &stubEnqueuer{}
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, enq, noopParser, noopRowParser, &stubConverter{})
|
||||
|
||||
in := validUpload()
|
||||
in.FileType = domain.FileTypeRaster
|
||||
in.Filename = "dem.tif"
|
||||
in.Reader = bytes.NewReader([]byte("II*\x00\x08\x00\x00\x00")) // TIFF magic
|
||||
|
||||
got, err := svc.Upload(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Status != domain.DatasetStatusProcessing {
|
||||
t.Fatalf("want status processing, got %q", got.Status)
|
||||
}
|
||||
if len(enq.converted) != 1 || enq.converted[0] != got.ID {
|
||||
t.Fatalf("conversion not enqueued: %v", enq.converted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ConvertToCOG(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeRaster,
|
||||
Filename: "dem.tif", StorageKey: "uid/dem.tif",
|
||||
Status: domain.DatasetStatusProcessing,
|
||||
}
|
||||
store := &stubStore{}
|
||||
footprint := []byte(`{"type":"Polygon","coordinates":[[[70,50],[72,50],[72,52],[70,52],[70,50]]]}`)
|
||||
conv := &stubConverter{footprint: footprint}
|
||||
svc := NewDatasetService(repo, store, stubCategoryReader{exists: true}, &stubEnqueuer{}, noopParser, noopRowParser, conv)
|
||||
|
||||
if err := svc.ConvertToCOG(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := repo.store[id]
|
||||
if got.Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("want ready, got %q", got.Status)
|
||||
}
|
||||
if got.CogStorageKey == nil || *got.CogStorageKey != "uid/cog/dem.tif" {
|
||||
t.Fatalf("unexpected cog key: %v", got.CogStorageKey)
|
||||
}
|
||||
if string(got.Geometry) != string(footprint) {
|
||||
t.Fatalf("footprint not stored: %s", got.Geometry)
|
||||
}
|
||||
if conv.cogCalls != 1 {
|
||||
t.Fatalf("ToCOG calls = %d, want 1", conv.cogCalls)
|
||||
}
|
||||
if len(store.put) != 1 || store.put[0] != "uid/cog/dem.tif" {
|
||||
t.Fatalf("cog not uploaded: %v", store.put)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ConvertToCOG_RecordsFailure(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeRaster,
|
||||
Filename: "dem.tif", StorageKey: "uid/dem.tif",
|
||||
Status: domain.DatasetStatusProcessing,
|
||||
}
|
||||
conv := &stubConverter{toCOGErr: errors.New("gdal failed")}
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, noopParser, noopRowParser, conv)
|
||||
|
||||
if err := svc.ConvertToCOG(context.Background(), id); err != nil {
|
||||
t.Fatalf("conversion failure should be recorded, not returned: %v", err)
|
||||
}
|
||||
if repo.store[id].Status != domain.DatasetStatusFailed {
|
||||
t.Fatalf("want failed, got %q", repo.store[id].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Parse(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato,
|
||||
Filename: "r.geojson", StorageKey: "k", Status: domain.DatasetStatusParsing,
|
||||
}
|
||||
cols := []domain.AttributeColumn{{Name: "като"}, {Name: "F_2023"}}
|
||||
parser := ColumnParser(func(string, []byte) ([]domain.AttributeColumn, error) { return cols, nil })
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, parser, noopRowParser, &stubConverter{})
|
||||
|
||||
if err := svc.Parse(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := repo.store[id]
|
||||
if got.Status != domain.DatasetStatusAwaitingMapping {
|
||||
t.Fatalf("want awaiting_mapping, got %q", got.Status)
|
||||
}
|
||||
if len(got.AttributeColumns) != 2 {
|
||||
t.Fatalf("columns not stored: %v", got.AttributeColumns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Parse_RecordsFailure(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato,
|
||||
Filename: "r.zip", StorageKey: "k", Status: domain.DatasetStatusParsing,
|
||||
}
|
||||
parser := ColumnParser(func(string, []byte) ([]domain.AttributeColumn, error) {
|
||||
return nil, errors.New("corrupt archive")
|
||||
})
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, parser, noopRowParser, &stubConverter{})
|
||||
|
||||
if err := svc.Parse(context.Background(), id); err != nil {
|
||||
t.Fatalf("parse failure should be recorded, not returned: %v", err)
|
||||
}
|
||||
got := repo.store[id]
|
||||
if got.Status != domain.DatasetStatusFailed {
|
||||
t.Fatalf("want failed, got %q", got.Status)
|
||||
}
|
||||
if got.ParseError == nil || *got.ParseError == "" {
|
||||
t.Fatal("expected parse error to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
base := domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato,
|
||||
Status: domain.DatasetStatusAwaitingMapping,
|
||||
AttributeColumns: []domain.AttributeColumn{{Name: "като"}, {Name: "F_2023"}},
|
||||
}
|
||||
validYears := []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}}
|
||||
|
||||
newSvc := func() (*stubDatasetRepo, *DatasetService) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = base
|
||||
return repo, newDatasetService(repo, &stubStore{}, true)
|
||||
}
|
||||
|
||||
t.Run("unknown kato column", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "missing", YearColumns: validYears})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown year column", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "X", Date: "2023-01-01"}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad date", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023"}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no year columns", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като"})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong state is a conflict", func(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
d := base
|
||||
d.Status = domain.DatasetStatusParsing
|
||||
repo.store[id] = d
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: validYears})
|
||||
if !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("want ErrConflict, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success moves to extracting and enqueues extraction", func(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = base
|
||||
enq := &stubEnqueuer{}
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, enq, noopParser, noopRowParser, &stubConverter{})
|
||||
|
||||
got, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: validYears})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Status != domain.DatasetStatusExtracting {
|
||||
t.Fatalf("want extracting, got %q", got.Status)
|
||||
}
|
||||
if got.KatoColumn == nil || *got.KatoColumn != "като" {
|
||||
t.Fatalf("kato column not saved: %+v", got.KatoColumn)
|
||||
}
|
||||
if len(enq.extracted) != 1 || enq.extracted[0] != id {
|
||||
t.Fatalf("extraction not enqueued: %v", enq.extracted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildObservations(t *testing.T) {
|
||||
id := uuid.New()
|
||||
years := []domain.YearColumn{
|
||||
{Column: "F_2023", Date: "2023-01-01"},
|
||||
{Column: "D_2025", Date: "2025-01-01"},
|
||||
}
|
||||
rows := []map[string]string{
|
||||
{"като": "751010000", "F_2023": "100", "D_2025": "n/a"},
|
||||
{"като": "751020000", "F_2023": "150", "D_2025": "250"},
|
||||
{"като": "", "F_2023": "999"}, // skipped: no KATO code
|
||||
}
|
||||
|
||||
obs := buildObservations(id, "като", years, rows)
|
||||
if len(obs) != 4 { // 2 valid rows x 2 years
|
||||
t.Fatalf("want 4 observations, got %d", len(obs))
|
||||
}
|
||||
|
||||
byKey := map[string]domain.Observation{}
|
||||
for _, o := range obs {
|
||||
byKey[o.KatoCode+"|"+o.Date] = o
|
||||
}
|
||||
if o := byKey["751010000|2023-01-01"]; o.Value == nil || *o.Value != 100 {
|
||||
t.Errorf("numeric cell not stored as value: %+v", o)
|
||||
}
|
||||
if o := byKey["751010000|2025-01-01"]; o.ValueText == nil || *o.ValueText != "n/a" {
|
||||
t.Errorf("non-numeric cell not stored as value_text: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Extract(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
kato := "като"
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato,
|
||||
Filename: "r.geojson", StorageKey: "k",
|
||||
Status: domain.DatasetStatusExtracting,
|
||||
KatoColumn: &kato,
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01"}},
|
||||
}
|
||||
rows := []map[string]string{{"като": "751010000", "F_2023": "100"}}
|
||||
rp := RowParser(func(string, []byte) ([]map[string]string, error) { return rows, nil })
|
||||
svc := NewDatasetService(repo, &stubStore{}, stubCategoryReader{exists: true}, &stubEnqueuer{}, noopParser, rp, &stubConverter{})
|
||||
|
||||
if err := svc.Extract(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if repo.store[id].Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("want ready, got %q", repo.store[id].Status)
|
||||
}
|
||||
got := repo.observations[id]
|
||||
if len(got) != 1 || got[0].KatoCode != "751010000" || got[0].Value == nil || *got[0].Value != 100 {
|
||||
t.Fatalf("unexpected observations: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[uuid.New()] = domain.Dataset{}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
// page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0.
|
||||
res, err := svc.ListSummaries(context.Background(), nil, 0, 10_000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Page != 1 || res.PageSize != MaxPageSize {
|
||||
t.Fatalf("clamp failed: page=%d pageSize=%d", res.Page, res.PageSize)
|
||||
}
|
||||
if repo.lastLimit != MaxPageSize || repo.lastOffset != 0 {
|
||||
t.Fatalf("repo got limit=%d offset=%d", repo.lastLimit, repo.lastOffset)
|
||||
}
|
||||
if res.Total != 1 {
|
||||
t.Fatalf("total = %d, want 1", res.Total)
|
||||
}
|
||||
|
||||
// page 3, pageSize 20 -> offset 40.
|
||||
if _, err := svc.ListSummaries(context.Background(), nil, 3, 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.lastOffset != 40 || repo.lastLimit != 20 {
|
||||
t.Fatalf("expected limit=20 offset=40, got limit=%d offset=%d", repo.lastLimit, repo.lastOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_WaitForStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, Status: domain.DatasetStatusReady}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
t.Run("returns immediately when status differs from current", func(t *testing.T) {
|
||||
info, err := svc.WaitForStatus(ctx, id, domain.DatasetStatusProcessing, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.Status != domain.DatasetStatusReady || info.ID != id {
|
||||
t.Fatalf("unexpected info: %+v", info)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns immediately when no current is given", func(t *testing.T) {
|
||||
info, err := svc.WaitForStatus(ctx, id, "", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("status = %q", info.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("times out returning the unchanged status", func(t *testing.T) {
|
||||
start := time.Now()
|
||||
info, err := svc.WaitForStatus(ctx, id, domain.DatasetStatusReady, 30*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info.Status != domain.DatasetStatusReady {
|
||||
t.Fatalf("status = %q", info.Status)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("timed out too slowly: %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
_, err := svc.WaitForStatus(ctx, uuid.New(), "", time.Minute)
|
||||
if !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDatasetService_Delete_RemovesObject(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
id := uuid.New()
|
||||
repo.store[id] = domain.Dataset{ID: id, StorageKey: "key/data.geojson"}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
if err := svc.Delete(context.Background(), id); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(repo.deleted) != 1 || repo.deleted[0] != id {
|
||||
t.Fatalf("row not deleted: %v", repo.deleted)
|
||||
}
|
||||
if len(store.removed) != 1 || store.removed[0] != "key/data.geojson" {
|
||||
t.Fatalf("object not removed: %v", store.removed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user