feat: Districts table and geojson stub

This commit is contained in:
2026-07-01 00:23:22 +05:00
parent 0b45636e5b
commit 27a8d8b4f5
7 changed files with 731 additions and 0 deletions

View File

@@ -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{}