feat: Districts table and geojson stub
This commit is contained in:
@@ -38,6 +38,8 @@ type DatasetRepository interface {
|
||||
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)
|
||||
ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error)
|
||||
DistrictGeometriesByKato(ctx context.Context, katos []string) (map[string]domain.District, error)
|
||||
}
|
||||
|
||||
// Pagination defaults for dataset listings.
|
||||
@@ -616,6 +618,221 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat
|
||||
return ObservationPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// GeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a vector or
|
||||
// vector_with_kato dataset.
|
||||
//
|
||||
// A plain vector dataset has no KATO mapping or observations, so the result is a
|
||||
// single geometry-only Feature wrapping the dataset's own (dissolved) geometry,
|
||||
// with empty properties (or an empty collection when the dataset has no
|
||||
// geometry).
|
||||
//
|
||||
// A vector_with_kato dataset is built from its observations: when it carries its
|
||||
// own (dissolved) geometry the observations are taken to describe that whole
|
||||
// geometry, yielding a single Feature whose properties hold only the
|
||||
// observations keyed by KATO code (each KATO mapping to its date->value pairs);
|
||||
// otherwise one Feature is emitted per KATO, its boundary taken from the
|
||||
// districts table and its observation values placed into the Feature's
|
||||
// properties keyed by date. KATO codes with no matching district are skipped.
|
||||
//
|
||||
// Only ready datasets are served; a dataset still being processed yields a
|
||||
// conflict.
|
||||
func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
||||
dataset, err := s.loadGeoJSONDataset(ctx, id, true)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
|
||||
// Plain vector: no KATO mapping or observations. Return the dataset's own
|
||||
// geometry as a single Feature, exposing the extracted attribute table (e.g.
|
||||
// a GeoPackage's table data) as the Feature's top-level properties.
|
||||
if dataset.FileType == domain.FileTypeVector {
|
||||
fc := domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: []domain.Feature{}}
|
||||
if hasGeometry(dataset.Geometry) {
|
||||
fc.Features = append(fc.Features, domain.Feature{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dataset.Geometry,
|
||||
Properties: vectorFeatureProperties(dataset.Properties),
|
||||
})
|
||||
}
|
||||
return fc, nil
|
||||
}
|
||||
|
||||
obs, err := s.repo.ListAllObservations(ctx, id)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
grouped, order := groupObservationsByKato(obs)
|
||||
|
||||
// The dataset has its own geometry (the dissolved union of all features): the
|
||||
// observations describe that whole geometry, so emit a single Feature wrapping
|
||||
// it whose properties hold only the observations, keyed by KATO code.
|
||||
if hasGeometry(dataset.Geometry) {
|
||||
props := make(map[string]any, len(order))
|
||||
for _, kato := range order {
|
||||
props[kato] = grouped[kato]
|
||||
}
|
||||
return domain.FeatureCollection{
|
||||
Type: domain.GeoJSONFeatureCollection,
|
||||
Features: []domain.Feature{{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dataset.Geometry,
|
||||
Properties: props,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No geometry: build one Feature per KATO from the districts table.
|
||||
features, err := s.districtFeatures(ctx, grouped, order)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
|
||||
}
|
||||
|
||||
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
|
||||
// vector_with_kato dataset by always joining the districts table on KATO code,
|
||||
// ignoring any geometry the dataset carries. One Feature is emitted per KATO,
|
||||
// its boundary taken from the districts table and its observation values placed
|
||||
// into the Feature's properties keyed by date (alongside `kato` and `name`).
|
||||
// KATO codes with no matching district are skipped. Plain vector datasets are
|
||||
// not supported (they have no KATO observations). Only ready datasets are
|
||||
// served; a dataset still being processed yields a conflict.
|
||||
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
||||
if _, err := s.loadGeoJSONDataset(ctx, id, false); err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
obs, err := s.repo.ListAllObservations(ctx, id)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
grouped, order := groupObservationsByKato(obs)
|
||||
features, err := s.districtFeatures(ctx, grouped, order)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
|
||||
}
|
||||
|
||||
// loadGeoJSONDataset fetches a dataset for a GeoJSON endpoint and validates that
|
||||
// it is ready and of a supported file type. vector_with_kato is always
|
||||
// accepted; plain vector is accepted only when allowVector is true (the
|
||||
// .kato.geojson endpoint requires KATO observations, which plain vector lacks).
|
||||
func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, allowVector bool) (domain.Dataset, error) {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
supported := dataset.FileType == domain.FileTypeVectorWithKato ||
|
||||
(allowVector && dataset.FileType == domain.FileTypeVector)
|
||||
if !supported {
|
||||
allowed := "vector_with_kato"
|
||||
if allowVector {
|
||||
allowed = "vector and vector_with_kato"
|
||||
}
|
||||
return domain.Dataset{}, fmt.Errorf("%w: geojson is only available for %s datasets", domain.ErrValidation, allowed)
|
||||
}
|
||||
if dataset.Status != domain.DatasetStatusReady {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: dataset is not ready (status %q)", domain.ErrConflict, dataset.Status)
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
// districtFeatures builds one Feature per KATO from the districts table, placing
|
||||
// the grouped observation values into each Feature's properties keyed by date
|
||||
// (alongside `kato` and `name`). KATO codes with no matching district are
|
||||
// skipped. order drives the deterministic feature order.
|
||||
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, order []string) ([]domain.Feature, error) {
|
||||
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features := make([]domain.Feature, 0, len(order))
|
||||
for _, kato := range order {
|
||||
dist, ok := districts[kato]
|
||||
if !ok {
|
||||
continue // skip KATO codes with no district boundary
|
||||
}
|
||||
props := map[string]any{"kato": kato, "name": dist.Name}
|
||||
for date, value := range grouped[kato] {
|
||||
props[date] = value
|
||||
}
|
||||
features = append(features, domain.Feature{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dist.Geometry,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
// hasGeometry reports whether a dataset's GeoJSON geometry (as produced by
|
||||
// ST_AsGeoJSON) is a real geometry rather than absent or JSON null.
|
||||
func hasGeometry(geom json.RawMessage) bool {
|
||||
t := bytes.TrimSpace(geom)
|
||||
return len(t) > 0 && !bytes.Equal(t, []byte("null"))
|
||||
}
|
||||
|
||||
// vectorFeatureProperties turns a plain vector dataset's stored attribute table
|
||||
// into a Feature's properties object. The table is persisted as a JSON array of
|
||||
// row objects: a single row becomes the top-level properties directly, multiple
|
||||
// rows are kept under a "rows" key (so no data is lost while the value stays a
|
||||
// valid GeoJSON properties object), and an empty/absent table yields {}.
|
||||
func vectorFeatureProperties(raw json.RawMessage) map[string]any {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(raw, &rows); err == nil {
|
||||
switch len(rows) {
|
||||
case 0:
|
||||
return map[string]any{}
|
||||
case 1:
|
||||
return rows[0]
|
||||
default:
|
||||
return map[string]any{"rows": rows}
|
||||
}
|
||||
}
|
||||
// Fallback: the column already holds a plain object.
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
return obj
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// groupObservationsByKato groups observations by KATO code into date->value
|
||||
// maps. The value is the numeric Value when present, otherwise ValueText,
|
||||
// otherwise nil (an empty cell). order lists KATO codes in first-seen order so
|
||||
// the resulting feature order is deterministic.
|
||||
func groupObservationsByKato(obs []domain.Observation) (map[string]map[string]any, []string) {
|
||||
grouped := make(map[string]map[string]any)
|
||||
order := make([]string, 0)
|
||||
for _, o := range obs {
|
||||
values, ok := grouped[o.KatoCode]
|
||||
if !ok {
|
||||
values = make(map[string]any)
|
||||
grouped[o.KatoCode] = values
|
||||
order = append(order, o.KatoCode)
|
||||
}
|
||||
values[o.Date] = observationValue(o)
|
||||
}
|
||||
return grouped, order
|
||||
}
|
||||
|
||||
// observationValue returns the typed cell value: numeric Value, else ValueText,
|
||||
// else nil.
|
||||
func observationValue(o domain.Observation) any {
|
||||
switch {
|
||||
case o.Value != nil:
|
||||
return *o.Value
|
||||
case o.ValueText != nil:
|
||||
return *o.ValueText
|
||||
default:
|
||||
return 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)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type stubDatasetRepo struct {
|
||||
store map[uuid.UUID]domain.Dataset
|
||||
observations map[uuid.UUID][]domain.Observation
|
||||
districts map[string]domain.District
|
||||
createErr error
|
||||
deleted []uuid.UUID
|
||||
lastLimit, lastOffset int
|
||||
@@ -29,6 +30,7 @@ func newStubDatasetRepo() *stubDatasetRepo {
|
||||
return &stubDatasetRepo{
|
||||
store: map[uuid.UUID]domain.Dataset{},
|
||||
observations: map[uuid.UUID][]domain.Observation{},
|
||||
districts: map[string]domain.District{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +157,20 @@ func (r *stubDatasetRepo) CountObservations(_ context.Context, id uuid.UUID, _ *
|
||||
return len(r.observations[id]), nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) ListAllObservations(_ context.Context, id uuid.UUID) ([]domain.Observation, error) {
|
||||
return r.observations[id], nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) DistrictGeometriesByKato(_ context.Context, katos []string) (map[string]domain.District, error) {
|
||||
out := make(map[string]domain.District)
|
||||
for _, k := range katos {
|
||||
if d, ok := r.districts[k]; ok {
|
||||
out[k] = d
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stubEnqueuer records parse, properties, extract, and convert enqueues.
|
||||
type stubEnqueuer struct {
|
||||
enqueued []uuid.UUID
|
||||
@@ -787,6 +803,267 @@ func TestDatasetService_Extract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
|
||||
v2020, v2021 := 100.0, 150.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v2020},
|
||||
{KatoCode: "710000000", Date: "2021-01-01", Value: &v2021},
|
||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v2020}, // no district -> skipped
|
||||
}
|
||||
repo.districts["710000000"] = domain.District{
|
||||
Kato: "710000000", Name: "Astana",
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`),
|
||||
}
|
||||
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
fc, err := svc.GeoJSON(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fc.Type != domain.GeoJSONFeatureCollection {
|
||||
t.Fatalf("type = %q", fc.Type)
|
||||
}
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature (the unmatched KATO is skipped), got %d", len(fc.Features))
|
||||
}
|
||||
f := fc.Features[0]
|
||||
if f.Type != domain.GeoJSONFeature {
|
||||
t.Fatalf("feature type = %q", f.Type)
|
||||
}
|
||||
if len(f.Geometry) == 0 {
|
||||
t.Fatal("feature geometry should come from the district")
|
||||
}
|
||||
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
||||
t.Fatalf("unexpected properties: %+v", f.Properties)
|
||||
}
|
||||
if f.Properties["2020-01-01"] != 100.0 || f.Properties["2021-01-01"] != 150.0 {
|
||||
t.Fatalf("year values not flattened into properties: %+v", f.Properties)
|
||||
}
|
||||
|
||||
// The whole thing must marshal to valid GeoJSON.
|
||||
if _, err := json.Marshal(fc); err != nil {
|
||||
t.Fatalf("feature collection not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
geom := json.RawMessage(`{"type":"MultiPolygon","coordinates":[]}`)
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady,
|
||||
Name: "Population", Geometry: geom,
|
||||
}
|
||||
v := 42.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
||||
{KatoCode: "710000000", Date: "2021-01-01", Value: &v},
|
||||
}
|
||||
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
fc, err := svc.GeoJSON(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want a single feature wrapping the dataset geometry, got %d", len(fc.Features))
|
||||
}
|
||||
f := fc.Features[0]
|
||||
if string(f.Geometry) != string(geom) {
|
||||
t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry)
|
||||
}
|
||||
// Properties hold only the observations, keyed by KATO at the top level.
|
||||
if _, ok := f.Properties["name"]; ok {
|
||||
t.Fatalf("properties should contain only observations, got metadata: %+v", f.Properties)
|
||||
}
|
||||
kato, ok := f.Properties["710000000"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("observations not keyed by KATO at top level: %+v", f.Properties)
|
||||
}
|
||||
if kato["2020-01-01"] != 42.0 || kato["2021-01-01"] != 42.0 {
|
||||
t.Fatalf("year values missing under KATO: %+v", kato)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_KatoGeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
// Dataset HAS its own geometry, which KatoGeoJSON must ignore entirely.
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady,
|
||||
Name: "Population", Geometry: json.RawMessage(`{"type":"MultiPolygon","coordinates":[[[[0,0]]]]}`),
|
||||
}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
||||
{KatoCode: "999999999", Date: "2020-01-01", Value: &v}, // no district -> skipped
|
||||
}
|
||||
district := json.RawMessage(`{"type":"Polygon","coordinates":[[[71,51],[72,51],[72,52],[71,51]]]}`)
|
||||
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana", Geometry: district}
|
||||
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
fc, err := svc.KatoGeoJSON(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature (unmatched KATO skipped), got %d", len(fc.Features))
|
||||
}
|
||||
f := fc.Features[0]
|
||||
if string(f.Geometry) != string(district) {
|
||||
t.Fatalf("geometry must come from the district, not the dataset: %s", f.Geometry)
|
||||
}
|
||||
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
||||
t.Fatalf("unexpected properties: %+v", f.Properties)
|
||||
}
|
||||
if f.Properties["2020-01-01"] != 100.0 {
|
||||
t.Fatalf("observation value not mapped onto polygon: %+v", f.Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_KatoGeoJSON_ConflictWhenNotReady(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusParsing}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
if _, err := svc.KatoGeoJSON(context.Background(), id); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("want ErrConflict for non-ready dataset, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_ConflictWhenNotReady(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusExtracting}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
if _, err := svc.GeoJSON(context.Background(), id); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("want ErrConflict for non-ready dataset, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_RejectsRaster(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeRaster, Status: domain.DatasetStatusReady}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
if _, err := svc.GeoJSON(context.Background(), id); !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Vector_GeometryOnly(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
geom := json.RawMessage(`{"type":"MultiPolygon","coordinates":[[[[1,1],[2,2],[3,1],[1,1]]]]}`)
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
|
||||
Name: "Roads", Geometry: geom,
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want a single geometry-only feature, got %d", len(fc.Features))
|
||||
}
|
||||
f := fc.Features[0]
|
||||
if string(f.Geometry) != string(geom) {
|
||||
t.Fatalf("feature should reuse the dataset geometry, got %s", f.Geometry)
|
||||
}
|
||||
if len(f.Properties) != 0 {
|
||||
t.Fatalf("vector feature should have empty properties, got %+v", f.Properties)
|
||||
}
|
||||
if _, err := json.Marshal(fc); err != nil {
|
||||
t.Fatalf("feature collection not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Vector_TableDataAsProperties(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
geom := json.RawMessage(`{"type":"Polygon","coordinates":[[[1,1],[2,2],[3,1],[1,1]]]}`)
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
|
||||
Geometry: geom,
|
||||
Properties: json.RawMessage(`[{"name":"Astana","pop":"1000"}]`), // single gpkg row
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature, got %d", len(fc.Features))
|
||||
}
|
||||
props := fc.Features[0].Properties
|
||||
if props["name"] != "Astana" || props["pop"] != "1000" {
|
||||
t.Fatalf("table data not exposed as top-level properties: %+v", props)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Vector_MultiRowTableData(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
geom := json.RawMessage(`{"type":"Polygon","coordinates":[[[1,1],[2,2],[3,1],[1,1]]]}`)
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady,
|
||||
Geometry: geom,
|
||||
Properties: json.RawMessage(`[{"name":"Astana"},{"name":"Almaty"}]`),
|
||||
}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
rows, ok := fc.Features[0].Properties["rows"].([]map[string]any)
|
||||
if !ok || len(rows) != 2 {
|
||||
t.Fatalf("multi-row table data not kept under \"rows\": %+v", fc.Features[0].Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Vector_NoGeometry(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
fc, err := svc.GeoJSON(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fc.Type != domain.GeoJSONFeatureCollection {
|
||||
t.Fatalf("type = %q", fc.Type)
|
||||
}
|
||||
if len(fc.Features) != 0 {
|
||||
t.Fatalf("want empty feature collection when vector has no geometry, got %d", len(fc.Features))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_KatoGeoJSON_RejectsVector(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVector, Status: domain.DatasetStatusReady}
|
||||
svc := newDatasetService(repo, &stubStore{}, true)
|
||||
|
||||
if _, err := svc.KatoGeoJSON(context.Background(), id); !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation for vector on .kato.geojson, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[uuid.New()] = domain.Dataset{}
|
||||
|
||||
Reference in New Issue
Block a user