Add a persisted test suite
`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.
This commit is contained in:
1 parent
a3c31ace06
commit
cd4eef342b
19 files changed
+706
-174
No files matched your search
@@ -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: "<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
|
||||
Reference in new issue
Block a user