Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.
Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
220 lines
8.5 KiB
Ruby
220 lines
8.5 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" }, 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
|
|
|
|
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_gnss_position_unavailable_with_no_fix
|
|
response = request.get("/api/gnss/position")
|
|
assert_equal({ "available" => false }, json_body(response))
|
|
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
|
|
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=1")
|
|
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=1")
|
|
assert_equal 200, response.status
|
|
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
|
|
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
|
|
|
|
# --- 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 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
|