package main import ( "bytes" "compress/gzip" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // A tile in central Berlin. var testTile = mapTile{Z: 14, X: 8802, Y: 5373} // ---------- A minimal vector-tile encoder for the tests ---------- func pbVarint(v uint64) []byte { var b []byte for v >= 0x80 { b = append(b, byte(v)|0x80) v >>= 7 } return append(b, byte(v)) } func pbKey(num, wire int) []byte { return pbVarint(uint64(num<<3 | wire)) } func pbBytes(num int, data []byte) []byte { return append(append(pbKey(num, 2), pbVarint(uint64(len(data)))...), data...) } func pbUint(num int, v uint64) []byte { return append(pbKey(num, 0), pbVarint(v)...) } func pbPacked(num int, vs []uint32) []byte { var d []byte for _, v := range vs { d = append(d, pbVarint(uint64(v))...) } return pbBytes(num, d) } func zz(v int) uint32 { return uint32((v << 1) ^ (v >> 31)) } // lineCmds encodes line strings as MoveTo/LineTo commands with relative moves. func lineCmds(lines ...[][2]int) []uint32 { var out []uint32 x, y := 0, 0 for _, l := range lines { out = append(out, 1|1<<3, zz(l[0][0]-x), zz(l[0][1]-y)) x, y = l[0][0], l[0][1] out = append(out, uint32(2|(len(l)-1)<<3)) for _, p := range l[1:] { out = append(out, zz(p[0]-x), zz(p[1]-y)) x, y = p[0], p[1] } } return out } type testFeature struct { class int // index into the values below geomType uint64 geometry []uint32 } // testVectorTile has a water layer (ignored) and a transportation layer with the given extent. func testVectorTile(extent uint64) []byte { values := []string{"primary", "minor", "service", "motorway"} features := []testFeature{ // A primary road with a needless middle point. {0, 2, lineCmds([][2]int{{100, 100}, {200, 200}, {300, 300}})}, // A minor street in two pieces. {1, 2, lineCmds([][2]int{{0, 500}, {400, 500}}, [][2]int{{600, 500}, {900, 520}})}, // A service road and a motorway as a polygon: neither is drawn. {2, 2, lineCmds([][2]int{{10, 10}, {20, 20}})}, {3, 3, lineCmds([][2]int{{10, 10}, {20, 20}})}, } var layer []byte layer = append(layer, pbUint(15, 2)...) layer = append(layer, pbBytes(1, []byte("transportation"))...) for _, f := range features { var feat []byte feat = append(feat, pbPacked(2, []uint32{0, uint32(f.class)})...) feat = append(feat, pbUint(3, f.geomType)...) feat = append(feat, pbPacked(4, f.geometry)...) layer = append(layer, pbBytes(2, feat)...) } layer = append(layer, pbBytes(3, []byte("class"))...) for _, v := range values { layer = append(layer, pbBytes(4, pbBytes(1, []byte(v)))...) } layer = append(layer, pbUint(5, extent)...) water := append(pbBytes(1, []byte("water")), pbUint(5, 4096)...) return append(pbBytes(3, water), pbBytes(3, layer)...) } // ---------- A fake tile host ---------- type fakeHost struct { *httptest.Server tiles atomic.Int32 status int delay time.Duration gzip bool } func newFakeHost(t *testing.T) *fakeHost { t.Helper() h := &fakeHost{status: http.StatusOK} mux := http.NewServeMux() mux.HandleFunc("GET /planet", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"tilejson":"3.0.0","tiles":["%s/planet/build1/{z}/{x}/{y}.pbf"]}`, h.URL) }) mux.HandleFunc("GET /planet/build1/{z}/{x}/{y}", func(w http.ResponseWriter, r *http.Request) { h.tiles.Add(1) if !strings.Contains(r.UserAgent(), "nils-mg4-server") { t.Errorf("user agent %q", r.UserAgent()) } time.Sleep(h.delay) if h.status != http.StatusOK { http.Error(w, "busy", h.status) return } body := testVectorTile(4096) if h.gzip { var buf bytes.Buffer zw := gzip.NewWriter(&buf) _, _ = zw.Write(body) _ = zw.Close() body = buf.Bytes() } _, _ = w.Write(body) }) h.Server = httptest.NewTLSServer(mux) return h } func mapsAPIWith(host *fakeHost, cacheDir string) http.Handler { api, _ := newTestAPI() api.Maps = NewMaps(host.URL+"/planet", cacheDir, mapUserAgent) api.Maps.Client = host.Client() return api.Routes() } func tileBody(t mapTile) string { return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y) } func decodeAnswer(t *testing.T, rec *httptest.ResponseRecorder) roadTile { t.Helper() if rec.Code != 200 { t.Fatalf("%d %s", rec.Code, rec.Body) } var got roadTile if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } return got } // ---------- Tests ---------- func TestVectorTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) { lines, err := decodeRoads(testVectorTile(4096)) if err != nil { t.Fatal(err) } if len(lines) != 3 || lines[0].class != 1 || lines[1].class != 3 || lines[2].class != 3 { t.Fatalf("lines %+v", lines) } if fmt.Sprint(lines[1].points) != "[[0 500] [400 500]]" || fmt.Sprint(lines[2].points) != "[[600 500] [900 520]]" { t.Fatalf("pieces %v %v", lines[1].points, lines[2].points) } // Another extent is scaled to 4096. half, err := decodeRoads(testVectorTile(8192)) if err != nil || fmt.Sprint(half[0].points[2]) != "[150 150]" { t.Fatalf("scaled %v %v", half, err) } body, err := buildTile(testTile, testVectorTile(4096)) if err != nil { t.Fatal(err) } if !strings.Contains(string(body), `{"c":1,"p":[100,100,300,300]}`) { t.Fatalf("the straight road keeps only its ends: %s", body) } } func TestMalformedVectorTilesAreRefused(t *testing.T) { good := testVectorTile(4096) for name, raw := range map[string][]byte{ "cut short": good[:len(good)-7], "bad wire type": {0x1f, 0x00}, "length too long": {0x1a, 0x7f, 0x01}, } { if _, err := decodeRoads(raw); err == nil { t.Errorf("%s: accepted", name) } } if lines, err := decodeRoads(nil); err != nil || len(lines) != 0 { t.Fatalf("an empty tile is an empty map: %v %v", lines, err) } } func TestMapTilesAreFetchedOnceAndKeptOnDisk(t *testing.T) { host := newFakeHost(t) host.gzip = true // some hosts gzip whatever was asked defer host.Close() dir := t.TempDir() h := mapsAPIWith(host, dir) if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 { t.Fatalf("without the car's token: %d", rec.Code) } got := decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))) if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y || len(got.Ways) != 3 { t.Fatalf("tile %+v", got) } decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))) // A restart: a new server with the same directory asks the host nothing. decodeAnswer(t, do(t, mapsAPIWith(host, dir), "POST", "/v1/map/roads", testToken, tileBody(testTile))) if n := host.tiles.Load(); n != 1 { t.Fatalf("tile fetched %d times", n) } } func TestASlowTileIsFinishedForTheCarsNextTry(t *testing.T) { previous := mapWait mapWait = 50 * time.Millisecond defer func() { mapWait = previous }() host := newFakeHost(t) host.delay = 300 * time.Millisecond defer host.Close() h := mapsAPIWith(host, "") rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)) if rec.Code != 503 || rec.Header().Get("Retry-After") == "" { t.Fatalf("the car should hear 'try again': %d", rec.Code) } time.Sleep(600 * time.Millisecond) decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))) if n := host.tiles.Load(); n != 1 { t.Fatalf("tile fetched %d times", n) } } func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) { host := newFakeHost(t) defer host.Close() req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile))) req.Header.Set("Authorization", "Bearer "+testToken) req.Header.Set("Accept-Encoding", "gzip") rec := httptest.NewRecorder() mapsAPIWith(host, "").ServeHTTP(rec, req) if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" { t.Fatalf("%d %v", rec.Code, rec.Header()) } zr, err := gzip.NewReader(rec.Body) if err != nil { t.Fatal(err) } plain, _ := io.ReadAll(zr) if !bytes.Contains(plain, []byte(`"extent":4096`)) { t.Fatalf("%s", plain) } } func TestMapRequestsAreChecked(t *testing.T) { host := newFakeHost(t) defer host.Close() h := mapsAPIWith(host, "") for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`, `{"z":14,"x":1,"y":16384}`, `not json`} { if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 { t.Errorf("%s: %d", body, rec.Code) } } if host.tiles.Load() != 0 { t.Fatal("a bad request reached the tile host") } } func TestAFailingHostIsATemporaryFailureAndNotCached(t *testing.T) { var logged bytes.Buffer previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil))) defer slog.SetDefault(previous) host := newFakeHost(t) host.status = http.StatusBadGateway defer host.Close() h := mapsAPIWith(host, t.TempDir()) for i := 0; i < 2; i++ { if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 { t.Fatalf("%d", rec.Code) } } if n := host.tiles.Load(); n != 2 { t.Fatalf("a failure was cached: %d fetches", n) } // Where the car is stays out of the log. out := logged.String() if !strings.Contains(out, "tile host answered 502") || strings.Contains(out, "8802") || strings.Contains(out, "5373") { t.Fatalf("log:\n%s", out) } } func TestATileIndexWithoutHttpsIsRefused(t *testing.T) { index := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"tiles":["http://tiles.example.com/{z}/{x}/{y}.pbf"]}`)) })) defer index.Close() api, _ := newTestAPI() api.Maps = NewMaps(index.URL, "", mapUserAgent) api.Maps.Client = index.Client() if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 { t.Fatalf("%d", rec.Code) } } func TestMapsAreOffWithoutATileSource(t *testing.T) { api, _ := newTestAPI() if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 { t.Fatalf("%d", rec.Code) } } func TestMapTilesURLConfiguration(t *testing.T) { base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", "INGEST_TOKEN": "0123456789abcdef01234567"} load := func(tiles string) (config, error) { return loadConfig(func(k string) string { if k == "MAP_TILES_URL" { return tiles } return base[k] }) } if c, err := load(""); err != nil || c.mapTilesURL != DefaultMapTilesURL { t.Fatalf("default: %q %v", c.mapTilesURL, err) } if c, err := load("off"); err != nil || c.mapTilesURL != "" { t.Fatalf("off: %q %v", c.mapTilesURL, err) } if _, err := load("http://tiles.example.com/planet"); err == nil { t.Fatal("plain http accepted") } } func TestSimplification(t *testing.T) { line := [][2]int{{0, 0}, {10, 1}, {20, 0}, {30, 40}} got := simplify(line, 4) if len(got) != 3 || got[1] != [2]int{20, 0} { t.Fatalf("%v", got) } }