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