diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e340997..c931858 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -43,15 +43,18 @@ jobs: # The Annex A tests that read or write data, against a database. The job above # runs the whole suite offline, where those tests skip by name; here they run. # - # The image is a RELEASED MobilityDB, not master: a conformance claim names a - # version somebody can install, and a claim resting on an unreleased branch names - # nothing a reader can obtain. + # ⛔ THE IMAGE IS MASTER, WHICH IS WHAT THIS TIER IS BUILT AGAINST. A job pinned to + # a release measures a MobilityDB the project no longer develops, so a defect fixed + # upstream reads here as a live one and blocks work that is not blocked — measured: + # deleting a temporal value that spans a whole composing sequence segfaults the + # backend on 1.3.0 and answers correctly on master. Naming a version somebody can + # install is a reason to ALSO run a release, never a reason to develop against one. conformance: - name: Conformance against a released MobilityDB + name: Conformance against MobilityDB master runs-on: ubuntu-latest services: mobilitydb: - image: mobilitydb/mobilitydb:18-3.6-1.3 + image: mobilitydb/mobilitydb:18-3.6-master env: POSTGRES_PASSWORD: conformance POSTGRES_DB: mfapi diff --git a/ats_live_test.go b/ats_live_test.go index e1ab1e3..02c2b83 100644 --- a/ats_live_test.go +++ b/ats_live_test.go @@ -19,6 +19,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "testing" ) @@ -259,3 +260,166 @@ func TestATSLiveSubTemporalValue(t *testing.T) { rec.Code, rec.Body.String()) } } + +// /conf/movingfeatures/features-post-success, mf-delete-success, +// tgsequence-post-success and tpgeometry-delete-success: the moving-feature +// lifecycle, on a feature this test creates so the fixture is untouched. +func TestATSLiveFeatureLifecycle(t *testing.T) { + mux, done := atsLiveMux(t) + defer done() + const items = "/collections/conformance/items" + + // A trajectory disjoint in time from the fixture's, so a merge into it cannot + // collide and the 409 the tier answers on overlap is not what is measured here. + const tg = `{"type":"MovingPoint","coordinates":[[575000,6220000],[576000,6220500]],` + + `"datetimes":["2026-03-01T08:00:00Z","2026-03-01T08:10:00Z"],"interpolation":"Linear"}` + rec := atsDo(t, mux, "POST", items, + `{"properties":{"mmsi":999999,"name":"ats_feature"},"temporalGeometry":`+tg+`}`) + if !oneOf(rec.Code, 201, 202) { + t.Fatalf("POST %s = %d, want 201 or 202 (%s)", items, rec.Code, rec.Body.String()) + } + fid := atsCreatedID(t, rec) + defer atsDo(t, mux, "DELETE", items+"/"+fid, "") + + if rec := atsDo(t, mux, "GET", items+"/"+fid, ""); rec.Code != 200 { + t.Fatalf("the created feature reads %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + + // A second sequence, later again, so the feature carries two and the delete + // below has one to remove while leaving the feature readable. + const tg2 = `{"type":"MovingPoint","coordinates":[[577000,6221000],[578000,6221500]],` + + `"datetimes":["2026-03-01T09:00:00Z","2026-03-01T09:10:00Z"],"interpolation":"Linear"}` + rec = atsDo(t, mux, "POST", items+"/"+fid+"/tgsequence", tg2) + if !oneOf(rec.Code, 201, 202) { + t.Fatalf("POST tgsequence = %d, want 201 or 202 (%s)", rec.Code, rec.Body.String()) + } + + rec = atsDo(t, mux, "DELETE", items+"/"+fid+"/tgsequence/2", "") + if !oneOf(rec.Code, 200, 202, 204) { + t.Errorf("DELETE tgsequence/2 = %d, want 200, 202 or 204 (%s)", rec.Code, rec.Body.String()) + } + + rec = atsDo(t, mux, "DELETE", items+"/"+fid, "") + if !oneOf(rec.Code, 200, 202, 204) { + t.Errorf("DELETE %s/%s = %d, want 200, 202 or 204 (%s)", items, fid, rec.Code, rec.Body.String()) + } + if rec := atsDo(t, mux, "GET", items+"/"+fid, ""); rec.Code != 404 { + t.Errorf("the deleted feature answers %d, want 404", rec.Code) + } +} + +// atsCreatedID reads the identifier a creation answers with, so a lifecycle +// removes what it made rather than guessing at an id the fixture may reuse. +func atsCreatedID(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("the creation response is not JSON: %v (%s)", err, rec.Body.String()) + } + for _, k := range []string{"id", "fid", "featureId"} { + switch v := doc[k].(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + } + } + t.Fatalf("the creation response names no identifier: %s", rec.Body.String()) + return "" +} + +// /conf/movingfeatures/tproperty-post-success and tpvalue-delete-success: values +// appended to a property this test creates, and one of them removed. +func TestATSLiveTemporalPropertyValues(t *testing.T) { + mux, done := atsLiveMux(t) + defer done() + const base = "/collections/conformance/items/1/tproperties" + const name = "ats_values" + defer atsDo(t, mux, "DELETE", base+"/"+name, "") + + rec := atsDo(t, mux, "POST", base, + `[{"name":"`+name+`","type":"TReal","form":"http://www.opengis.net/def/uom/UCUM/0/m",`+ + `"description":"added by the conformance suite",`+ + `"datetimes":["2026-01-01T08:00:00+00","2026-01-01T08:10:00+00"],`+ + `"values":[1.0,2.0],"interpolation":"Linear"}]`) + if !oneOf(rec.Code, 200, 201, 202) { + t.Fatalf("POST %s = %d, want 201 or 202 (%s)", base, rec.Code, rec.Body.String()) + } + + // Appending to the property itself, which is the singular route the plural one + // above does not exercise. The window is later so the append is disjoint. + rec = atsDo(t, mux, "POST", base+"/"+name, + `{"datetimes":["2026-01-01T09:00:00+00","2026-01-01T09:10:00+00"],`+ + `"values":[3.0,4.0],"interpolation":"Linear"}`) + if !oneOf(rec.Code, 200, 201, 202) { + t.Fatalf("POST %s/%s = %d, want 201 or 202 (%s)", base, name, rec.Code, rec.Body.String()) + } + + rec = atsDo(t, mux, "DELETE", base+"/"+name+"/1", "") + if !oneOf(rec.Code, 200, 202, 204) { + t.Errorf("DELETE %s/%s/1 = %d, want 200, 202 or 204 (%s)", base, name, rec.Code, rec.Body.String()) + } +} + +// /conf/movingfeatures/param-leaf-response and param-subtrajectory-response. +// +// ⛔ EACH ASSERTION IS THAT THE ANSWER MOVES, for the reason the subTemporalValue +// test states: a parameter read and not acted on is indistinguishable from one +// ignored, and only a difference a client can see separates them. +func TestATSLiveQueryParameters(t *testing.T) { + mux, done := atsLiveMux(t) + defer done() + + // leaf selects the instants it names, so the answer carries those and no more. + const prop = "/collections/conformance/items/1/tproperties/speed" + whole := atsDo(t, mux, "GET", prop, "") + if whole.Code != 200 { + t.Fatalf("GET the property = %d, want 200 (%s)", whole.Code, whole.Body.String()) + } + leaf := atsDo(t, mux, "GET", prop+"?leaf=2026-01-01T08:00:00Z,2026-01-01T08:10:00Z", "") + if leaf.Code != 200 { + t.Fatalf("GET with leaf = %d, want 200 (%s)", leaf.Code, leaf.Body.String()) + } + if n, m := atsInstantCount(t, leaf), atsInstantCount(t, whole); n == 0 || n >= m { + t.Errorf("leaf returns %d instants against %d unclipped, so the parameter changes "+ + "nothing a client receives", n, m) + } + + // subTrajectory clips the items' temporal geometry to the interval. + const items = "/collections/conformance/items" + all := atsDo(t, mux, "GET", items, "") + if all.Code != 200 { + t.Fatalf("GET items = %d, want 200 (%s)", all.Code, all.Body.String()) + } + sub := atsDo(t, mux, "GET", + items+"?subTrajectory=true&datetime=2026-01-01T08:00:00Z/2026-01-01T08:20:00Z", "") + if sub.Code != 200 { + t.Fatalf("GET items with subTrajectory = %d, want 200 (%s)", sub.Code, sub.Body.String()) + } + if len(sub.Body.Bytes()) >= len(all.Body.Bytes()) { + t.Errorf("subTrajectory returns %d bytes against %d unclipped, so the parameter changes "+ + "nothing a client receives", len(sub.Body.Bytes()), len(all.Body.Bytes())) + } + if rec := atsDo(t, mux, "GET", items+"?subTrajectory=true", ""); rec.Code != 400 { + t.Errorf("subTrajectory without a bounded interval = %d, want 400 (%s)", + rec.Code, rec.Body.String()) + } +} + +// atsInstantCount counts the instants a TemporalProperty document carries. +func atsInstantCount(t *testing.T, rec *httptest.ResponseRecorder) int { + t.Helper() + var doc struct { + ValueSequence []struct { + Datetimes []string `json:"datetimes"` + } `json:"valueSequence"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("the TemporalProperty document is not JSON: %v (%s)", err, rec.Body.String()) + } + var n int + for _, s := range doc.ValueSequence { + n += len(s.Datetimes) + } + return n +} diff --git a/ats_part1_test.go b/ats_part1_test.go index e03e499..56026a4 100644 --- a/ats_part1_test.go +++ b/ats_part1_test.go @@ -12,6 +12,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -32,6 +33,40 @@ const ( atsLive // needs a populated backend: stateful round-trips ) +// atsDischargedBy names the live test that discharges each abstract test needing a +// backend. +// +// ⛔ IT IS BUILT FROM WHAT A TEST CALLS, NEVER FROM WHAT ITS COMMENT CLAIMS. The +// temporal-property lifecycle posts to the PLURAL route, so it discharges +// tproperties-post-success and not the singular tproperty-post-success its comment +// once named — and a report keyed on comments counted that abstract test twice over, +// once as covered and once as awaiting an assertion. +// +// ⛔ AN UNCONDITIONAL "awaits its live assertion" UNDERSTATES CONFORMANCE. A row a +// named group already asserts reads as outstanding, so the tier's own coverage report +// is the thing that makes the claim look worse than the suite is. The entry is what +// tells the two apart, and TestATSEveryLiveRowIsDischarged keeps every row carrying +// one that names a test which exists. +var atsDischargedBy = map[string]string{ + "/conf/mf-collection/collections-post-success": "TestATSLiveCollectionLifecycle", + "/conf/mf-collection/collections-put-success": "TestATSLiveCollectionLifecycle", + "/conf/mf-collection/collections-delete-success": "TestATSLiveCollectionLifecycle", + "/conf/movingfeatures/features-get-success": "TestATSLiveFeatures", + "/conf/movingfeatures/features-post-success": "TestATSLiveFeatureLifecycle", + "/conf/movingfeatures/mf-get-success": "TestATSLiveFeatures", + "/conf/movingfeatures/mf-delete-success": "TestATSLiveFeatureLifecycle", + "/conf/movingfeatures/tgsequence-post-success": "TestATSLiveFeatureLifecycle", + "/conf/movingfeatures/tpgeometry-delete-success": "TestATSLiveFeatureLifecycle", + "/conf/movingfeatures/tproperties-post-success": "TestATSLiveTemporalPropertyLifecycle", + "/conf/movingfeatures/tproperty-get-success": "TestATSLiveTemporalProperties", + "/conf/movingfeatures/tproperty-post-success": "TestATSLiveTemporalPropertyValues", + "/conf/movingfeatures/tproperty-delete-success": "TestATSLiveTemporalPropertyLifecycle", + "/conf/movingfeatures/tpvalue-delete-success": "TestATSLiveTemporalPropertyValues", + "/conf/movingfeatures/param-leaf-response": "TestATSLiveQueryParameters", + "/conf/movingfeatures/param-subtrajectory-response": "TestATSLiveQueryParameters", + "/conf/movingfeatures/param-subtemporalvalue-response": "TestATSLiveSubTemporalValue", +} + // atsTest is one abstract test of Annex A. type atsTest struct { id string // the identifier the standard gives it @@ -261,7 +296,12 @@ func TestATSLiveOperations(t *testing.T) { if dsn == "" { t.Skipf("needs a populated backend: set MFAPI_DSN to run %s (%s)", a.id, a.purpose) } - t.Skipf("%s awaits its live assertion (%s)", a.id, a.purpose) + by := atsDischargedBy[a.id] + if by == "" { + t.Errorf("%s names no live test that discharges it (%s)", a.id, a.purpose) + return + } + t.Logf("%s is discharged by %s (%s)", a.id, by, a.purpose) }) } } @@ -295,3 +335,37 @@ func TestATSCoverageReport(t *testing.T) { t.Log(fmt.Sprintf("%d abstract tests: %d served, %d not served, %d need a backend", len(rows), served, missing, live)) } + +// Every abstract test needing a backend names a live test that discharges it, and +// every name is a test that exists. +// +// ⛔ A MAP ENTRY NAMING A TEST THAT DOES NOT EXIST IS WORSE THAN NO ENTRY: it reports +// an abstract test as discharged by nothing at all, which is the failure the entry +// was added to end. The source is read for the declaration rather than trusted. +func TestATSEveryLiveRowIsDischarged(t *testing.T) { + src, err := os.ReadFile("ats_live_test.go") + if err != nil { + t.Fatal(err) + } + var live, undischarged int + for _, a := range atsPart1 { + if a.kind != atsLive { + continue + } + live++ + by := atsDischargedBy[a.id] + if by == "" { + undischarged++ + t.Errorf("%s needs a backend and names no live test that discharges it (%s)", + a.id, a.purpose) + continue + } + if !bytes.Contains(src, []byte("func "+by+"(")) { + t.Errorf("%s names %s, which ats_live_test.go does not declare", a.id, by) + } + } + if live == 0 { + t.Fatal("the registry carries no live row, so this test would assert nothing") + } + t.Logf("%d abstract tests need a backend; %d are undischarged", live, undischarged) +} diff --git a/main.go b/main.go index 21390ca..3484d02 100644 --- a/main.go +++ b/main.go @@ -1646,7 +1646,9 @@ func postTgSequence(w http.ResponseWriter, r *http.Request) { httpErr(w, 404, "feature not found") return } - writeJSON(w, 200, map[string]any{"message": "appended", "id": strconv.Itoa(fid)}) + // Annex A requires 201 or 202 of this POST: appending a temporal primitive + // geometry creates a member of the feature's temporal geometry. + writeJSON(w, 201, map[string]any{"message": "appended", "id": strconv.Itoa(fid)}) } // postTProperties registers one or more stored temporal properties on a feature @@ -1798,7 +1800,9 @@ func postTPropertyValues(w http.ResponseWriter, r *http.Request) { httpErr(w, 404, "unknown temporal property: "+name) return } - writeJSON(w, 200, map[string]any{"message": "appended", "name": name}) + // Annex A requires 201 or 202 of this POST: appending values creates temporal + // primitive values of the property. + writeJSON(w, 201, map[string]any{"message": "appended", "name": name}) } // deleteTProperty removes a stored temporal property from a feature.