Skip to content
Merged
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
105 changes: 87 additions & 18 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,35 +43,104 @@ 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 DATABASE IS MOBILITYDB MASTER, BUILT HERE FROM SOURCE. 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.
#
# ⛔ AND A PUBLISHED IMAGE IS NOT THE SOURCE. The mobilitydb/mobilitydb tags are
# built by MobilityDB/MobilityDB-docker, a separate repository on its own schedule,
# so what they carry is that repository's packaging rather than the source this
# tier is written against: `18-3.6-master` fails to load the library on a missing
# libgdal.so.39, and `17-3.5-master` exits its own entrypoint because the
# pointcloud extension it asks for is not available. Pinning whichever tag happens
# to work makes the packaging the source of truth and dates the measurement to
# whenever that tag was pushed. The recipe below is MobilityDB's own
# .github/workflows/pgversion.yml, read from the checkout under test.
conformance:
name: Conformance against a released MobilityDB
runs-on: ubuntu-latest
services:
mobilitydb:
image: mobilitydb/mobilitydb:18-3.6-1.3
env:
POSTGRES_PASSWORD: conformance
POSTGRES_DB: mfapi
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d mfapi"
--health-interval 5s
--health-timeout 5s
--health-retries 20
name: Conformance against MobilityDB master
runs-on: ubuntu-24.04
env:
PGVERSION: "18"
POSTGISVERSION: "3"
MFAPI_DSN: postgres://postgres:conformance@127.0.0.1:5432/mfapi?sslmode=disable
steps:
- uses: actions/checkout@v4

- name: Check out MobilityDB master
uses: actions/checkout@v4
with:
repository: MobilityDB/MobilityDB
ref: master
path: mobilitydb

- name: Name the commit under test
run: git -C mobilitydb log -1 --format='MobilityDB master %H %s'

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Remove the runner's PostgreSQL, so apt.postgresql.org answers
run: |
sudo service postgresql stop || true
sudo apt-get --purge remove postgresql* -y || true
sudo rm -rf /var/lib/postgresql/ /etc/postgresql/ /var/log/postgresql/ || true

# MobilityDB's own apt hardening, taken from the checkout above rather than
# copied: the runner image ships two package sources that answer 403 from the
# Azure runners, which fails an apt-get update asking only for Ubuntu packages.
- uses: ./mobilitydb/.github/actions/apt-resilient

- name: Add the PostgreSQL APT repository
run: |
sudo apt-get install -y curl ca-certificates gnupg
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
codename=$(lsb_release -cs)
echo "deb http://apt.postgresql.org/pub/repos/apt/ ${codename}-pgdg main ${PGVERSION}" \
| sudo tee /etc/apt/sources.list.d/pgdg.list

- name: Install the build and server dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgeos++-dev \
libproj-dev \
libgdal-dev \
libjson-c-dev \
postgresql-${PGVERSION} \
postgresql-${PGVERSION}-postgis-${POSTGISVERSION} \
postgresql-server-dev-${PGVERSION}

# The default family set is what this tier reads: temporal geometry points
# and the scalar temporal types. The optional families carry dependencies of
# their own and no route here reaches one, so the build stays at the defaults
# and the job measures the surface it uses.
- name: Build and install MobilityDB
run: |
export PATH=/usr/lib/postgresql/${PGVERSION}/bin:$PATH
mkdir mobilitydb/build
cd mobilitydb/build
cmake -DCMAKE_BUILD_TYPE=Release ..
make -j "$(nproc)"
sudo make install

# ⛔ LOADING THE EXTENSION IS THE ASSERTION THE PUBLISHED IMAGES FAILED. Doing
# it in a step of its own, ahead of the fixture, is what makes a database that
# cannot carry MobilityDB say so here rather than inside a fixture load.
- name: Create the database and load the extension
run: |
sudo service postgresql start
sudo -u postgres psql -v ON_ERROR_STOP=1 \
-c "ALTER USER postgres PASSWORD 'conformance'"
sudo -u postgres createdb mfapi
psql "$MFAPI_DSN" -v ON_ERROR_STOP=1 -q \
-c "CREATE EXTENSION mobilitydb CASCADE"
psql "$MFAPI_DSN" -X -A -t -c "SELECT mobilitydbFullVersion()"

- name: Load the conformance fixture
run: psql "$MFAPI_DSN" -v ON_ERROR_STOP=1 -q -f tutorial/setup/load_conformance.sql

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)
}
Loading
Loading