The topbar's coordinate display was plain decimal degrees ("48.14611,
17.17722") — switched to DMS (48°08'46"N 17°10'38"E), the more
GPS-unit-like format Locus Pro itself uses, and made it click-to-copy
(plain decimal, since that's what pastes cleanly into another map/GPS
app — not the DMS text actually shown).
Added sunrise/sunset + a countdown to whichever's next, for the same
map-center point the coordinates already describe. Vendored SunCalc
(BSD-2-Clause, github.com/mourner/suncalc) rather than hand-rolling
the astronomical math — small, dependency-free, and correctness here
has real pitfalls (equation of time, refraction, polar day/night) a
first-pass reimplementation would risk getting subtly wrong.
Also added a test asserting every public/js/*.js module has a
versioned importmap entry in layout.erb — caught that overlays.js
(from the previous commit) was missing one, which would have quietly
skipped ASSET_VERSION's cache-busting for that file after every
deploy.
423 lines
17 KiB
Ruby
423 lines
17 KiB
Ruby
require_relative "test_helper"
|
|
require "tempfile"
|
|
|
|
class AppTest < Minitest::Test
|
|
include AppTestHelper
|
|
|
|
def test_health
|
|
response = request.get("/api/health")
|
|
assert_equal 200, response.status
|
|
assert_equal({ "status" => "ok", "revision" => "dev" }, json_body(response))
|
|
end
|
|
|
|
def test_index_renders_with_asset_versioning_and_importmap
|
|
response = request.get("/")
|
|
assert_equal 200, response.status
|
|
assert_match(/\/js\/map\.js\?v=\d+/, response.body)
|
|
assert_includes response.body, "importmap"
|
|
assert_includes response.body, "livetrack-panel"
|
|
end
|
|
|
|
# Every public/js/*.js module map.js (or something it imports) can reach
|
|
# needs its own versioned importmap entry (views/layout.erb) or it skips
|
|
# ASSET_VERSION's whole cache-busting story — easy to forget when adding a
|
|
# new module, since the app still works fine locally either way (a plain
|
|
# unmapped import just resolves straight to the unversioned file).
|
|
# map.js itself is excluded: it's the one entry point loaded directly by
|
|
# its own <script> tag, not imported by anything, so it has no importmap
|
|
# entry of its own by design.
|
|
def test_every_own_js_module_has_a_versioned_importmap_entry
|
|
response = request.get("/")
|
|
modules = Dir.glob(File.join(__dir__, "..", "public", "js", "*.js")).map { |path| File.basename(path) } - ["map.js"]
|
|
modules.each do |name|
|
|
assert_match(%r{"/js/#{Regexp.escape(name)}":\s*"/js/#{Regexp.escape(name)}\?v=\d+"}, response.body, "#{name} is missing from the importmap")
|
|
end
|
|
end
|
|
|
|
def test_offline_tiles_reports_unavailable_when_no_pmtiles_present
|
|
response = request.get("/api/offline-tiles")
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_kiwix_reports_unavailable_when_no_url_configured
|
|
response = request.get("/api/kiwix")
|
|
assert_equal({ "available" => false, "url" => "" }, json_body(response))
|
|
end
|
|
|
|
def test_kiwix_proxy_404s_when_no_url_configured
|
|
assert_equal 404, request.get("/kiwix/search").status
|
|
end
|
|
|
|
def test_kiwix_nearby_404s_when_no_url_configured
|
|
assert_equal 404, request.get("/api/kiwix/nearby", params: { south: "48", west: "17", north: "48.1", east: "17.1" }).status
|
|
end
|
|
|
|
def test_gnss_position_unavailable_with_no_fix
|
|
response = request.get("/api/gnss/position", **logged_in_env)
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
end
|
|
|
|
def test_gnss_position_requires_login
|
|
assert_equal 401, request.get("/api/gnss/position").status
|
|
end
|
|
|
|
# --- auth guard ---
|
|
|
|
def test_write_routes_require_login
|
|
assert_equal 401, request.post("/api/gpx").status
|
|
assert_equal 401, request.post("/api/gpx/folder").status
|
|
assert_equal 401, request.delete("/api/gpx/file/x.gpx").status
|
|
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
|
assert_equal 401, request.post("/api/gpx/move").status
|
|
assert_equal 401, request.post("/api/plugins/test-plugin/run").status
|
|
end
|
|
|
|
# --- GPX/FIT upload and the tree ---
|
|
|
|
def test_gpx_upload_and_tree
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
response = post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_equal ["ride.gpx"], body["paths"]
|
|
assert_equal ["ride.gpx"], body["tree"]["children"].map { |c| c["name"] }
|
|
end
|
|
|
|
def test_gpx_file_returns_geojson_with_track_properties
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx")
|
|
assert_equal 200, response.status
|
|
track = json_body(response)["features"].find { |f| f["properties"]["kind"] == "track" }
|
|
assert_operator track["properties"]["distance_meters"], :>, 0
|
|
end
|
|
|
|
def test_geojson_upload_is_served_back_unchanged
|
|
content = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
|
|
response = post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["spots.geojson"], json_body(response)["paths"]
|
|
|
|
file_response = request.get("/api/gpx/file/spots.geojson")
|
|
assert_equal 200, file_response.status
|
|
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
|
|
end
|
|
|
|
def test_download_of_a_converted_gpx_names_it_gpx_geojson
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
|
|
end
|
|
|
|
def test_download_of_a_native_geojson_keeps_its_own_name
|
|
content = '{"type":"FeatureCollection","features":[]}'
|
|
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/spots.geojson?download=geojson", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
|
end
|
|
|
|
def test_download_of_the_original_file_serves_it_unconverted
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/ride.gpx?download=original", **logged_in_env)
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx"'
|
|
assert_equal content, response.body
|
|
end
|
|
|
|
def test_download_requires_login
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=geojson").status
|
|
assert_equal 401, request.get("/api/gpx/file/ride.gpx?download=original").status
|
|
# Plain viewing (no ?download) stays open to anyone.
|
|
assert_equal 200, request.get("/api/gpx/file/ride.gpx").status
|
|
end
|
|
|
|
def test_unparseable_gpx_returns_422
|
|
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)
|
|
|
|
response = request.get("/api/gpx/file/bad.gpx")
|
|
assert_equal 422, response.status
|
|
end
|
|
|
|
def test_fit_upload_is_parsed_the_same_as_gpx
|
|
fit_bytes = build_fit_file([[100_000_000, 200_000_000], [100_000_100, 200_000_100]])
|
|
response = post_file("/api/gpx", filename: "activity.fit", content: fit_bytes, env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
assert_equal ["activity.fit"], json_body(response)["paths"]
|
|
|
|
geojson = json_body(request.get("/api/gpx/file/activity.fit"))
|
|
assert_equal 2, geojson["features"][0]["geometry"]["coordinates"][0].size
|
|
end
|
|
|
|
def test_zip_upload_expands_into_its_track_entries_and_skips_the_rest
|
|
zip_bytes = build_zip(
|
|
"one.gpx" => File.read(fixture_path("sample.gpx")),
|
|
"two.fit" => build_fit_file([[100_000_000, 200_000_000]]),
|
|
"readme.txt" => "not a track file",
|
|
)
|
|
response = post_file("/api/gpx?folder=imported", filename: "export.zip", content: zip_bytes,
|
|
content_type: "application/zip", env: logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
paths = json_body(response)["paths"]
|
|
assert_equal ["imported/one.gpx", "imported/two.fit"].sort, paths.sort
|
|
|
|
names = json_body(request.get("/api/gpx/tree"))["children"]
|
|
.find { |c| c["name"] == "imported" }["children"].map { |c| c["name"] }
|
|
refute_includes names, "readme.txt"
|
|
end
|
|
|
|
def test_corrupt_zip_is_rejected
|
|
response = post_file("/api/gpx", filename: "bad.zip", content: "not a zip", content_type: "application/zip", env: logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_folder_create_move_and_delete
|
|
content = File.read(fixture_path("sample.gpx"))
|
|
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
|
|
|
|
create = request.post("/api/gpx/folder?path=trips", **logged_in_env)
|
|
assert_equal 200, create.status
|
|
|
|
move = request.post("/api/gpx/move?from=ride.gpx&to_folder=trips", **logged_in_env)
|
|
assert_equal 200, move.status
|
|
assert_equal "trips/ride.gpx", json_body(move)["path"]
|
|
|
|
delete = request.delete("/api/gpx/file/trips/ride.gpx", **logged_in_env)
|
|
assert_equal 200, delete.status
|
|
assert_empty json_body(delete)["tree"]["children"].find { |c| c["name"] == "trips" }["children"]
|
|
end
|
|
|
|
def test_invalid_folder_path_is_rejected
|
|
response = request.post("/api/gpx/folder?path=..%2F..%2Fescape", **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
# --- Notes (hand-drawn points/areas, notes.js) ---
|
|
|
|
def test_notes_accepts_a_polygon_feature
|
|
polygon = JSON.generate(
|
|
"type" => "Feature",
|
|
"properties" => { "name" => "Turning area", "category" => "Note" },
|
|
"geometry" => { "type" => "Polygon", "coordinates" => [[[17.1, 48.1], [17.2, 48.1], [17.2, 48.2], [17.1, 48.1]]] },
|
|
)
|
|
response = request.post("/api/gpx/notes", params: { feature: polygon, name: "Areas" }, **logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
geometry = json_body(request.get("/api/gpx/file/Areas.geojson"))["features"][0]["geometry"]
|
|
assert_equal "Polygon", geometry["type"]
|
|
end
|
|
|
|
def test_notes_requires_login
|
|
assert_equal 401, request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "Notes" }).status
|
|
end
|
|
|
|
def test_notes_creates_a_new_layer_file
|
|
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, name: "My Notes" }, **logged_in_env)
|
|
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_equal "My Notes.geojson", body["path"]
|
|
assert_equal ["My Notes.geojson"], body["tree"]["children"].map { |c| c["name"] }
|
|
end
|
|
|
|
def test_notes_appends_to_an_existing_layer_file_instead_of_disambiguating
|
|
first = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot"), name: "My Notes" }, **logged_in_env))
|
|
|
|
second = request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate"), path: first["path"] }, **logged_in_env)
|
|
assert_equal 200, second.status
|
|
assert_equal first["path"], json_body(second)["path"]
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(first["path"])}"))["features"]
|
|
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_rejects_malformed_feature_json
|
|
response = request.post("/api/gpx/notes", params: { feature: "not json", name: "Notes" }, **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_notes_rejects_a_feature_with_no_geometry
|
|
response = request.post("/api/gpx/notes", params: { feature: '{"type":"Feature","properties":{}}', name: "Notes" }, **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_notes_to_a_missing_path_is_404
|
|
response = request.post("/api/gpx/notes", params: { feature: sample_note_feature, path: "nope.geojson" }, **logged_in_env)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
def test_notes_update_requires_login
|
|
assert_equal 401, request.put("/api/gpx/notes", params: { feature: sample_note_feature, path: "x.geojson", id: "a" }).status
|
|
end
|
|
|
|
def test_notes_update_edits_the_matching_note
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.put(
|
|
"/api/gpx/notes",
|
|
params: { feature: sample_note_feature("Edited", id: "a"), path: created["path"], id: "a" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 200, response.status
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
|
assert_equal ["Edited"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_update_missing_note_is_404
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Original", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.put(
|
|
"/api/gpx/notes",
|
|
params: { feature: sample_note_feature("Edited", id: "nope"), path: created["path"], id: "nope" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
def test_notes_delete_requires_login
|
|
assert_equal 401, request.delete("/api/gpx/notes", params: { path: "x.geojson", id: "a" }).status
|
|
end
|
|
|
|
def test_notes_delete_removes_the_matching_note
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
request.post("/api/gpx/notes", params: { feature: sample_note_feature("Narrow gate", id: "b"), path: created["path"] }, **logged_in_env)
|
|
|
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "a" }, **logged_in_env)
|
|
assert_equal 200, response.status
|
|
|
|
features = json_body(request.get("/api/gpx/file/#{Rack::Utils.escape_path(created["path"])}"))["features"]
|
|
assert_equal ["Narrow gate"], features.map { |f| f["properties"]["name"] }
|
|
end
|
|
|
|
def test_notes_delete_missing_note_is_404
|
|
created = json_body(request.post("/api/gpx/notes", params: { feature: sample_note_feature("Turning spot", id: "a"), name: "My Notes" }, **logged_in_env))
|
|
|
|
response = request.delete("/api/gpx/notes", params: { path: created["path"], id: "nope" }, **logged_in_env)
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
# --- Import plugins (lib/maptool/plugins.rb) ---
|
|
|
|
def test_plugins_lists_whatever_is_on_disk
|
|
response = request.get("/api/plugins")
|
|
assert_equal 200, response.status
|
|
assert_equal [{ "name" => "test-plugin", "label" => "Test plugin" }], json_body(response)
|
|
end
|
|
|
|
def test_plugins_run_saves_the_output_as_a_new_geojson_file
|
|
response = request.post(
|
|
"/api/plugins/test-plugin/run",
|
|
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 200, response.status
|
|
body = json_body(response)
|
|
assert_match(/\Atest-plugin_48\.05_17\.05_\d{6}_\d{4}\.geojson\z/, body["path"])
|
|
|
|
saved = json_body(request.get("/api/gpx/file/#{body["path"]}"))
|
|
assert_equal "Test POI", saved["features"][0]["properties"]["name"]
|
|
end
|
|
|
|
def test_plugins_run_requires_a_bbox
|
|
response = request.post("/api/plugins/test-plugin/run", **logged_in_env)
|
|
assert_equal 400, response.status
|
|
end
|
|
|
|
def test_plugins_run_unknown_plugin_is_502
|
|
response = request.post(
|
|
"/api/plugins/nope/run",
|
|
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
|
**logged_in_env,
|
|
)
|
|
assert_equal 502, response.status
|
|
end
|
|
|
|
# --- LiveTrack webhook ---
|
|
|
|
def test_livetrack_webhook_requires_the_configured_secret
|
|
response = request.post("/api/livetrack/session", params: { secret: "wrong", session_id: "abc" })
|
|
assert_equal 403, response.status
|
|
end
|
|
|
|
def test_livetrack_webhook_stores_session_id_and_token
|
|
response = request.post(
|
|
"/api/livetrack/session",
|
|
params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "abc123", session_token: "tok" },
|
|
)
|
|
assert_equal 200, response.status
|
|
|
|
sessions = json_body(request.get("/api/livetrack/sessions"))["sessions"]
|
|
assert_equal "abc123", sessions.first["id"]
|
|
assert_equal "tok", sessions.first["token"]
|
|
end
|
|
|
|
def test_livetrack_track_unknown_session_is_404
|
|
response = request.get("/api/livetrack/sessions/does-not-exist/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
# LiveTrackClient's actual page-fetching/parsing has its own tests
|
|
# (test_livetrack_client.rb, no network) and was checked against real
|
|
# Garmin sessions by hand during development — this only covers the
|
|
# route's own guard clause, not the success path, since exercising that
|
|
# here would mean a real network call in every test run.
|
|
def test_livetrack_track_session_without_token_is_404
|
|
request.post("/api/livetrack/session", params: { secret: ENV.fetch("LIVETRACK_WEBHOOK_SECRET"), session_id: "no-token" })
|
|
|
|
response = request.get("/api/livetrack/sessions/no-token/track")
|
|
assert_equal 404, response.status
|
|
end
|
|
|
|
private
|
|
|
|
def sample_note_feature(name = "Turning spot", id: nil)
|
|
JSON.generate(
|
|
"type" => "Feature",
|
|
"properties" => { "name" => name, "category" => "Note", "id" => id },
|
|
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] },
|
|
)
|
|
end
|
|
|
|
def le32(n)
|
|
[n].pack("l<")
|
|
end
|
|
|
|
# Mirrors FitTest#build_fit — a minimal single-definition FIT file, just
|
|
# enough to exercise the upload -> store -> parse path end to end.
|
|
def build_fit_file(records)
|
|
definition = [0, 0].pack("CC") + [20].pack("S<") + [2].pack("C") +
|
|
[0, 4, 0x85].pack("C3") + [1, 4, 0x85].pack("C3")
|
|
definition_header = [0b01000000].pack("C")
|
|
data = records.map { |lat, lon| [0b00000000].pack("C") + le32(lat) + le32(lon) }.join
|
|
body = definition_header + definition + data
|
|
header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT"
|
|
header + body + "\x00\x00"
|
|
end
|
|
|
|
def build_zip(entries)
|
|
require "zip"
|
|
Tempfile.create(["test", ".zip"]) do |file|
|
|
file.close
|
|
Zip::File.open(file.path, create: true) do |zip|
|
|
entries.each { |name, content| zip.get_output_stream(name) { |f| f.write(content) } }
|
|
end
|
|
File.binread(file.path)
|
|
end
|
|
end
|
|
end
|