From cd4eef342b04ef746155c25cd8796baf47dec999 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 12:46:35 +0200 Subject: [PATCH] Add a persisted test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bundle exec rake test` (minitest, a default gem but pinned in Gemfile for reproducibility) now covers what ad-hoc scripts kept re-verifying by hand during development: Gpx/Fit/TrackStats parsing, GpxStore/LiveTrackStore file/TTL logic, LiveTrackClient's payload parsing, and app.rb's routes end to end via Rack::MockRequest. MAPTOOL_DATA_DIR (new, app.rb) lets the suite point GPX_STORE/ LIVETRACK_STORE at a throwaway directory instead of real data/ — without it every run would leave uploaded test files behind. Each lib file's old `if __FILE__ == $0` self-check is now redundant with real tests covering the same ground, so removed rather than kept as a second copy of the same assertions. Wired into ci.yml so it isn't just a local convenience. --- .gitea/workflows/ci.yml | 3 + Gemfile | 5 + Gemfile.lock | 12 ++ Rakefile | 8 ++ app.rb | 10 +- docs/development.adoc | 30 ++++- lib/maptool/fit.rb | 64 ----------- lib/maptool/livetrack_client.rb | 36 ------ lib/maptool/livetrack_store.rb | 41 ------- lib/maptool/track_stats.rb | 27 ----- test/fixtures/sample.gpx | 26 +++++ test/test_app.rb | 189 ++++++++++++++++++++++++++++++++ test/test_fit.rb | 60 ++++++++++ test/test_gpx.rb | 44 ++++++++ test/test_gpx_store.rb | 70 ++++++++++++ test/test_helper.rb | 89 +++++++++++++++ test/test_livetrack_client.rb | 68 ++++++++++++ test/test_livetrack_store.rb | 63 +++++++++++ test/test_track_stats.rb | 35 ++++++ 19 files changed, 706 insertions(+), 174 deletions(-) create mode 100644 Rakefile create mode 100644 test/fixtures/sample.gpx create mode 100644 test/test_app.rb create mode 100644 test/test_fit.rb create mode 100644 test/test_gpx.rb create mode 100644 test/test_gpx_store.rb create mode 100644 test/test_helper.rb create mode 100644 test/test_livetrack_client.rb create mode 100644 test/test_livetrack_store.rb create mode 100644 test/test_track_stats.rb diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ecded0..56fd675 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: App loads without errors run: bundle exec ruby -e 'require_relative "app"' + - name: Ruby tests + run: bundle exec rake test + - uses: actions/setup-node@v4 with: node-version: "22" diff --git a/Gemfile b/Gemfile index 1cbccf3..629866e 100644 --- a/Gemfile +++ b/Gemfile @@ -10,3 +10,8 @@ gem "rubyzip", "~> 2.4" group :development do gem "rerun" end + +group :test do + gem "minitest", "~> 6.0" + gem "rake", "~> 13.0" +end diff --git a/Gemfile.lock b/Gemfile.lock index a6c671d..00f3dcf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,7 @@ GEM version_gem (~> 1.1, >= 1.1.14) base64 (0.3.0) bigdecimal (4.1.2) + drb (2.2.3) faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json @@ -33,6 +34,9 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) multi_json (1.21.1) multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) @@ -50,6 +54,7 @@ GEM rack (>= 1.2, < 4) snaky_hash (~> 2.0, >= 2.0.7) version_gem (~> 1.1, >= 1.1.14) + prism (1.9.0) puma (8.0.2) nio4r (~> 2.0) rack (3.2.7) @@ -60,6 +65,7 @@ GEM rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) + rake (13.4.2) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) @@ -100,8 +106,10 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + minitest (~> 6.0) oauth2 (~> 2.0) puma (~> 8.0) + rake (~> 13.0) rerun rexml (~> 3.4) rubyzip (~> 2.4) @@ -114,6 +122,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df @@ -131,16 +140,19 @@ CHECKSUMS jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57 mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4ffe980 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" << "lib" + t.test_files = FileList["test/test_*.rb"] +end + +task default: :test diff --git a/app.rb b/app.rb index e4edd83..4746a3d 100644 --- a/app.rb +++ b/app.rb @@ -32,9 +32,13 @@ module Maptool # the container) is enough — it doesn't need to survive restarts. ASSET_VERSION = Time.now.to_i - OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") - GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) - LIVETRACK_STORE = LiveTrackStore.new(File.join(__dir__, "data", "livetrack.json")) + # Overridable so the test suite can point this at a throwaway directory + # instead of the real data/ — nothing else needs to set this. + DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") } + + OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles") + GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx")) + LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) # Shared secret for the Garmin LiveTrack webhook (a Google Apps Script # watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a diff --git a/docs/development.adoc b/docs/development.adoc index b67d6c2..bb34bec 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -91,13 +91,37 @@ gpsd -N -n tcp://127.0.0.1:5001 `GET /api/gnss/position` should then report the fake fix (Bratislava Castle, in the sentences above) within a couple of seconds. +== Tests + +`bundle exec rake test` runs the whole suite (`test/test_*.rb`, +Minitest — a default gem, but pinned in the `Gemfile`'s `:test` group +for reproducibility, same reasoning as `rake` itself). `test_app.rb` +drives `app.rb`'s routes end to end through `Rack::MockRequest` rather +than a real server; everything else tests one `lib/maptool/` module in +isolation. `MAPTOOL_DATA_DIR` (see `app.rb`) is pointed at a throwaway +`Dir.mktmpdir` by `test/test_helper.rb` before `app.rb` loads, so +running the suite never touches real `data/` — the app-level tests +also wipe that directory between each test (see `AppTestHelper#teardown`), +since `GPX_STORE`/`LIVETRACK_STORE` are process-wide constants shared +by every test in the run. + +`LiveTrackClient`'s own tests use a synthetic fixture (no network) — +its parsing was checked against real Garmin sessions by hand during +development, not by the suite, since that needs a live, unexpired +session token to fetch. `Fit`'s tests likewise build a minimal FIT +file by hand rather than shipping a real device fixture; correctness +against real files (position/altitude fields, definition-message +framing, compressed-timestamp headers) was checked by hand against +`python-fitparse`'s test fixtures during development — see +`docs/architecture.adoc`. + == Continuous integration `.gitea/workflows/ci.yml` runs on every push/PR against a self-hosted Gitea Actions runner: `ruby -c` on every `.rb` file, a `require_relative -"app"` smoke check that the app loads without error, and `node --check` -on every file under `public/js/`. There is no test suite yet — these -are cheap syntax/load checks, not behavioral tests. +"app"` smoke check that the app loads without error, `bundle exec rake +test` (see above), and `node --check` on every file under +`public/js/` — no JS test suite, just a syntax check. `.gitea/workflows/renovate.yml` runs https://docs.renovatebot.com[Renovate] (the `renovate/renovate` image) diff --git a/lib/maptool/fit.rb b/lib/maptool/fit.rb index 6a65ea3..5e1a945 100644 --- a/lib/maptool/fit.rb +++ b/lib/maptool/fit.rb @@ -172,67 +172,3 @@ module Maptool private_class_method :invalid? end end - -if __FILE__ == $0 - require "tmpdir" - - # A hand-built minimal FIT file: header + one definition message (local - # type 0, global mesg 20 "record", fields position_lat/position_long, - # both sint32) + two data messages + a throwaway CRC. Exercises the - # header/definition/data framing without external fixtures. - def le32(n) - [n].pack("l<") - end - - def build_fit(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 do |lat, lon| - [0b00000000].pack("C") + le32(lat) + le32(lon) - end.join - - body = definition_header + definition + data - header = [12, 16, 0, 0].pack("C2S<") + le32(body.bytesize) + ".FIT" - header + body + "\x00\x00" - end - - Dir.mktmpdir do |dir| - path = File.join(dir, "test.fit") - File.binwrite(path, build_fit([[100_000_000, 200_000_000], [100_000_100, 200_000_100]])) - - geojson = Maptool::Fit.to_geojson(path) - coords = geojson["features"][0]["geometry"]["coordinates"][0] - raise "expected 2 points" unless coords.size == 2 - expected_lat = 100_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES - expected_lon = 200_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES - raise "bad coordinate: #{coords[0]}" unless (coords[0][1] - expected_lat).abs < 1e-9 && (coords[0][0] - expected_lon).abs < 1e-9 - raise "expected kind track" unless Maptool::Fit.kinds(path) == ["track"] - - empty_path = File.join(dir, "empty.fit") - File.binwrite(empty_path, build_fit([])) - raise "expected no features for a pointless file" unless Maptool::Fit.to_geojson(empty_path)["features"].empty? - raise "expected no kinds for a pointless file" unless Maptool::Fit.kinds(empty_path) == [] - - raise "expected ParseError for garbage" rescue nil - begin - Maptool::Fit.to_geojson(File.join(dir, "nonexistent.fit")) - raise "expected an error reading a missing file" - rescue Errno::ENOENT - # fine — this app only ever calls to_geojson with a path it already - # knows exists - end - - garbage_path = File.join(dir, "garbage.fit") - File.binwrite(garbage_path, "not a fit file at all") - begin - Maptool::Fit.to_geojson(garbage_path) - raise "expected ParseError for garbage input" - rescue Maptool::Fit::ParseError - # expected - end - end - - puts "ok" -end diff --git a/lib/maptool/livetrack_client.rb b/lib/maptool/livetrack_client.rb index 6679622..9f1b494 100644 --- a/lib/maptool/livetrack_client.rb +++ b/lib/maptool/livetrack_client.rb @@ -126,39 +126,3 @@ module Maptool end end end - -if __FILE__ == $0 - client = Maptool::LiveTrackClient.new - - fake_page = <<~HTML - - - - - - HTML - - payloads = client.send(:parse_payloads, fake_page) - points = client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } - raise "expected 2 points" unless points&.size == 2 - raise "expected first point lat 48.1" unless points[0]["position"]["lat"] == 48.1 - - session = client.send(:search, payloads) { |n| n if n["sessionName"] } - raise "expected session name" unless session["sessionName"] == "Test Ride" - - result = Maptool::LiveTrackClient::Result.new(points: client.send(:clean, points), session: client.send(:clean, session)) - summary = Maptool::LiveTrackClient.summarize(result) - raise "expected $undefined cleaned to nil" unless result.points[0]["totalDistanceMeters"].nil? - raise "expected summary from last point" unless summary["activity_type"] == "CYCLING" && summary["distance_meters"] == 5000 - raise "expected session name in summary" unless summary["session_name"] == "Test Ride" - raise "expected inactive: session has an end" if summary["active"] - - ongoing_session = session.merge("end" => nil) - ongoing_summary = Maptool::LiveTrackClient.summarize(Maptool::LiveTrackClient::Result.new(points: result.points, session: ongoing_session)) - raise "expected active: session has no end" unless ongoing_summary["active"] - - no_points = client.send(:search, client.send(:parse_payloads, "")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } - raise "expected nil when no trackPoints present" unless no_points.nil? - - puts "ok" -end diff --git a/lib/maptool/livetrack_store.rb b/lib/maptool/livetrack_store.rb index a700896..a94e0fd 100644 --- a/lib/maptool/livetrack_store.rb +++ b/lib/maptool/livetrack_store.rb @@ -88,44 +88,3 @@ module Maptool end end end - -if __FILE__ == $0 - require "tmpdir" - - Dir.mktmpdir do |dir| - store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) - raise "expected empty list" unless store.list == [] - - store.add("abc123", "tok-abc") - store.add("def456", "tok-def") - raise "expected 2 sessions" unless store.list.size == 2 - raise "expected newest first" unless store.list.first["id"] == "def456" - raise "expected token stored" unless store.find("abc123")["token"] == "tok-abc" - raise "expected unknown id to be nil" unless store.find("nope").nil? - raise "expected no summary yet" unless store.find("abc123")["summary"].nil? - - store.add("abc123", "tok-abc-2") - raise "expected re-added session moved to front, not duplicated" \ - unless store.list.map { |s| s["id"] } == %w[abc123 def456] - raise "expected token updated on re-add" unless store.find("abc123")["token"] == "tok-abc-2" - - store.set_summary("abc123", { "session_name" => "Test Ride" }) - raise "expected summary stored" unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" } - store.add("abc123", "tok-abc-3") - raise "expected summary to carry over across re-add" \ - unless store.find("abc123")["summary"] == { "session_name" => "Test Ride" } - - # Manually age an unsummarized session past the TTL and confirm it gets - # pruned, while a summarized one (however old) survives. - raw = JSON.parse(File.read(File.join(dir, "livetrack.json"))) - raw.find { |s| s["id"] == "def456" }["received_at"] = (Time.now.utc - 49 * 60 * 60).iso8601 - raw.find { |s| s["id"] == "abc123" }["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601 - File.write(File.join(dir, "livetrack.json"), JSON.generate(raw)) - - reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) - raise "expected old unsummarized session pruned" if reloaded.list.any? { |s| s["id"] == "def456" } - raise "expected old but summarized session kept" unless reloaded.list.any? { |s| s["id"] == "abc123" } - end - - puts "ok" -end diff --git a/lib/maptool/track_stats.rb b/lib/maptool/track_stats.rb index 6c70b74..6f2b8c7 100644 --- a/lib/maptool/track_stats.rb +++ b/lib/maptool/track_stats.rb @@ -37,30 +37,3 @@ module Maptool private_class_method :haversine end end - -if __FILE__ == $0 - # A right-angle path with a known leg length (via the equirectangular - # approximation, accurate at small scale) plus a climb-then-descend - # elevation profile, so both distance and elevation-gain have a value to - # check against instead of just "did it run". - square = [ - [17.0, 48.0, 100], - [17.001, 48.0, 150], # +50m gain - [17.001, 48.001, 120], # descent, no gain added - ] - stats = Maptool::TrackStats.compute(square, times: [Time.utc(2026, 1, 1, 0, 0, 0), nil, Time.utc(2026, 1, 1, 0, 10, 0)]) - - raise "expected nonzero distance" unless stats["distance_meters"] > 100 - raise "expected elevation gain 50" unless stats["elevation_gain_meters"] == 50.0 - raise "expected duration 600s" unless stats["duration_secs"] == 600 - raise "expected point count 3" unless stats["point_count"] == 3 - - flat = Maptool::TrackStats.compute([[17.0, 48.0], [17.0, 48.0]], times: []) - raise "expected nil elevation gain without elevation data" unless flat["elevation_gain_meters"].nil? - raise "expected nil duration without times" unless flat["duration_secs"].nil? - - single = Maptool::TrackStats.compute([[17.0, 48.0]]) - raise "expected zero distance for a single point" unless single["distance_meters"] == 0.0 - - puts "ok" -end diff --git a/test/fixtures/sample.gpx b/test/fixtures/sample.gpx new file mode 100644 index 0000000..0ad720e --- /dev/null +++ b/test/fixtures/sample.gpx @@ -0,0 +1,26 @@ + + + + 200 + Start + Trailhead, see https://example.com/trail for details + + + + Morning Loop + + + 200 + + + + 250 + + + + 230 + + + + + diff --git a/test/test_app.rb b/test/test_app.rb new file mode 100644 index 0000000..da061b1 --- /dev/null +++ b/test/test_app.rb @@ -0,0 +1,189 @@ +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_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 diff --git a/test/test_fit.rb b/test/test_fit.rb new file mode 100644 index 0000000..9ce6678 --- /dev/null +++ b/test/test_fit.rb @@ -0,0 +1,60 @@ +require_relative "test_helper" +require "maptool/fit" + +class FitTest < Minitest::Test + def le32(n) + [n].pack("l<") + end + + # A minimal hand-built FIT file: header + one definition message (local + # type 0, global mesg 20 "record", fields position_lat/position_long, + # both sint32) + data messages. Exercises the header/definition/data + # framing without needing an external fixture file. + def build_fit(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 write_fit(records) + path = File.join(Dir.mktmpdir, "test.fit") + File.binwrite(path, build_fit(records)) + path + end + + def test_parses_coordinates_from_record_messages + path = write_fit([[100_000_000, 200_000_000], [100_000_100, 200_000_100]]) + + geojson = Maptool::Fit.to_geojson(path) + coords = geojson["features"][0]["geometry"]["coordinates"][0] + + assert_equal 2, coords.size + expected_lat = 100_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES + expected_lon = 200_000_000 * Maptool::Fit::SEMICIRCLE_TO_DEGREES + assert_in_delta expected_lat, coords[0][1], 1e-9 + assert_in_delta expected_lon, coords[0][0], 1e-9 + end + + def test_kinds_is_track_when_positioned + assert_equal ["track"], Maptool::Fit.kinds(write_fit([[100_000_000, 200_000_000]])) + end + + def test_a_file_with_no_positioned_records_has_no_features_or_kinds + path = write_fit([]) + assert_empty Maptool::Fit.to_geojson(path)["features"] + assert_empty Maptool::Fit.kinds(path) + end + + def test_garbage_input_raises_parse_error + path = File.join(Dir.mktmpdir, "garbage.fit") + File.binwrite(path, "not a fit file at all") + + assert_raises(Maptool::Fit::ParseError) { Maptool::Fit.to_geojson(path) } + end +end diff --git a/test/test_gpx.rb b/test/test_gpx.rb new file mode 100644 index 0000000..e71cb25 --- /dev/null +++ b/test/test_gpx.rb @@ -0,0 +1,44 @@ +require_relative "test_helper" +require "maptool/gpx" + +class GpxTest < Minitest::Test + def test_kinds_detects_waypoints_and_tracks + assert_equal %w[waypoint track], Maptool::Gpx.kinds(fixture_path("sample.gpx")) + end + + def test_to_geojson_produces_a_waypoint_point_feature + geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx")) + waypoint = geojson["features"].find { |f| f["properties"]["kind"] == "waypoint" } + + assert_equal "Point", waypoint["geometry"]["type"] + assert_equal [17.2, 48.1, 200.0], waypoint["geometry"]["coordinates"] + assert_equal "Start", waypoint["properties"]["name"] + assert_includes waypoint["properties"]["description"], "example.com" + end + + def test_to_geojson_produces_a_track_multilinestring_with_stats + geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx")) + track = geojson["features"].find { |f| f["properties"]["kind"] == "track" } + + assert_equal "MultiLineString", track["geometry"]["type"] + assert_equal 3, track["geometry"]["coordinates"][0].size + assert_equal "Morning Loop", track["properties"]["name"] + assert_operator track["properties"]["distance_meters"], :>, 0 + assert_equal 50.0, track["properties"]["elevation_gain_meters"], "200->250 is the only gain, 250->230 descent not subtracted" + assert_equal 1200, track["properties"]["duration_secs"] + end + + def test_invalid_xml_raises_parse_error + path = File.join(Dir.mktmpdir, "bad.gpx") + File.write(path, "") + + assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) } + end + + def test_non_gpx_root_raises_parse_error + path = File.join(Dir.mktmpdir, "notgpx.gpx") + File.write(path, "") + + assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) } + end +end diff --git a/test/test_gpx_store.rb b/test/test_gpx_store.rb new file mode 100644 index 0000000..5827dd9 --- /dev/null +++ b/test/test_gpx_store.rb @@ -0,0 +1,70 @@ +require_relative "test_helper" +require "maptool/gpx_store" +require "stringio" + +class GpxStoreTest < Minitest::Test + def setup + @store = Maptool::GpxStore.new(Dir.mktmpdir) + end + + def test_save_returns_the_relative_path_and_lists_it_in_the_tree + path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("")) + + assert_equal "ride.gpx", path + assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] } + end + + def test_fit_extension_is_preserved_not_forced_to_gpx + path = @store.save(folder: "", filename: "activity.fit", io: StringIO.new("binary")) + assert_equal "activity.fit", path + end + + def test_unrecognized_extension_gets_gpx_appended + path = @store.save(folder: "", filename: "notes.txt", io: StringIO.new("x")) + assert_equal "notes.txt.gpx", path + end + + def test_colliding_filenames_are_disambiguated + @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("a")) + path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("b")) + + assert_equal "ride-1.gpx", path + end + + def test_save_creates_the_target_folder + @store.save(folder: "trips/2026", filename: "ride.gpx", io: StringIO.new("x")) + + folder = @store.tree["children"].find { |c| c["name"] == "trips" }["children"].find { |c| c["name"] == "2026" } + assert_equal ["ride.gpx"], folder["children"].map { |c| c["name"] } + end + + def test_path_traversal_is_rejected + assert_raises(Maptool::GpxStore::InvalidPath) { @store.resolve_file("../../etc/passwd") } + assert_raises(Maptool::GpxStore::InvalidPath) { @store.save(folder: "../escape", filename: "x.gpx", io: StringIO.new("x")) } + end + + def test_delete_file + @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("x")) + @store.delete_file("ride.gpx") + + assert_empty @store.tree["children"] + end + + def test_delete_missing_file_raises_enoent + assert_raises(Errno::ENOENT) { @store.delete_file("nope.gpx") } + end + + def test_delete_folder_refuses_the_store_root + assert_raises(Maptool::GpxStore::InvalidPath) { @store.delete_folder("") } + end + + def test_move_relocates_and_disambiguates + @store.save(folder: "", filename: "ride.gpx", io: StringIO.new("a")) + @store.save(folder: "archive", filename: "ride.gpx", io: StringIO.new("b")) + + new_path = @store.move(from: "ride.gpx", to_folder: "archive") + + assert_equal "archive/ride-1.gpx", new_path + assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..c0461a9 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,89 @@ +ENV["RACK_ENV"] = "test" +ENV["LIVETRACK_WEBHOOK_SECRET"] ||= "test-webhook-secret" + +require "fileutils" +require "tmpdir" +require "minitest/autorun" +require "rack" +require "json" + +# A fresh throwaway directory per test run, not the real data/ — every +# GpxStore/LiveTrackStore write app.rb's routes make during the run lands +# here and is gone when the process exits, so running the suite never +# touches (or needs to clean up after itself in) real uploaded data. +ENV["MAPTOOL_DATA_DIR"] = Dir.mktmpdir("maptool-test-data") +Minitest.after_run { FileUtils.remove_entry(ENV["MAPTOOL_DATA_DIR"]) } + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require_relative "../app" + +# Available in every test via Minitest::Test below — fixture_path/json_body +# are generic enough to be worth having everywhere, not just in app.rb's +# own HTTP-level tests (see AppTestHelper for those). +module TestHelpers + def fixture_path(name) + File.join(__dir__, "fixtures", name) + end + + def json_body(response) + JSON.parse(response.body) + end +end + +class Minitest::Test + include TestHelpers +end + +# Only included where needed (test_app.rb) — drives app.rb's Sinatra routes +# through Rack::MockRequest, the same way every ad-hoc verification during +# development did. +module AppTestHelper + def app + Maptool::App + end + + def request + @request ||= Rack::MockRequest.new(app) + end + + # Sinatra's session is read from env["rack.session"] (populated by the + # Rack::Session::Cookie middleware on a real request) — setting it + # directly here is equivalent to already having a valid session cookie, + # without needing to drive the actual Gitea OAuth2 login flow. + def logged_in_env(user: "testuser") + { "rack.session" => { user: user } } + end + + # GPX_STORE/LIVETRACK_STORE are process-wide constants pointing at the + # one throwaway MAPTOOL_DATA_DIR (see above) — without this, uploads from + # one test would still be there (and show up in tree/list assertions) in + # the next one, since minitest runs a class's tests in one process. + def teardown + FileUtils.rm_rf(Dir.glob(File.join(Maptool::App::DATA_DIR, "gpx", "*"))) + File.write(File.join(Maptool::App::DATA_DIR, "livetrack.json"), "[]") + super + end + + def multipart_body(boundary, filename, content, content_type: "application/octet-stream", field_name: "file") + "--#{boundary}\r\n" \ + "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{content_type}\r\n\r\n" \ + "#{content}\r\n" \ + "--#{boundary}--\r\n" + end + + # POSTs a single file as a multipart/form-data upload; pass folder in + # `path`'s query string (e.g. "/api/gpx?folder=trips") the same way the + # real upload form does. + def post_file(path, filename:, content:, content_type: "application/octet-stream", env: {}) + boundary = "TestBoundary#{rand(1_000_000)}" + body = multipart_body(boundary, filename, content, content_type: content_type) + request.post( + path, + input: body, + "CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}", + "CONTENT_LENGTH" => body.bytesize.to_s, + **env, + ) + end +end diff --git a/test/test_livetrack_client.rb b/test/test_livetrack_client.rb new file mode 100644 index 0000000..d699241 --- /dev/null +++ b/test/test_livetrack_client.rb @@ -0,0 +1,68 @@ +require_relative "test_helper" +require "maptool/livetrack_client" + +# No real network calls here — Garmin's page format was verified manually +# against real sessions during development; a synthetic fixture (matching +# the real page's structure: several `self.__next_f.push(...)` calls, one +# holding the track points nested under some data-fetching library's own +# structure) is what actually needs to keep working over time. +class LiveTrackClientTest < Minitest::Test + def setup + @client = Maptool::LiveTrackClient.new + end + + FAKE_PAGE = <<~HTML.freeze + + + + + + HTML + + def test_finds_track_points_and_session_across_separate_push_payloads + payloads = @client.send(:parse_payloads, FAKE_PAGE) + + points = @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } + assert_equal 2, points.size + assert_in_delta 48.1, points[0]["position"]["lat"] + + session = @client.send(:search, payloads) { |n| n if n["sessionName"] } + assert_equal "Test Ride", session["sessionName"] + end + + def test_undefined_sentinel_is_cleaned_to_nil + payloads = @client.send(:parse_payloads, FAKE_PAGE) + points = @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } + cleaned = @client.send(:clean, points) + + assert_nil cleaned[0]["totalDistanceMeters"] + end + + def test_summarize_pulls_from_last_point_and_session + payloads = @client.send(:parse_payloads, FAKE_PAGE) + points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) + session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }) + result = Maptool::LiveTrackClient::Result.new(points: points, session: session) + + summary = Maptool::LiveTrackClient.summarize(result) + + assert_equal "Test Ride", summary["session_name"] + assert_equal "CYCLING", summary["activity_type"] + assert_equal 5000, summary["distance_meters"] + refute summary["active"], "session has an end timestamp" + end + + def test_summarize_marks_active_when_session_has_no_end + payloads = @client.send(:parse_payloads, FAKE_PAGE) + points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) + session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil) + result = Maptool::LiveTrackClient::Result.new(points: points, session: session) + + assert Maptool::LiveTrackClient.summarize(result)["active"] + end + + def test_no_track_points_found_in_a_page_without_them + points = @client.send(:search, @client.send(:parse_payloads, "")) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) } + assert_nil points + end +end diff --git a/test/test_livetrack_store.rb b/test/test_livetrack_store.rb new file mode 100644 index 0000000..41ca1c7 --- /dev/null +++ b/test/test_livetrack_store.rb @@ -0,0 +1,63 @@ +require_relative "test_helper" +require "maptool/livetrack_store" + +class LiveTrackStoreTest < Minitest::Test + def setup + @store = Maptool::LiveTrackStore.new(File.join(Dir.mktmpdir, "livetrack.json")) + end + + def test_starts_empty + assert_empty @store.list + end + + def test_add_and_find + @store.add("abc123", "tok-abc") + @store.add("def456", "tok-def") + + assert_equal 2, @store.list.size + assert_equal "def456", @store.list.first["id"], "newest received first" + assert_equal "tok-abc", @store.find("abc123")["token"] + assert_nil @store.find("nope") + end + + def test_re_adding_moves_to_front_without_duplicating + @store.add("abc123", "tok-abc") + @store.add("def456", "tok-def") + @store.add("abc123", "tok-abc-2") + + assert_equal %w[abc123 def456], @store.list.map { |s| s["id"] } + assert_equal "tok-abc-2", @store.find("abc123")["token"], "token updated on re-add" + end + + def test_summary_carries_over_across_re_add + @store.add("abc123", "tok-abc") + @store.set_summary("abc123", { "session_name" => "Test Ride" }) + @store.add("abc123", "tok-abc-2") + + assert_equal({ "session_name" => "Test Ride" }, @store.find("abc123")["summary"]) + end + + def test_unsummarized_sessions_are_pruned_after_the_ttl_but_summarized_ones_are_kept + @store.add("old_no_summary", "tok1") + @store.add("old_with_summary", "tok2") + @store.set_summary("old_with_summary", { "session_name" => "Kept" }) + + path = @store.instance_variable_get(:@path) + raw = JSON.parse(File.read(path)) + raw.each { |s| s["received_at"] = (Time.now.utc - 1000 * 60 * 60).iso8601 } + File.write(path, JSON.generate(raw)) + + reloaded = Maptool::LiveTrackStore.new(path) + ids = reloaded.list.map { |s| s["id"] } + refute_includes ids, "old_no_summary" + assert_includes ids, "old_with_summary" + end + + def test_data_survives_reload_from_disk + @store.add("abc123", "tok-abc") + path = @store.instance_variable_get(:@path) + + reloaded = Maptool::LiveTrackStore.new(path) + assert_equal 1, reloaded.list.size + end +end diff --git a/test/test_track_stats.rb b/test/test_track_stats.rb new file mode 100644 index 0000000..1e04a3a --- /dev/null +++ b/test/test_track_stats.rb @@ -0,0 +1,35 @@ +require_relative "test_helper" +require "maptool/track_stats" + +class TrackStatsTest < Minitest::Test + def test_computes_distance_and_elevation_gain_ignoring_descents + # A right-angle path (equirectangular approximation is accurate enough + # at this scale to just assert "nonzero, roughly plausible") with a + # climb-then-descend elevation profile, so both distance and + # elevation-gain have a real value to check against. + points = [ + [17.0, 48.0, 100], + [17.001, 48.0, 150], # +50m gain + [17.001, 48.001, 120], # descent — not subtracted + ] + times = [Time.utc(2026, 1, 1, 0, 0, 0), nil, Time.utc(2026, 1, 1, 0, 10, 0)] + + stats = Maptool::TrackStats.compute(points, times: times) + + assert_operator stats["distance_meters"], :>, 100 + assert_equal 50.0, stats["elevation_gain_meters"] + assert_equal 600, stats["duration_secs"] + assert_equal 3, stats["point_count"] + end + + def test_nil_elevation_gain_and_duration_when_data_is_missing + stats = Maptool::TrackStats.compute([[17.0, 48.0], [17.0, 48.0]], times: []) + assert_nil stats["elevation_gain_meters"] + assert_nil stats["duration_secs"] + end + + def test_single_point_has_zero_distance + stats = Maptool::TrackStats.compute([[17.0, 48.0]]) + assert_equal 0.0, stats["distance_meters"] + end +end