Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions ats_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
)
Expand Down Expand Up @@ -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
}
76 changes: 75 additions & 1 deletion ats_part1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -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
Expand Down Expand Up @@ -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)
})
}
}
Expand Down Expand Up @@ -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)
}
8 changes: 6 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading