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 def test_save_eagerly_caches_the_geojson_conversion path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) cache_path = @store.resolve_file(path) + ".geojson" assert File.file?(cache_path) first = @store.geojson(path) # Corrupt the cache directly to prove the call above reused it instead of # re-parsing the (still-valid) source file. File.write(cache_path, first.sub("waypoint", "cached-waypoint")) assert_includes @store.geojson(path), "cached-waypoint" end def test_save_of_an_unparseable_file_still_succeeds path = @store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all")) refute File.file?(@store.resolve_file(path) + ".geojson") assert_raises(Maptool::Gpx::ParseError) { @store.geojson(path) } end def test_geojson_cache_is_invalidated_when_the_file_is_resaved path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) @store.geojson(path) File.write(@store.resolve_file(path), sample_gpx.sub("Waypoint", "Renamed")) FileUtils.touch(@store.resolve_file(path), mtime: Time.now + 1) assert_includes @store.geojson(path), "Renamed" end def test_delete_file_removes_the_geojson_cache_too path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) @store.geojson(path) cache_path = @store.resolve_file(path) + ".geojson" @store.delete_file(path) refute File.file?(cache_path) end def test_move_relocates_the_geojson_cache_too path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) @store.geojson(path) new_path = @store.move(from: path, to_folder: "archive") assert File.file?(@store.resolve_file(new_path) + ".geojson") end def test_geojson_raises_enoent_for_a_missing_file assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") } end def test_tree_reports_cached_true_after_a_successful_save @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" } assert file["cached"] end def test_tree_reports_cached_false_for_an_unparseable_file @store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all")) file = @store.tree["children"].find { |c| c["name"] == "bad.gpx" } refute file["cached"] end def test_tree_reports_cached_false_once_the_source_is_resaved path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) FileUtils.touch(@store.resolve_file(path) + ".geojson", mtime: Time.now - 10) File.write(@store.resolve_file(path), sample_gpx) FileUtils.touch(@store.resolve_file(path), mtime: Time.now) file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" } refute file["cached"] end def test_geojson_extension_is_preserved_not_forced_to_gpx path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson)) assert_equal "spots.geojson", path end def test_uploaded_geojson_is_served_as_is_with_no_cache_file path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson)) assert_equal sample_geojson, @store.geojson(path) refute File.file?(@store.resolve_file(path) + ".geojson"), "a .geojson source needs no separate cache sidecar" end def test_uploaded_geojson_is_always_reported_cached @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson)) file = @store.tree["children"].find { |c| c["name"] == "spots.geojson" } assert file["cached"] assert_equal ["waypoint"], file["kinds"] end # A GPX/FIT's own GeoJSON cache sidecar (e.g. "ride.gpx.geojson") also # ends in ".geojson" — regression test for it not showing up as its own # second, phantom tree entry alongside "ride.gpx". def test_geojson_cache_sidecar_is_not_listed_as_its_own_tree_entry @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] } end private def sample_gpx <<~GPX Waypoint GPX end def sample_geojson '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}' end end