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", **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 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: "", 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