feat: Add SLD endpoints, add date_to to vector_with_kato
This commit is contained in:
@@ -137,6 +137,18 @@ func (r *stubDatasetRepo) SaveMapping(_ context.Context, id uuid.UUID, kato stri
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) SetSLD(_ context.Context, id uuid.UUID, key, filename, contentType string) (domain.Dataset, error) {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
return domain.Dataset{}, domain.ErrNotFound
|
||||
}
|
||||
d.SldStorageKey = &key
|
||||
d.SldFilename = &filename
|
||||
d.SldContentType = &contentType
|
||||
r.store[id] = d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *stubDatasetRepo) MarkReady(_ context.Context, id uuid.UUID, geometry []byte) error {
|
||||
d, ok := r.store[id]
|
||||
if !ok {
|
||||
@@ -776,6 +788,39 @@ func TestDatasetService_SaveMapping(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad date_to", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-01-01", DateTo: strPtr("2023")}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date_to before date", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като", YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2023-12-31", DateTo: strPtr("2023-01-01")}}})
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("period mapping succeeds", func(t *testing.T) {
|
||||
repo, svc := newSvc()
|
||||
got, err := svc.SaveMapping(ctx, id, MappingInput{
|
||||
KatoColumn: "като",
|
||||
YearColumns: []domain.YearColumn{{Column: "F_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got.YearColumns) != 1 || got.YearColumns[0].DateTo == nil || *got.YearColumns[0].DateTo != "2023-12-31" {
|
||||
t.Fatalf("period not saved: %+v", got.YearColumns)
|
||||
}
|
||||
if len(repo.store[id].YearColumns) != 1 {
|
||||
t.Fatalf("period not persisted: %+v", repo.store[id].YearColumns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no year columns", func(t *testing.T) {
|
||||
_, svc := newSvc()
|
||||
_, err := svc.SaveMapping(ctx, id, MappingInput{KatoColumn: "като"})
|
||||
@@ -845,6 +890,45 @@ func TestBuildObservations(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
for _, o := range obs {
|
||||
if o.DateTo != nil {
|
||||
t.Fatalf("point-in-time mapping must not set DateTo, got %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObservations_Period(t *testing.T) {
|
||||
id := uuid.New()
|
||||
years := []domain.YearColumn{
|
||||
{Column: "F_2020_2023", Date: "2020-01-01", DateTo: strPtr("2023-12-31")}, // period
|
||||
{Column: "F_2024", Date: "2024-01-01"}, // point-in-time, unaffected
|
||||
}
|
||||
rows := []map[string]string{
|
||||
{"като": "751010000", "F_2020_2023": "100", "F_2024": "200"},
|
||||
}
|
||||
|
||||
obs := buildObservations(id, "като", years, rows)
|
||||
if len(obs) != 2 {
|
||||
t.Fatalf("want 2 observations, got %d", len(obs))
|
||||
}
|
||||
|
||||
byDate := map[string]domain.Observation{}
|
||||
for _, o := range obs {
|
||||
byDate[o.Date] = o
|
||||
}
|
||||
|
||||
period := byDate["2020-01-01"]
|
||||
if period.DateTo == nil || *period.DateTo != "2023-12-31" {
|
||||
t.Fatalf("period observation missing date_to: %+v", period)
|
||||
}
|
||||
if period.Value == nil || *period.Value != 100 {
|
||||
t.Fatalf("period observation value wrong: %+v", period)
|
||||
}
|
||||
|
||||
point := byDate["2024-01-01"]
|
||||
if point.DateTo != nil {
|
||||
t.Fatalf("point-in-time observation must have nil date_to (backward compat), got %+v", point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_Extract(t *testing.T) {
|
||||
@@ -959,6 +1043,68 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_Period(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
|
||||
period, point := 100.0, 200.0
|
||||
repo.observations[id] = []domain.Observation{
|
||||
{KatoCode: "710000000", Date: "2020-01-01", DateTo: strPtr("2023-12-31"), Value: &period},
|
||||
{KatoCode: "710000000", Date: "2024-01-01", Value: &point}, // point-in-time, no period
|
||||
}
|
||||
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 := mustGeoJSON(t, svc, id)
|
||||
if len(fc.Features) != 1 {
|
||||
t.Fatalf("want 1 feature, got %d", len(fc.Features))
|
||||
}
|
||||
f := fc.Features[0]
|
||||
|
||||
data, ok := f.Properties["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("observations not nested under data: %+v", f.Properties)
|
||||
}
|
||||
if data["2020-01-01"] != 100.0 || data["2024-01-01"] != 200.0 {
|
||||
t.Fatalf("unexpected data: %+v", data)
|
||||
}
|
||||
|
||||
periods, ok := f.Properties["periods"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("periods not exposed as a map: %+v", f.Properties)
|
||||
}
|
||||
if len(periods) != 1 || periods["2020-01-01"] != "2023-12-31" {
|
||||
t.Fatalf("period end date not exposed: %+v", periods)
|
||||
}
|
||||
if _, has := periods["2024-01-01"]; has {
|
||||
t.Fatalf("point-in-time date must not appear in periods: %+v", periods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_NoPeriods_OmitsPeriodsKey(t *testing.T) {
|
||||
// Regression: a dataset mapped without any period must produce output
|
||||
// byte-identical to before periods existed - no "periods" key at all.
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeVectorWithKato, Status: domain.DatasetStatusReady}
|
||||
v := 100.0
|
||||
repo.observations[id] = []domain.Observation{{KatoCode: "710000000", Date: "2020-01-01", Value: &v}}
|
||||
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 := mustGeoJSON(t, svc, id)
|
||||
if _, has := fc.Features[0].Properties["periods"]; has {
|
||||
t.Fatalf("periods key must be absent when no observation has a period: %+v", fc.Features[0].Properties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_GeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
@@ -1294,6 +1440,125 @@ func TestDatasetService_ReprocessAll_FiltersByFileType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func validSLDUpload() UploadSLDInput {
|
||||
body := `<?xml version="1.0"?><StyledLayerDescriptor></StyledLayerDescriptor>`
|
||||
return UploadSLDInput{
|
||||
Filename: "style.sld",
|
||||
ContentType: "application/xml",
|
||||
Size: int64(len(body)),
|
||||
Reader: strings.NewReader(body),
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_UploadSLD_Success(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, FileType: domain.FileTypeRaster, StorageKey: "uid/dem.tif"}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
got, err := svc.UploadSLD(context.Background(), id, validSLDUpload())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SldStorageKey == nil || *got.SldStorageKey != "uid/sld/style.sld" {
|
||||
t.Fatalf("unexpected sld storage key: %v", got.SldStorageKey)
|
||||
}
|
||||
if got.SldFilename == nil || *got.SldFilename != "style.sld" {
|
||||
t.Fatalf("sld filename not stored: %v", got.SldFilename)
|
||||
}
|
||||
if len(store.put) != 1 || store.put[0] != "uid/sld/style.sld" {
|
||||
t.Fatalf("sld not uploaded to store: %v", store.put)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_UploadSLD_ReplacesPrevious(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
oldKey := "uid/sld/old.sld"
|
||||
repo.store[id] = domain.Dataset{
|
||||
ID: id, FileType: domain.FileTypeVector, StorageKey: "uid/data.geojson",
|
||||
SldStorageKey: &oldKey,
|
||||
}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
in := validSLDUpload()
|
||||
in.Filename = "new.sld"
|
||||
got, err := svc.UploadSLD(context.Background(), id, in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.SldStorageKey == nil || *got.SldStorageKey != "uid/sld/new.sld" {
|
||||
t.Fatalf("unexpected sld storage key: %v", got.SldStorageKey)
|
||||
}
|
||||
if len(store.removed) != 1 || store.removed[0] != oldKey {
|
||||
t.Fatalf("old sld object not removed: %v", store.removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_UploadSLD_RejectsBadExtension(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, StorageKey: "uid/data.geojson"}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
in := validSLDUpload()
|
||||
in.Filename = "style.zip"
|
||||
_, err := svc.UploadSLD(context.Background(), id, 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_UploadSLD_RejectsMismatchedContent(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, StorageKey: "uid/data.geojson"}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
in := validSLDUpload()
|
||||
in.Reader = strings.NewReader(`{"not":"xml"}`)
|
||||
_, err := svc.UploadSLD(context.Background(), id, in)
|
||||
if !errors.Is(err, domain.ErrValidation) {
|
||||
t.Fatalf("want ErrValidation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatasetService_DownloadSLD(t *testing.T) {
|
||||
id := uuid.New()
|
||||
repo := newStubDatasetRepo()
|
||||
repo.store[id] = domain.Dataset{ID: id, StorageKey: "uid/data.geojson"}
|
||||
store := &stubStore{}
|
||||
svc := newDatasetService(repo, store, true)
|
||||
|
||||
t.Run("not found before upload", func(t *testing.T) {
|
||||
_, _, err := svc.DownloadSLD(context.Background(), id)
|
||||
if !errors.Is(err, ErrSLDNotFound) {
|
||||
t.Fatalf("want ErrSLDNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("streams after upload", func(t *testing.T) {
|
||||
if _, err := svc.UploadSLD(context.Background(), id, validSLDUpload()); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
_, obj, err := svc.DownloadSLD(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
defer obj.Close()
|
||||
if _, err := io.ReadAll(obj); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDatasetService_Delete_RemovesObject(t *testing.T) {
|
||||
repo := newStubDatasetRepo()
|
||||
id := uuid.New()
|
||||
|
||||
Reference in New Issue
Block a user