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
def test_append_feature_creates_a_new_geojson_file
path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
assert_equal "My Notes.geojson", path
file = @store.tree["children"].find { |c| c["name"] == "My Notes.geojson" }
assert_equal ["waypoint"], file["kinds"]
end
def test_append_feature_grows_the_same_file_instead_of_disambiguating
first_path = @store.append_feature(feature: sample_feature("Turning spot"), folder: "", name: "My Notes")
second_path = @store.append_feature(feature: sample_feature("Narrow gate"), path: first_path)
assert_equal first_path, second_path
features = JSON.parse(@store.geojson(first_path))["features"]
assert_equal ["Turning spot", "Narrow gate"], features.map { |f| f["properties"]["name"] }
end
def test_append_feature_to_a_missing_path_raises_enoent
assert_raises(Errno::ENOENT) { @store.append_feature(feature: sample_feature("x"), path: "nope.geojson") }
end
def test_append_feature_name_always_gets_a_geojson_extension
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "notes.txt")
assert_equal "notes.txt.geojson", path
end
def test_append_feature_blank_name_falls_back_to_notes
path = @store.append_feature(feature: sample_feature("x"), folder: "", name: "")
assert_equal "notes.geojson", path
end
def test_update_feature_replaces_only_the_matching_id
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
@store.append_feature(feature: sample_feature("Narrow gate", id: "b"), path: path)
@store.update_feature(path: path, id: "a", feature: sample_feature("Turning spot (edited)", id: "a"))
names = JSON.parse(@store.geojson(path))["features"].map { |f| f["properties"]["name"] }
assert_equal ["Turning spot (edited)", "Narrow gate"], names
end
def test_update_feature_missing_file_raises_enoent
assert_raises(Errno::ENOENT) { @store.update_feature(path: "nope.geojson", id: "a", feature: sample_feature("x", id: "a")) }
end
def test_update_feature_missing_id_raises_enoent
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
assert_raises(Errno::ENOENT) { @store.update_feature(path: path, id: "nope", feature: sample_feature("x", id: "a")) }
end
def test_delete_feature_removes_only_the_matching_id
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
@store.append_feature(feature: sample_feature("Narrow gate", id: "b"), path: path)
@store.delete_feature(path: path, id: "a")
names = JSON.parse(@store.geojson(path))["features"].map { |f| f["properties"]["name"] }
assert_equal ["Narrow gate"], names
end
def test_delete_feature_missing_id_raises_enoent
path = @store.append_feature(feature: sample_feature("Turning spot", id: "a"), folder: "", name: "My Notes")
assert_raises(Errno::ENOENT) { @store.delete_feature(path: path, id: "nope") }
end
private
def sample_feature(name, id: nil)
{ "type" => "Feature", "properties" => { "name" => name, "category" => "Note", "id" => id },
"geometry" => { "type" => "Point", "coordinates" => [17.1, 48.1] } }
end
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