Routes saved in the Fly App's mission library now sync to a new per-user routes collection on the API Server, in the WaypointMissionSpec JSON format. The library pulls on load and pushes on every save and delete, merges last-writer-wins by updatedAt, keeps unacknowledged deletes as tombstones, and keeps working offline. Each signed-in account has its own copy on the device; routes saved while signed out go to the first account that signs in. The API Server gets list / get / upsert / delete route endpoints with a PocketBase migration, and the Web App a read-only Routes view on the dashboard (rebuilt dist). Tested against a fake PocketBase and a fake server only; not yet tried with a real sign-in on the tablet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
8.0 KiB
Go
229 lines
8.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A route as the Fly App's WaypointMissionSpec.toJson() writes it.
|
|
const sampleRoute = `{"version":1,"id":"wp-m1x2y3","name":" Field ","points":[{"lat":55.1,"lon":12.2},{"lat":55.2,"lon":12.3}],"pois":[],` +
|
|
`"cruiseSpeed":8.0,"headingMode":"auto","finishAction":"goHome","pathMode":"straight","exitOnSignalLost":true,` +
|
|
`"gotoFirst":"safely","repeatTimes":1,"updatedAt":"2026-10-20T08:30:00.123Z"}`
|
|
|
|
func TestParseRouteSpec(t *testing.T) {
|
|
s, err := parseRouteSpec([]byte(sampleRoute))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if s.id != "wp-m1x2y3" || s.name != "Field" || s.pointCount != 2 {
|
|
t.Fatalf("got %+v", s)
|
|
}
|
|
want := time.Date(2026, 10, 20, 8, 30, 0, 123e6, time.UTC)
|
|
if !s.updatedAt.Equal(want) {
|
|
t.Fatalf("updatedAt = %v, want %v", s.updatedAt, want)
|
|
}
|
|
|
|
bad := map[string]string{
|
|
"not json": `{`,
|
|
"no version": `{"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
|
"version 2": `{"version":2,"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
|
"no id": `{"version":1,"updatedAt":"2026-10-20T08:30:00Z"}`,
|
|
"filter quote": `{"version":1,"id":"a\" || 1=1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
|
"no updatedAt": `{"version":1,"id":"wp-1"}`,
|
|
"bad updatedAt": `{"version":1,"id":"wp-1","updatedAt":"yesterday"}`,
|
|
}
|
|
for name, body := range bad {
|
|
if _, err := parseRouteSpec([]byte(body)); err == nil {
|
|
t.Errorf("%s: accepted", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRouteIsNewer(t *testing.T) {
|
|
at := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC)
|
|
if !routeIsNewer("2026-10-20T08:30:00.000001Z", at) {
|
|
t.Error("a later stored stamp must win")
|
|
}
|
|
if routeIsNewer("2026-10-20T08:30:00.000000Z", at) {
|
|
t.Error("an equal stamp lets the write through (idempotent retry)")
|
|
}
|
|
if routeIsNewer("2026-10-20T08:29:59Z", at) {
|
|
t.Error("an older stored stamp loses")
|
|
}
|
|
if routeIsNewer("", at) {
|
|
t.Error("a missing stored stamp never blocks")
|
|
}
|
|
}
|
|
|
|
func TestRouteTimeLayoutSortsAsText(t *testing.T) {
|
|
a := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC).Format(routeTimeLayout)
|
|
b := time.Date(2026, 10, 20, 8, 30, 0, 500e6, time.UTC).Format(routeTimeLayout)
|
|
if !(a < b) {
|
|
t.Fatalf("%q should sort before %q", a, b)
|
|
}
|
|
if _, ok := parseRouteTime(a); !ok {
|
|
t.Fatalf("stored stamp %q does not parse back", a)
|
|
}
|
|
}
|
|
|
|
func TestRouteScope(t *testing.T) {
|
|
user := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
|
admin := &callerIdentity{ID: "a1", Role: roleAdmin, OrgID: "o1"}
|
|
orgless := &callerIdentity{ID: "a2", Role: roleAdmin}
|
|
super := &callerIdentity{ID: "s1", Role: roleSuperadmin}
|
|
|
|
cases := []struct {
|
|
who *callerIdentity
|
|
mine bool
|
|
want string
|
|
}{
|
|
{user, false, `owner = "u1"`},
|
|
{admin, false, `organization = "o1"`},
|
|
{admin, true, `owner = "a1"`},
|
|
{orgless, false, `owner = "a2"`},
|
|
{super, false, ``},
|
|
{super, true, `owner = "s1"`},
|
|
}
|
|
for _, c := range cases {
|
|
if got := routeScopeFilter(c.who, c.mine); got != c.want {
|
|
t.Errorf("%s mine=%v: %q, want %q", c.who.ID, c.mine, got, c.want)
|
|
}
|
|
}
|
|
|
|
pilots := routeRecord{Owner: "u1", Organization: "o1"}
|
|
other := routeRecord{Owner: "u9", Organization: "o2"}
|
|
if !canManageRoute(user, pilots) || canManageRoute(user, routeRecord{Owner: "u2", Organization: "o1"}) {
|
|
t.Error("a user manages only their own routes")
|
|
}
|
|
if !canManageRoute(admin, pilots) || canManageRoute(admin, other) {
|
|
t.Error("an admin manages their organization's routes only")
|
|
}
|
|
if !canManageRoute(super, other) {
|
|
t.Error("a superadmin manages everything")
|
|
}
|
|
}
|
|
|
|
// fakeRoutesPB is just enough PocketBase for the route handlers: superuser
|
|
// auth, a route_id lookup, create and patch.
|
|
type fakeRoutesPB struct {
|
|
mu sync.Mutex
|
|
recs map[string]map[string]any // record id → fields
|
|
n int
|
|
}
|
|
|
|
func (f *fakeRoutesPB) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
const coll = "/api/collections/routes/records"
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/auth-with-password"):
|
|
writeJSON(w, 200, map[string]any{"token": "su"})
|
|
case r.Method == http.MethodGet && r.URL.Path == coll:
|
|
items := []map[string]any{}
|
|
filter := r.URL.Query().Get("filter")
|
|
for _, rec := range f.recs {
|
|
if strings.HasPrefix(filter, "route_id = ") && filter != `route_id = "`+rec["route_id"].(string)+`"` {
|
|
continue
|
|
}
|
|
items = append(items, rec)
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items})
|
|
case r.Method == http.MethodPost && r.URL.Path == coll:
|
|
var rec map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&rec)
|
|
f.n++
|
|
rec["id"] = "rec" + strconv.Itoa(f.n)
|
|
f.recs[rec["id"].(string)] = rec
|
|
writeJSON(w, 200, rec)
|
|
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, coll+"/"):
|
|
rec, ok := f.recs[strings.TrimPrefix(r.URL.Path, coll+"/")]
|
|
if !ok {
|
|
writeError(w, 404, "missing")
|
|
return
|
|
}
|
|
var patch map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&patch)
|
|
for k, v := range patch {
|
|
rec[k] = v
|
|
}
|
|
writeJSON(w, 200, rec)
|
|
default:
|
|
writeError(w, 404, "unexpected "+r.Method+" "+r.URL.Path)
|
|
}
|
|
}
|
|
|
|
func TestRouteHandlersLastWriterWins(t *testing.T) {
|
|
pb := &fakeRoutesPB{recs: map[string]map[string]any{}}
|
|
ts := httptest.NewServer(pb)
|
|
defer ts.Close()
|
|
s := &Server{admin: newAdminClient(ts.URL, "svc@x", "pw")}
|
|
pilot := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
|
stranger := &callerIdentity{ID: "u2", Role: roleUser, OrgID: "o2"}
|
|
|
|
call := func(who *callerIdentity, h http.HandlerFunc, method, target, body string) (int, map[string]any) {
|
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
|
req.SetPathValue("id", strings.Split(strings.TrimPrefix(req.URL.Path, "/api/routes/"), "?")[0])
|
|
req = req.WithContext(context.WithValue(req.Context(), ctxCaller, who))
|
|
rec := httptest.NewRecorder()
|
|
h(rec, req)
|
|
var out map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
return rec.Code, out
|
|
}
|
|
withTime := func(at string) string {
|
|
return strings.Replace(sampleRoute, "2026-10-20T08:30:00.123Z", at, 1)
|
|
}
|
|
|
|
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-other", sampleRoute); code != 422 {
|
|
t.Fatalf("id mismatch: %d", code)
|
|
}
|
|
code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute)
|
|
if code != 201 {
|
|
t.Fatalf("create: %d %v", code, out)
|
|
}
|
|
route := out["route"].(map[string]any)
|
|
if route["id"] != "wp-m1x2y3" || route["name"] != "Field" || route["mine"] != true || route["pointCount"].(float64) != 2 {
|
|
t.Fatalf("created view: %v", route)
|
|
}
|
|
stored := pb.recs["rec1"]
|
|
if stored["owner"] != "u1" || stored["organization"] != "o1" {
|
|
t.Fatalf("owner/org not stamped: %v", stored)
|
|
}
|
|
|
|
if code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T08:00:00Z")); code != 409 || out["route"] == nil {
|
|
t.Fatalf("older write: %d %v", code, out)
|
|
}
|
|
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute); code != 200 {
|
|
t.Fatalf("idempotent retry: %d", code)
|
|
}
|
|
if code, _ := call(stranger, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-21T00:00:00Z")); code != 409 {
|
|
t.Fatalf("someone else's id: %d", code)
|
|
}
|
|
if code, _ := call(stranger, s.handleGetRoute, "GET", "/api/routes/wp-m1x2y3", ""); code != 404 {
|
|
t.Fatalf("stranger get: %d", code)
|
|
}
|
|
|
|
if code, _ := call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T08:00:00Z", ""); code != 409 {
|
|
t.Fatalf("delete older than the edit: %d", code)
|
|
}
|
|
code, out = call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T09:00:00Z", "")
|
|
if code != 200 || out["route"].(map[string]any)["deleted"] != true {
|
|
t.Fatalf("delete: %d %v", code, out)
|
|
}
|
|
if stored["route_updated_at"] != "2026-10-20T09:00:00.000000Z" {
|
|
t.Fatalf("tombstone stamp: %v", stored["route_updated_at"])
|
|
}
|
|
// A save newer than the delete brings the route back.
|
|
code, out = call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T10:00:00Z"))
|
|
if code != 200 || out["route"].(map[string]any)["deleted"] != false {
|
|
t.Fatalf("resurrect: %d %v", code, out)
|
|
}
|
|
}
|