diff --git a/spec/features/admin_spec.rb b/spec/features/admin_spec.rb index 35d836d42..3618ea5d8 100644 --- a/spec/features/admin_spec.rb +++ b/spec/features/admin_spec.rb @@ -1,72 +1,43 @@ require "rails_helper" +# The list content, the filter and the admin check are server rendered, see the +# request spec spec/requests/admin_controller_spec.rb. This file keeps the parts +# that need a browser: the confirm dialogs and the navigation into a map. describe "Admin List" do - let!(:maps) { create_list(:map, 3) } - let!(:admin) { create(:user, admin: true) } + let(:admin) { create(:user, admin: true) } - context "as logged in admin" do - before do - allow_any_instance_of(ActionController::Base).to receive(:session) - .and_return({ user_id: admin.id }) + before do + create_list(:map, 3) + allow_any_instance_of(ActionController::Base).to receive(:session) + .and_return({ user_id: admin.id }) - visit admin_path - end - - it "shows private links to maps" do - expect(page).to have_selector(:xpath, "//a[@href='/m/#{maps[0].private_id}']") - end - - it "shows link to destroy map" do - accept_alert do - find("i[class='bi bi-trash']", match: :first).click - end - expect(page).to have_text("Mapforge") - wait_for { Map.count }.to eq(2) - end - - it "shows link to copy map" do - accept_confirm do - find("i[class='bi bi-copy']", match: :first).click - end - expect_map_loaded - expect(Map.count).to eq(4) - end - - # FIXME: Somehow Turbo Stream broadcasts / responses aren't visible to capybara... - # context 'map change broadcasts' do - # end - - context "filter list" do - let!(:maps) { - [ create(:map, name: "Map1"), - create(:map, name: "Map2") ] - } - - it "searches in map names" do - fill_in "search", with: "Map1" - find_field("search").send_keys(:enter) + visit admin_path + end - expect(page).to have_selector(".map-preview", count: 1) - expect(page).to have_text("Map1") - expect(page).not_to have_text("Map2") - end + it "shows link to destroy map" do + accept_alert do + find("i[class='bi bi-trash']", match: :first).click end + expect(page).to have_text("Mapforge") + wait_for { Map.count }.to eq(2) + end - context "navigating from admin list to map" do - it "shows map in edit mode without errors" do - find("img[class='preview-image']", match: :first).click - expect(page).to have_selector(:xpath, "//button[@aria-label='Map settings']") - end + it "shows link to copy map" do + accept_confirm do + find("i[class='bi bi-copy']", match: :first).click end + expect_map_loaded + expect(Map.count).to eq(4) end - context "as not logged in admin" do - before do - visit admin_path - end + # FIXME: Somehow Turbo Stream broadcasts / responses aren't visible to capybara... + # context 'map change broadcasts' do + # end - it "shows login page" do - expect(page).to have_text "Log in here" + context "navigating from admin list to map" do + it "shows map in edit mode without errors" do + find("img[class='preview-image']", match: :first).click + expect(page).to have_selector(:xpath, "//button[@aria-label='Map settings']") end end end diff --git a/spec/features/feature_edit_point_spec.rb b/spec/features/feature_edit_point_spec.rb new file mode 100644 index 000000000..6a05de4bd --- /dev/null +++ b/spec/features/feature_edit_point_spec.rb @@ -0,0 +1,273 @@ +require "rails_helper" + +describe "Feature edit, point features" do + include_context "with an editable map and an elevation stub" + + context "with point on map" do + let(:point) { create(:feature, :point_middle, title: "Point Title") } + let(:map) { create(:map, features: [ point ]) } + + context "with selected point feature" do + before do + click_coord("#maplibre-map", 512, 430) + find("#edit-button-edit").click + end + + it "can update point size" do + find("#edit-button-style").click + find("#point-size").set(15) + expect(page).to have_selector("#point-size-val", text: "15") + expect(point.reload.properties["marker-size"]).to eq("15") + end + + it "can update title" do + fill_in "feature-title", with: "New Title" + wait_for { point.reload.properties["title"] }.to eq("New Title") + end + + it "can toggle title visibility on map" do + fill_in "feature-title", with: "New Title" + check "Show title on map" + wait_for { point.reload.properties["label"] }.to eq("New Title") + + uncheck "Show title on map" + wait_for { point.reload.properties["label"] }.to be_nil + end + + it "can update desc" do + expect(page).not_to have_selector("#feature-desc-input") + click_button "Add description" + expect(page).to have_text("Add a description text") + text_area = find(:css, ".CodeMirror textarea", visible: false) + text_area.set("New Desc") + wait_for { point.reload.properties["desc"] }.to eq("New Desc") + end + + it "can update fill color" do + find("#edit-button-style").click + color = "#aa00cc" + set_color_input("#fill-color", color) + + wait_for { point.reload.properties["marker-color"] }.to eq(color) + end + + it "can set fill color transparent" do + find("#edit-button-style").click + expect(find("#fill-color-transparent")).not_to be_checked + + find("#fill-color-transparent").check + + wait_for { point.reload.properties["marker-color"] }.to eq("transparent") + end + + it "can update outline color" do + find("#edit-button-style").click + color = "#aa00cc" + set_color_input("#stroke-color", color) + + wait_for { point.reload.properties["stroke"] }.to eq(color) + end + + it "can upload image" do + find("#edit-button-style").click + image_path = Rails.root.join("spec", "fixtures", "files", "mapforge-logo-icon.png") + page.driver.execute_script("document.querySelector('#marker-image').classList.remove('hidden')") + expect(page).to have_selector("#marker-image") + attach_file("marker-image", image_path) + + wait_for { point.reload.properties["marker-image-url"] }.to match(/icon\/.+/) + wait_for { point.reload.image&.public_id }.to match(/mapforge-logo-icon-\d+.webp/) + end + + it "can upload image bigger 1024px" do + find("#edit-button-style").click + image_path = Rails.root.join("spec", "fixtures", "files", "image_large.jpg") + page.driver.execute_script("document.querySelector('#marker-image').classList.remove('hidden')") + expect(page).to have_selector("#marker-image") + attach_file("marker-image", image_path) + + wait_for { point.reload.properties["marker-image-url"] }.to match(/icon\/.+/) + end + + it "can use emoji selector" do + find("#edit-button-style").click + find("#marker-symbol-select").click + expect(page).to have_selector("em-emoji-picker") + + # Cannot select in shadow dom wiht capybara + shadow_host = find("em-emoji-picker") + page.execute_script(<<~JS, shadow_host) + const host = arguments[0]; + const shadow = host.shadowRoot; + if (shadow) { + const el = shadow.querySelector('span.emoji-mart-emoji'); + el.click(); + } + JS + + wait_for { point.reload.properties["marker-symbol"] }.to match("👍") + end + + it "can select an icon of an icon set" do + find("#edit-button-style").click + find("#marker-symbol-select").click + expect(page).to have_selector("em-emoji-picker") + + # An icon of a set is only rendered once its row is visible, so it gets searched for + page.execute_script(<<~JS) + const shadow = document.querySelector('em-emoji-picker').shadowRoot; + const input = shadow.querySelector('input[type="search"]'); + input.value = 'cafe'; + input.dispatchEvent(new Event('input', { bubbles: true })); + JS + + # Scoped to the grid: while the grid icon waits for its lazy src, a hover preview of + # the same icon keeps its own src, and that copy carries no click handler. + icon = "document.querySelector('em-emoji-picker').shadowRoot" \ + ".querySelector('.scroll img[src=\"/icon-sets/maki/cafe.png\"]')" + wait_for { page.evaluate_script("!!#{icon}") }.to be true + page.execute_script("#{icon}.closest('button').click()") + + wait_for { point.reload.properties["marker-symbol"] }.to eq("/icon-sets/maki/cafe.png") + end + + it "can remove the symbol" do + find("#edit-button-style").click + find("#marker-symbol-select").click + shadow_host = find("em-emoji-picker") + page.execute_script(<<~JS, shadow_host) + arguments[0].shadowRoot.querySelector('span.emoji-mart-emoji').click(); + JS + wait_for { point.reload.properties["marker-symbol"] }.to be_present + + find("#marker-symbol-ui .marker-remove").click + + wait_for { point.reload.properties["marker-symbol"] }.to be_nil + end + end + + it "can copy feature via context menu" do + click_coord("#maplibre-map", 512, 430, button: :right) + expect(page).to have_text("Copy") + find(".context-menu-item", text: "Copy").click + expect(page).to have_text("Point copied to clipboard") + expect(page).to have_text("Details") + end + + it "pastes a copied feature at the clicked position" do + click_coord("#maplibre-map", 512, 430, button: :right) + find(".context-menu-item", text: "Copy").click + expect(page).to have_text("Point copied to clipboard") + + # a feature under the cursor gets its own menu items, without the paste option + click_coord("#maplibre-map", 512, 430, button: :right) + expect(page).to have_css(".context-menu-item", text: "Copy") + expect(page).to have_no_css(".context-menu-item", text: "Paste") + + target = page.evaluate_script("[window.map.unproject([300, 300]).lng, window.map.unproject([300, 300]).lat]") + click_coord("#maplibre-map", 300, 300, button: :right) + find(".context-menu-item", text: "Paste Point").click + wait_for { Feature.point.count }.to eq(2) + + coordinates = Feature.point.last.geometry["coordinates"] + expect(coordinates[0]).to be_within(0.001).of(target[0]) + expect(coordinates[1]).to be_within(0.001).of(target[1]) + end + + it "pastes with ctrl+v at the cursor position" do + hover_coord(300, 300) + target = page.evaluate_script("[window.map.unproject([300, 300]).lng, window.map.unproject([300, 300]).lat]") + + # headless Chrome denies clipboard writes, so the paste event carries the data + page.execute_script(<<~JS) + const data = new DataTransfer() + data.setData('text', JSON.stringify({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [ 1, 1 ] }, + properties: { title: 'Pasted' } + })) + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true })) + JS + wait_for { Feature.point.count }.to eq(2) + + coordinates = Feature.point.last.geometry["coordinates"] + expect(coordinates[0]).to be_within(0.001).of(target[0]) + expect(coordinates[1]).to be_within(0.001).of(target[1]) + end + + it "does not read the clipboard on right click" do + page.execute_script("window.clipboardReads = 0") + page.execute_script(<<~JS) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { + readText: async () => { window.clipboardReads += 1; return '' } + }}) + JS + + click_coord("#maplibre-map", 300, 300, button: :right) + expect(page).to have_no_css(".context-menu-item") + expect(page.evaluate_script("window.clipboardReads")).to eq(0) + end + + it "can delete feature via context menu" do + click_coord("#maplibre-map", 512, 430, button: :right) + expect(page).to have_text("Delete") + accept_alert do + find(".context-menu-item", text: "Delete").click + end + expect(page).to have_text("Point deleted") + # need to wait until feature is saved server side + wait_for { Feature.count }.to eq(0) + end + end + + context "with two points on map" do + let(:point) { create(:feature, :point_middle, title: "Point Title") } + let(:colored_point) { create(:feature, :point, properties: { "marker-color" => "#123456" }) } + let(:map) { create(:map, features: [ point, colored_point ]) } + + it "offers colors of other features and the palette as picker presets" do + click_coord("#maplibre-map", 512, 430) + find("#edit-button-edit").click + find("#edit-button-style").click + presets = page.evaluate_script( + "Array.from(document.querySelectorAll('#color-presets option')).map(o => o.value)") + + expect(presets.first).to eq("#123456") # used by the other point + expect(presets).to include("#e5e5d7") # --color-light-sand + expect(presets.size).to eq(8) # list is capped + end + end + + context "cycling through overlapping features" do + let(:polygon) { create(:feature, :polygon_middle, title: "Poly") } + let(:point1) { create(:feature, :point_middle, title: "Point 1") } + let(:point2) { create(:feature, :point_middle, title: "Point 2") } + let(:map) { create(:map, features: [ polygon, point1, point2 ]) } + + it "cycles through all overlapping features on repeated clicks" do + titles = [] + 3.times do + click_center_of_screen + expect(page).to have_css("#feature-details-modal.show") + titles << find("#feature-title").text + end + expect(titles.uniq).to contain_exactly("Poly", "Point 1", "Point 2") + end + end + + context "with cursor sharing" do + let(:map) { create(:map, name: "Share Cursor", share_cursor: true) } + + it "sends cursor positions" do + hover_coord(70, 70) + expect(page).to have_css("#maplibre-map") + end + end + + context "with lost websocket" do + it "disables edit buttons" do + ActionCable.server.connections.each(&:close) + expect(page).to have_css(".mapbox-gl-draw_ctrl-draw-btn[disabled]") + end + end +end diff --git a/spec/features/feature_edit_spec.rb b/spec/features/feature_edit_spec.rb index 9483da8d4..f568c309a 100644 --- a/spec/features/feature_edit_spec.rb +++ b/spec/features/feature_edit_spec.rb @@ -1,24 +1,7 @@ require "rails_helper" describe "Feature edit" do - let(:map) { create(:map, name: "Feature edit test") } - let(:user) { create(:user) } - - before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - - elevation_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_elevation.json")) - CapybaraMock.stub_request( - :post, /api\.openrouteservice\.org\/elevation\/line/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, - status: 200, - body: elevation_file - ) - - visit map.private_map_path - expect_map_loaded - end + include_context "with an editable map and an elevation stub" context "with empty map" do it "shows feature edit buttons" do @@ -276,272 +259,4 @@ expect(page).to have_selector("#stroke-color", visible: true) end end - - context "with point on map" do - let(:point) { create(:feature, :point_middle, title: "Point Title") } - let(:map) { create(:map, features: [ point ]) } - - context "with selected point feature" do - before do - click_coord("#maplibre-map", 512, 430) - find("#edit-button-edit").click - end - - it "can update point size" do - find("#edit-button-style").click - find("#point-size").set(15) - expect(page).to have_selector("#point-size-val", text: "15") - expect(point.reload.properties["marker-size"]).to eq("15") - end - - it "can update title" do - fill_in "feature-title", with: "New Title" - wait_for { point.reload.properties["title"] }.to eq("New Title") - end - - it "can toggle title visibility on map" do - fill_in "feature-title", with: "New Title" - check "Show title on map" - wait_for { point.reload.properties["label"] }.to eq("New Title") - - uncheck "Show title on map" - wait_for { point.reload.properties["label"] }.to be_nil - end - - it "can update desc" do - expect(page).not_to have_selector("#feature-desc-input") - click_button "Add description" - expect(page).to have_text("Add a description text") - text_area = find(:css, ".CodeMirror textarea", visible: false) - text_area.set("New Desc") - wait_for { point.reload.properties["desc"] }.to eq("New Desc") - end - - it "can update fill color" do - find("#edit-button-style").click - color = "#aa00cc" - set_color_input("#fill-color", color) - - wait_for { point.reload.properties["marker-color"] }.to eq(color) - end - - it "can set fill color transparent" do - find("#edit-button-style").click - expect(find("#fill-color-transparent")).not_to be_checked - - find("#fill-color-transparent").check - - wait_for { point.reload.properties["marker-color"] }.to eq("transparent") - end - - it "can update outline color" do - find("#edit-button-style").click - color = "#aa00cc" - set_color_input("#stroke-color", color) - - wait_for { point.reload.properties["stroke"] }.to eq(color) - end - - it "can upload image" do - find("#edit-button-style").click - image_path = Rails.root.join("spec", "fixtures", "files", "mapforge-logo-icon.png") - page.driver.execute_script("document.querySelector('#marker-image').classList.remove('hidden')") - expect(page).to have_selector("#marker-image") - attach_file("marker-image", image_path) - - wait_for { point.reload.properties["marker-image-url"] }.to match(/icon\/.+/) - wait_for { point.reload.image&.public_id }.to match(/mapforge-logo-icon-\d+.webp/) - end - - it "can upload image bigger 1024px" do - find("#edit-button-style").click - image_path = Rails.root.join("spec", "fixtures", "files", "image_large.jpg") - page.driver.execute_script("document.querySelector('#marker-image').classList.remove('hidden')") - expect(page).to have_selector("#marker-image") - attach_file("marker-image", image_path) - - wait_for { point.reload.properties["marker-image-url"] }.to match(/icon\/.+/) - end - - it "can use emoji selector" do - find("#edit-button-style").click - find("#marker-symbol-select").click - expect(page).to have_selector("em-emoji-picker") - - # Cannot select in shadow dom wiht capybara - shadow_host = find("em-emoji-picker") - page.execute_script(<<~JS, shadow_host) - const host = arguments[0]; - const shadow = host.shadowRoot; - if (shadow) { - const el = shadow.querySelector('span.emoji-mart-emoji'); - el.click(); - } - JS - - wait_for { point.reload.properties["marker-symbol"] }.to match("👍") - end - - it "can select an icon of an icon set" do - find("#edit-button-style").click - find("#marker-symbol-select").click - expect(page).to have_selector("em-emoji-picker") - - # An icon of a set is only rendered once its row is visible, so it gets searched for - page.execute_script(<<~JS) - const shadow = document.querySelector('em-emoji-picker').shadowRoot; - const input = shadow.querySelector('input[type="search"]'); - input.value = 'cafe'; - input.dispatchEvent(new Event('input', { bubbles: true })); - JS - - # Scoped to the grid: while the grid icon waits for its lazy src, a hover preview of - # the same icon keeps its own src, and that copy carries no click handler. - icon = "document.querySelector('em-emoji-picker').shadowRoot" \ - ".querySelector('.scroll img[src=\"/icon-sets/maki/cafe.png\"]')" - wait_for { page.evaluate_script("!!#{icon}") }.to be true - page.execute_script("#{icon}.closest('button').click()") - - wait_for { point.reload.properties["marker-symbol"] }.to eq("/icon-sets/maki/cafe.png") - end - - it "can remove the symbol" do - find("#edit-button-style").click - find("#marker-symbol-select").click - shadow_host = find("em-emoji-picker") - page.execute_script(<<~JS, shadow_host) - arguments[0].shadowRoot.querySelector('span.emoji-mart-emoji').click(); - JS - wait_for { point.reload.properties["marker-symbol"] }.to be_present - - find("#marker-symbol-ui .marker-remove").click - - wait_for { point.reload.properties["marker-symbol"] }.to be_nil - end - end - - it "can copy feature via context menu" do - click_coord("#maplibre-map", 512, 430, button: :right) - expect(page).to have_text("Copy") - find(".context-menu-item", text: "Copy").click - expect(page).to have_text("Point copied to clipboard") - expect(page).to have_text("Details") - end - - it "pastes a copied feature at the clicked position" do - click_coord("#maplibre-map", 512, 430, button: :right) - find(".context-menu-item", text: "Copy").click - expect(page).to have_text("Point copied to clipboard") - - # a feature under the cursor gets its own menu items, without the paste option - click_coord("#maplibre-map", 512, 430, button: :right) - expect(page).to have_css(".context-menu-item", text: "Copy") - expect(page).to have_no_css(".context-menu-item", text: "Paste") - - target = page.evaluate_script("[window.map.unproject([300, 300]).lng, window.map.unproject([300, 300]).lat]") - click_coord("#maplibre-map", 300, 300, button: :right) - find(".context-menu-item", text: "Paste Point").click - wait_for { Feature.point.count }.to eq(2) - - coordinates = Feature.point.last.geometry["coordinates"] - expect(coordinates[0]).to be_within(0.001).of(target[0]) - expect(coordinates[1]).to be_within(0.001).of(target[1]) - end - - it "pastes with ctrl+v at the cursor position" do - hover_coord(300, 300) - target = page.evaluate_script("[window.map.unproject([300, 300]).lng, window.map.unproject([300, 300]).lat]") - - # headless Chrome denies clipboard writes, so the paste event carries the data - page.execute_script(<<~JS) - const data = new DataTransfer() - data.setData('text', JSON.stringify({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [ 1, 1 ] }, - properties: { title: 'Pasted' } - })) - window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true })) - JS - wait_for { Feature.point.count }.to eq(2) - - coordinates = Feature.point.last.geometry["coordinates"] - expect(coordinates[0]).to be_within(0.001).of(target[0]) - expect(coordinates[1]).to be_within(0.001).of(target[1]) - end - - it "does not read the clipboard on right click" do - page.execute_script("window.clipboardReads = 0") - page.execute_script(<<~JS) - Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { - readText: async () => { window.clipboardReads += 1; return '' } - }}) - JS - - click_coord("#maplibre-map", 300, 300, button: :right) - expect(page).to have_no_css(".context-menu-item") - expect(page.evaluate_script("window.clipboardReads")).to eq(0) - end - - it "can delete feature via context menu" do - click_coord("#maplibre-map", 512, 430, button: :right) - expect(page).to have_text("Delete") - accept_alert do - find(".context-menu-item", text: "Delete").click - end - expect(page).to have_text("Point deleted") - # need to wait until feature is saved server side - wait_for { Feature.count }.to eq(0) - end - end - - context "with two points on map" do - let(:point) { create(:feature, :point_middle, title: "Point Title") } - let(:colored_point) { create(:feature, :point, properties: { "marker-color" => "#123456" }) } - let(:map) { create(:map, features: [ point, colored_point ]) } - - it "offers colors of other features and the palette as picker presets" do - click_coord("#maplibre-map", 512, 430) - find("#edit-button-edit").click - find("#edit-button-style").click - presets = page.evaluate_script( - "Array.from(document.querySelectorAll('#color-presets option')).map(o => o.value)") - - expect(presets.first).to eq("#123456") # used by the other point - expect(presets).to include("#e5e5d7") # --color-light-sand - expect(presets.size).to eq(8) # list is capped - end - end - - context "cycling through overlapping features" do - let(:polygon) { create(:feature, :polygon_middle, title: "Poly") } - let(:point1) { create(:feature, :point_middle, title: "Point 1") } - let(:point2) { create(:feature, :point_middle, title: "Point 2") } - let(:map) { create(:map, features: [ polygon, point1, point2 ]) } - - it "cycles through all overlapping features on repeated clicks" do - titles = [] - 3.times do - click_center_of_screen - expect(page).to have_css("#feature-details-modal.show") - titles << find("#feature-title").text - end - expect(titles.uniq).to contain_exactly("Poly", "Point 1", "Point 2") - end - end - - context "with cursor sharing" do - let(:map) { create(:map, name: "Share Cursor", share_cursor: true) } - - it "sends cursor positions" do - hover_coord(70, 70) - expect(page).to have_css("#maplibre-map") - end - end - - context "with lost websocket" do - it "disables edit buttons" do - ActionCable.server.connections.each(&:close) - expect(page).to have_css(".mapbox-gl-draw_ctrl-draw-btn[disabled]") - end - end end diff --git a/spec/features/frontpage_spec.rb b/spec/features/frontpage_spec.rb deleted file mode 100644 index 8821377e1..000000000 --- a/spec/features/frontpage_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -require "rails_helper" - -describe "Frontpage" do - context "when not logged in" do - before do - visit root_path - end - - it "shows frontpage description" do - expect(page).to have_text("Create your own map") - end - end - - context "when logged in" do - let(:user) { create :user } - - before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit root_path - end - - it "links to your maps and create" do - expect(page).to have_link("your maps", href: "/my") - expect(page).to have_link("create", href: "/m") - end - end -end diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index 9e20a8cf4..b47c1ef54 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -1,32 +1,16 @@ require "rails_helper" +# The login and logout flow itself is server side, see the request spec +# spec/requests/sessions_controller_spec.rb. Only the profile dropdown needs a browser. describe "Login" do - before do - visit login_path - end - - it "shows login options" do - expect(page).to have_button("Developer Login") - end + let(:user) { create(:user) } - it "login via developer provider" do - click_button("Developer Login") - expect(page).to have_text("User Info") - fill_in "name", with: "Test User" - fill_in "email", with: "test@mapforge.org" - click_button("Sign In") - expect(page).to have_current_path(my_path) - expect(User.count).to eq 1 + before do + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + visit maps_path end - it "destroys session on logout" do - click_button("Developer Login") - fill_in "name", with: "Test User" - fill_in "email", with: "test@mapforge.org" - click_button("Sign In") - expect(page).to have_current_path(my_path) - expect(User.count).to eq 1 - visit maps_path + it "offers logout in the profile menu" do find(".profile-image").click click_link("Logout") expect(page).to have_current_path(root_path) diff --git a/spec/features/map_layers_spec.rb b/spec/features/map_layers_spec.rb index c737873a2..972f635a8 100644 --- a/spec/features/map_layers_spec.rb +++ b/spec/features/map_layers_spec.rb @@ -1,24 +1,7 @@ require "rails_helper" -describe "Map" do - subject(:map) { create(:map, name: 'Layers test') } - - let(:user) { create(:user) } - - before do - overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json")) - CapybaraMock.stub_request( - :post, "https://overpass-api.de/api/interpreter" - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: overpass_file - ) - - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path - expect_map_loaded - end +describe "Map layers" do + include_context "with an editable map and an overpass stub" context "with initial map rendering" do it "shows map layers button" do @@ -220,342 +203,4 @@ wait_for { Layer.find(layer.id) }.to be_nil end end - - context "overpass comment styling" do - subject(:map) { create(:map, name: "Styling test", center: [ 11.077, 49.447 ], zoom: 15) } - - let(:layer) do - create(:layer, :overpass, name: "styled", query: <<~QUERY) - // stroke=#ff0000 - // stroke-width=6 - // fill=#00ff00 - // fill-opacity=0.3 - way["highway"]; - out geom; - QUERY - end - - before do - CapybaraMock.clear_stubs - overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass_line_polygon.json")) - CapybaraMock.stub_request( - :post, "https://overpass-api.de/api/interpreter" - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: overpass_file - ) - map.layers << layer - visit map.private_map_path - expect_map_loaded - expect_overpass_loaded - end - - it "applies stroke, stroke-width, fill and fill-opacity from query comments" do - line_props = page.evaluate_script( - "map.queryRenderedFeatures().find(f => f.geometry.type === 'LineString')?.properties" - ) - polygon_props = page.evaluate_script( - "map.queryRenderedFeatures().find(f => f.geometry.type === 'Polygon')?.properties" - ) - - expect(line_props["stroke"]).to eq("#ff0000") - expect(line_props["stroke-width"]).to eq("6") - - expect(polygon_props["stroke"]).to eq("#ff0000") - expect(polygon_props["stroke-width"]).to eq("6") - expect(polygon_props["fill"]).to eq("#00ff00") - expect(polygon_props["fill-opacity"]).to eq("0.3") - end - end - - context "layer visibility" do - it "toggles layer visibility via websocket" do - map.layers.first.update!(show: false) - layer_id = map.layers.first.id - expect_layer_visibility(layer_id, false) - map.layers.first.update!(show: true) - expect_layer_visibility(layer_id, true) - end - - it "toggles layer visibility from show to hidden" do - layer_id = map.layers.first.id - expect_layer_visibility(layer_id, true) - find(".maplibregl-ctrl-layers").click - find("button.layer-visibility").click - - expect(page).to have_css(".layer-item.layer-dimmed") - expect(page).to have_css("button.layer-visibility i.bi-eye-slash") - wait_for { map.layers.first.reload.show }.to be false - expect_layer_visibility(layer_id, false) - end - - it "toggles layer visibility from hidden to show" do - layer_id = map.layers.first.id - map.layers.first.update!(show: false) - expect_layer_visibility(layer_id, false) - - find(".maplibregl-ctrl-layers").click - expect(page).to have_css("button.layer-visibility i.bi-eye-slash") - find("button.layer-visibility").click - - expect(page).to have_css("button.layer-visibility i.bi-eye") - expect(page).not_to have_css(".layer-item.layer-dimmed") - wait_for { map.layers.first.reload.show }.to be true - expect_layer_visibility(layer_id, true) - end - end - - context "layer visibility mobile dropdown", :phone do - it "toggles layer visibility from show to hidden" do - layer_id = map.layers.first.id - - # When only visibility toggle is available, it shows inline instead of dropdown - find(".maplibregl-ctrl-layers").click - expect(page).to have_css("button.layer-visibility i.bi-eye") - find("button.layer-visibility").click - - expect(page).to have_css(".layer-item.layer-dimmed") - wait_for { map.layers.first.reload.show }.to be false - expect_layer_visibility(layer_id, false) - end - - it "toggles layer visibility from hidden to show" do - layer_id = map.layers.first.id - map.layers.first.update!(show: false) - expect_layer_visibility(layer_id, false) - - find(".maplibregl-ctrl-layers").click - expect(page).to have_css("button.layer-visibility i.bi-eye-slash") - find("button.layer-visibility").click - - expect(page).not_to have_css(".layer-item.layer-dimmed") - wait_for { map.layers.first.reload.show }.to be true - expect_layer_visibility(layer_id, true) - end - end - - context "layer visibility in readonly mode" do - it "does not sync visibility change to server" do - map.layers.first.update!(show: false) - visit map.public_map_path - expect_map_loaded - - find(".maplibregl-ctrl-layers").click - expect(page).to have_css("button.layer-visibility i.bi-eye-slash") - find("button.layer-visibility").click - expect(page).to have_css("button.layer-visibility i.bi-eye") - expect_layer_visibility(map.layers.first.id, true) - # server state unchanged - expect(map.layers.first.reload.show).to be false - end - end - - context "add new layer" do - before do - find(".maplibregl-ctrl-layers").click - end - - it "can add predefined query layer" do - click_button "Add layer" - within("#query-dropdown") do - find("button.dropdown-item", text: "🍻 Breweries").trigger("click") - end - wait_for { map.layers.find { |m| m.name == "🍻 Breweries" } }.to be_present - end - - it "can add custom query layer" do - click_button "Add layer" - within("#query-dropdown") do - find("button.dropdown-item", text: "Custom Overpass query").trigger("click") - end - wait_for { map.layers.find { |m| m.name == "Custom query" } }.to be_present - end - end - - context "overpass layer visibility" do - before do - map.layers << layer - visit map.private_map_path - expect_map_loaded - expect_overpass_loaded - end - - let(:layer) { create(:layer, :overpass, name: "opass") } - - it "toggles overpass layer visibility via websocket" do - layer.update!(show: false) - expect_layer_visibility(layer.id, false, 'overpass') - layer.update!(show: true) - expect_layer_visibility(layer.id, true, 'overpass') - end - end - - context "layer update via websocket" do - it "updates layer name via server-side change" do - layer = map.layers.first - layer.update!(name: "Renamed Layer") - # Re-open modal to see updated layer name - find(".maplibregl-ctrl-layers").click - expect(page).to have_text("Renamed Layer") - end - end - - context "wikipedia layer" do - before do - wikipedia_file = File.read(Rails.root.join("spec", "fixtures", "files", "wikipedia.json")) - CapybaraMock.stub_request( - :get, /de\.wikipedia\.org\/w\/api\.php/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: wikipedia_file - ) - visit map.private_map_path - expect_map_loaded - end - - it "can add wikipedia layer" do - find(".maplibregl-ctrl-layers").click - click_button "Add layer" - within("#query-dropdown") do - find("button.dropdown-item", text: "Wikipedia articles").trigger("click") - end - wait_for { map.layers.find { |m| m.name == "Wikipedia" } }.to be_present - end - end - - context "basemap change preserves layers" do - before do - stub_const("Map::BASE_MAPS", [ "test", "test2" ] + Map::BASE_MAPS) - feature - visit map.private_map_path - expect_map_loaded - end - - let(:feature) { create(:feature, :point, title: "Basemap Test Feature", layer: map.layers.first) } - - it "features remain visible after basemap change" do - layer_id = map.layers.first.id - expect_layer_visibility(layer_id, true) - - map.update(base_map: "test2") - expect(page).to have_text(/Loaded base map test2|Map properties updated|Map view updated/) - expect_layer_visibility(layer_id, true) - end - end - - context "copy to my layer context menu" do - # center matches the feature in spec/fixtures/files/overpass.json - subject(:map) { create(:map, name: 'Copy test', center: [ 12.3651437, 44.9165141 ], zoom: 15) } - - let(:layer) { create(:layer, :overpass, name: "opass") } - - before do - map.layers << layer - end - - context "in read-write mode" do - before do - visit map.private_map_path - expect_map_loaded - expect_overpass_loaded - end - - it "successfully copies overpass feature to user's geojson layer" do - expect(Feature.count).to eq(0) - - center = center_of_screen - click_coord("#maplibre-map", center[:x], center[:y], button: :right) - expect(page).to have_text("Copy to my layer") - - find(".context-menu-item", text: "Copy to my layer").click - wait_for { Feature.count }.to eq(1) - - copied_feature = Feature.first - expect(copied_feature.layer).to eq(map.layers.first) - expect(copied_feature.properties["label"]).to eq("Agriturismo Caprissio") - # the tags of the element live under 'osm', the top level holds the style keys - expect(copied_feature.properties["osm"]["name"]).to eq("Agriturismo Caprissio") - expect(copied_feature.properties["osm"]["tourism"]).to eq("camp_site") - expect(copied_feature.geometry["type"]).to eq("Point") - expect(copied_feature.geometry["coordinates"]).to eq([ 12.3651437, 44.9165141 ]) - end - end - - context "in read-only mode" do - before do - visit map.public_map_path - expect_map_loaded - expect_overpass_loaded - end - - it "does not show copy to my layer option on overpass feature right-click" do - center = center_of_screen - click_coord("#maplibre-map", center[:x], center[:y], button: :right) - expect(page).not_to have_text("Copy to my layer") - end - end - end - - # MapLibre drops features only when a new load succeeds, so a failed reload leaves them - # on the map. The list must show the same. - context "when the layer reload fails after a reconnect" do - let(:feature) { create(:feature, :point, title: "Kept Feature", layer: map.layers.first) } - - before do - feature - visit map.private_map_path - expect_map_loaded - end - - it "keeps the features in the layer list", :skip_console_errors do - go_offline - expect(page).to have_css("#maplibre-map[data-online='false']") - - map.update!(name: "changed while offline") # forces the reload path on reconnect - allow_any_instance_of(MapsController).to receive(:layer) { |c| c.head(:internal_server_error) } - go_online - expect(page).to have_css("#maplibre-map[data-online='true']") - - find(".maplibregl-ctrl-layers").click - expect(page).to have_text("Kept Feature") - expect(page).to have_no_text("No elements in this layer") - end - end - - context "reordering features" do - let!(:f1) { create(:feature, :point, title: "Reorder A", layer: map.layers.first) } - let!(:f2) { create(:feature, :point_middle, title: "Reorder B", layer: map.layers.first) } - let!(:f3) { create(:feature, :polygon_middle, title: "Reorder C", layer: map.layers.first) } - - # reload after features exist so they render server-side in created_at order - before do - visit map.private_map_path - expect_map_loaded - find(".maplibregl-ctrl-layers").click - expect(page).to have_css("li[data-feature-id='#{f1.id}'] .feature-drag-handle") - end - - def feature_ids = page.all("#layers li[data-feature-id]").map { |li| li["data-feature-id"] } - - it "drags a feature to a new position and persists the order" do - # listed top-first: the last-created feature (drawn on top) appears first - expect(feature_ids).to eq [ f3.id.to_s, f2.id.to_s, f1.id.to_s ] - - # drag the bottom feature (f1) up onto the top one (f3) - drag_element("li[data-feature-id='#{f1.id}'] .feature-drag-handle", - "li[data-feature-id='#{f3.id}']") - - new_order = feature_ids - expect(new_order.first).to eq f1.id.to_s - expect(new_order).not_to eq [ f3.id.to_s, f2.id.to_s, f1.id.to_s ] - - # feature_order is the draw order (bottom first) — the reverse of the shown list - wait_for { map.layers.first.reload.feature_order }.to eq new_order.reverse - - # dragging must not select a feature or open its details - expect(page).not_to have_css("#feature-details-modal.show") - end - end end diff --git a/spec/features/map_layers_visibility_spec.rb b/spec/features/map_layers_visibility_spec.rb new file mode 100644 index 000000000..ec2a3df5b --- /dev/null +++ b/spec/features/map_layers_visibility_spec.rb @@ -0,0 +1,343 @@ +require "rails_helper" + +describe "Map layers, visibility and order" do + include_context "with an editable map and an overpass stub" + + context "overpass comment styling" do + subject(:map) { create(:map, name: "Styling test", center: [ 11.077, 49.447 ], zoom: 15) } + + let(:layer) do + create(:layer, :overpass, name: "styled", query: <<~QUERY) + // stroke=#ff0000 + // stroke-width=6 + // fill=#00ff00 + // fill-opacity=0.3 + way["highway"]; + out geom; + QUERY + end + + before do + CapybaraMock.clear_stubs + overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass_line_polygon.json")) + CapybaraMock.stub_request( + :post, "https://overpass-api.de/api/interpreter" + ).to_return( + headers: { "Access-Control-Allow-Origin" => "*" }, + status: 200, + body: overpass_file + ) + map.layers << layer + visit map.private_map_path + expect_map_loaded + expect_overpass_loaded + end + + it "applies stroke, stroke-width, fill and fill-opacity from query comments" do + line_props = page.evaluate_script( + "map.queryRenderedFeatures().find(f => f.geometry.type === 'LineString')?.properties" + ) + polygon_props = page.evaluate_script( + "map.queryRenderedFeatures().find(f => f.geometry.type === 'Polygon')?.properties" + ) + + expect(line_props["stroke"]).to eq("#ff0000") + expect(line_props["stroke-width"]).to eq("6") + + expect(polygon_props["stroke"]).to eq("#ff0000") + expect(polygon_props["stroke-width"]).to eq("6") + expect(polygon_props["fill"]).to eq("#00ff00") + expect(polygon_props["fill-opacity"]).to eq("0.3") + end + end + + context "layer visibility" do + it "toggles layer visibility via websocket" do + map.layers.first.update!(show: false) + layer_id = map.layers.first.id + expect_layer_visibility(layer_id, false) + map.layers.first.update!(show: true) + expect_layer_visibility(layer_id, true) + end + + it "toggles layer visibility from show to hidden" do + layer_id = map.layers.first.id + expect_layer_visibility(layer_id, true) + find(".maplibregl-ctrl-layers").click + find("button.layer-visibility").click + + expect(page).to have_css(".layer-item.layer-dimmed") + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + wait_for { map.layers.first.reload.show }.to be false + expect_layer_visibility(layer_id, false) + end + + it "toggles layer visibility from hidden to show" do + layer_id = map.layers.first.id + map.layers.first.update!(show: false) + expect_layer_visibility(layer_id, false) + + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + find("button.layer-visibility").click + + expect(page).to have_css("button.layer-visibility i.bi-eye") + expect(page).not_to have_css(".layer-item.layer-dimmed") + wait_for { map.layers.first.reload.show }.to be true + expect_layer_visibility(layer_id, true) + end + end + + context "layer visibility mobile dropdown", :phone do + it "toggles layer visibility from show to hidden" do + layer_id = map.layers.first.id + + # When only visibility toggle is available, it shows inline instead of dropdown + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye") + find("button.layer-visibility").click + + expect(page).to have_css(".layer-item.layer-dimmed") + wait_for { map.layers.first.reload.show }.to be false + expect_layer_visibility(layer_id, false) + end + + it "toggles layer visibility from hidden to show" do + layer_id = map.layers.first.id + map.layers.first.update!(show: false) + expect_layer_visibility(layer_id, false) + + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + find("button.layer-visibility").click + + expect(page).not_to have_css(".layer-item.layer-dimmed") + wait_for { map.layers.first.reload.show }.to be true + expect_layer_visibility(layer_id, true) + end + end + + context "layer visibility in readonly mode" do + it "does not sync visibility change to server" do + map.layers.first.update!(show: false) + visit map.public_map_path + expect_map_loaded + + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + find("button.layer-visibility").click + expect(page).to have_css("button.layer-visibility i.bi-eye") + expect_layer_visibility(map.layers.first.id, true) + # server state unchanged + expect(map.layers.first.reload.show).to be false + end + end + + context "add new layer" do + before do + find(".maplibregl-ctrl-layers").click + end + + it "can add predefined query layer" do + click_button "Add layer" + within("#query-dropdown") do + find("button.dropdown-item", text: "🍻 Breweries").trigger("click") + end + wait_for { map.layers.find { |m| m.name == "🍻 Breweries" } }.to be_present + end + + it "can add custom query layer" do + click_button "Add layer" + within("#query-dropdown") do + find("button.dropdown-item", text: "Custom Overpass query").trigger("click") + end + wait_for { map.layers.find { |m| m.name == "Custom query" } }.to be_present + end + end + + context "overpass layer visibility" do + before do + map.layers << layer + visit map.private_map_path + expect_map_loaded + expect_overpass_loaded + end + + let(:layer) { create(:layer, :overpass, name: "opass") } + + it "toggles overpass layer visibility via websocket" do + layer.update!(show: false) + expect_layer_visibility(layer.id, false, 'overpass') + layer.update!(show: true) + expect_layer_visibility(layer.id, true, 'overpass') + end + end + + context "layer update via websocket" do + it "updates layer name via server-side change" do + layer = map.layers.first + layer.update!(name: "Renamed Layer") + # Re-open modal to see updated layer name + find(".maplibregl-ctrl-layers").click + expect(page).to have_text("Renamed Layer") + end + end + + context "wikipedia layer" do + before do + wikipedia_file = File.read(Rails.root.join("spec", "fixtures", "files", "wikipedia.json")) + CapybaraMock.stub_request( + :get, /de\.wikipedia\.org\/w\/api\.php/ + ).to_return( + headers: { "Access-Control-Allow-Origin" => "*" }, + status: 200, + body: wikipedia_file + ) + visit map.private_map_path + expect_map_loaded + end + + it "can add wikipedia layer" do + find(".maplibregl-ctrl-layers").click + click_button "Add layer" + within("#query-dropdown") do + find("button.dropdown-item", text: "Wikipedia articles").trigger("click") + end + wait_for { map.layers.find { |m| m.name == "Wikipedia" } }.to be_present + end + end + + context "basemap change preserves layers" do + before do + stub_const("Map::BASE_MAPS", [ "test", "test2" ] + Map::BASE_MAPS) + feature + visit map.private_map_path + expect_map_loaded + end + + let(:feature) { create(:feature, :point, title: "Basemap Test Feature", layer: map.layers.first) } + + it "features remain visible after basemap change" do + layer_id = map.layers.first.id + expect_layer_visibility(layer_id, true) + + map.update(base_map: "test2") + expect(page).to have_text(/Loaded base map test2|Map properties updated|Map view updated/) + expect_layer_visibility(layer_id, true) + end + end + + context "copy to my layer context menu" do + # center matches the feature in spec/fixtures/files/overpass.json + subject(:map) { create(:map, name: 'Copy test', center: [ 12.3651437, 44.9165141 ], zoom: 15) } + + let(:layer) { create(:layer, :overpass, name: "opass") } + + before do + map.layers << layer + end + + context "in read-write mode" do + before do + visit map.private_map_path + expect_map_loaded + expect_overpass_loaded + end + + it "successfully copies overpass feature to user's geojson layer" do + expect(Feature.count).to eq(0) + + center = center_of_screen + click_coord("#maplibre-map", center[:x], center[:y], button: :right) + expect(page).to have_text("Copy to my layer") + + find(".context-menu-item", text: "Copy to my layer").click + wait_for { Feature.count }.to eq(1) + + copied_feature = Feature.first + expect(copied_feature.layer).to eq(map.layers.first) + expect(copied_feature.properties["label"]).to eq("Agriturismo Caprissio") + # the tags of the element live under 'osm', the top level holds the style keys + expect(copied_feature.properties["osm"]["name"]).to eq("Agriturismo Caprissio") + expect(copied_feature.properties["osm"]["tourism"]).to eq("camp_site") + expect(copied_feature.geometry["type"]).to eq("Point") + expect(copied_feature.geometry["coordinates"]).to eq([ 12.3651437, 44.9165141 ]) + end + end + + context "in read-only mode" do + before do + visit map.public_map_path + expect_map_loaded + expect_overpass_loaded + end + + it "does not show copy to my layer option on overpass feature right-click" do + center = center_of_screen + click_coord("#maplibre-map", center[:x], center[:y], button: :right) + expect(page).not_to have_text("Copy to my layer") + end + end + end + + # MapLibre drops features only when a new load succeeds, so a failed reload leaves them + # on the map. The list must show the same. + context "when the layer reload fails after a reconnect" do + let(:feature) { create(:feature, :point, title: "Kept Feature", layer: map.layers.first) } + + before do + feature + visit map.private_map_path + expect_map_loaded + end + + it "keeps the features in the layer list", :skip_console_errors do + go_offline + expect(page).to have_css("#maplibre-map[data-online='false']") + + map.update!(name: "changed while offline") # forces the reload path on reconnect + allow_any_instance_of(MapsController).to receive(:layer) { |c| c.head(:internal_server_error) } + go_online + expect(page).to have_css("#maplibre-map[data-online='true']") + + find(".maplibregl-ctrl-layers").click + expect(page).to have_text("Kept Feature") + expect(page).to have_no_text("No elements in this layer") + end + end + + context "reordering features" do + let!(:f1) { create(:feature, :point, title: "Reorder A", layer: map.layers.first) } + let!(:f2) { create(:feature, :point_middle, title: "Reorder B", layer: map.layers.first) } + let!(:f3) { create(:feature, :polygon_middle, title: "Reorder C", layer: map.layers.first) } + + # reload after features exist so they render server-side in created_at order + before do + visit map.private_map_path + expect_map_loaded + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("li[data-feature-id='#{f1.id}'] .feature-drag-handle") + end + + def feature_ids = page.all("#layers li[data-feature-id]").map { |li| li["data-feature-id"] } + + it "drags a feature to a new position and persists the order" do + # listed top-first: the last-created feature (drawn on top) appears first + expect(feature_ids).to eq [ f3.id.to_s, f2.id.to_s, f1.id.to_s ] + + # drag the bottom feature (f1) up onto the top one (f3) + drag_element("li[data-feature-id='#{f1.id}'] .feature-drag-handle", + "li[data-feature-id='#{f3.id}']") + + new_order = feature_ids + expect(new_order.first).to eq f1.id.to_s + expect(new_order).not_to eq [ f3.id.to_s, f2.id.to_s, f1.id.to_s ] + + # feature_order is the draw order (bottom first) — the reverse of the shown list + wait_for { map.layers.first.reload.feature_order }.to eq new_order.reverse + + # dragging must not select a feature or open its details + expect(page).not_to have_css("#feature-details-modal.show") + end + end +end diff --git a/spec/features/map_permissions_spec.rb b/spec/features/map_permissions_spec.rb deleted file mode 100644 index b57c3b9c6..000000000 --- a/spec/features/map_permissions_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -require "rails_helper" - -describe "Map" do - let(:user) { create :user } - - context "edit" do - before do - visit map.private_map_path - end - - context "private" do - subject(:map) { create(:map, edit_permission: "private", owners: [ user ]) } - - it "is not accessible via link" do - expect(page).to have_current_path(maps_path) - end - - it "is accessible for owner" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path - expect_map_loaded - end - end - - context "link" do - subject(:map) { create(:map, edit_permission: "link") } - - it "is accessible via link" do - expect_map_loaded - end - end - end - - context "view" do - before do - visit map.public_map_path - end - - context "private" do - subject(:map) { create(:map, view_permission: "private", owners: [ user ]) } - - it "is not accessible via link" do - expect(page).to have_current_path(maps_path) - end - - it "is accessible for owner" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.public_map_path - expect_map_loaded - end - end - - context "link" do - subject(:map) { create(:map, view_permission: "link") } - - it "is accessible via link" do - expect_map_loaded - end - end - - context "public" do - subject(:map) { create(:map, view_permission: "public") } - - it "is accessible via link" do - expect_map_loaded - end - end - end -end diff --git a/spec/features/maps_list_spec.rb b/spec/features/maps_list_spec.rb index 49163bd94..f49b4ef37 100644 --- a/spec/features/maps_list_spec.rb +++ b/spec/features/maps_list_spec.rb @@ -1,18 +1,16 @@ require "rails_helper" +# The filter results themselves are server rendered, see the request spec +# spec/requests/maps_controller_spec.rb. This file only covers the parts that +# need a browser: the live broadcast and the Stimulus filter controls. describe "Map List" do let(:maps) { create_list(:map, 3, view_permission: "listed") } - let(:user) { create(:user) } before do maps visit maps_path end - it "shows public links to maps" do - expect(page).to have_selector(:xpath, "//a[@href='/m/#{maps[0].public_id}']") - end - it "receives broadcasts for map changes" do # page is already loaded expect(page).to have_selector(:xpath, "//a[@href='/m/#{maps[0].public_id}']") @@ -25,34 +23,17 @@ context "filter list" do let(:maps) { [ create(:map, name: "Map1", view_permission: "listed"), - create(:map, name: "Map2", owners: [ user ], view_permission: "listed") ] + create(:map, name: "Map2", view_permission: "listed") ] } - it "searches in map names" do + it "searches on enter and clears the filter again" do fill_in "search", with: "Map1" find_field("search").send_keys(:enter) expect(page).to have_selector(".map-preview", count: 1) expect(page).to have_text("Map1") - expect(page).not_to have_text("Map2") - end - - it "can clear filter" do - fill_in "search", with: "Map1" - find_field("search").send_keys(:enter) - - expect(page).to have_selector(".map-preview", count: 1) find("button[aria-label='Clear filter']").click expect(page).to have_selector(".map-preview", count: 2) end - - it "searches for map owners" do - fill_in "search", with: "user:#{user.id}" - find_field("search").send_keys(:enter) - - expect(page).to have_selector(".map-preview", count: 1) - expect(page).to have_text("Map2") - expect(page).not_to have_text("Map1") - end end end diff --git a/spec/features/maps_my_list_spec.rb b/spec/features/maps_my_list_spec.rb index 02c0b8fda..615bef1d3 100644 --- a/spec/features/maps_my_list_spec.rb +++ b/spec/features/maps_my_list_spec.rb @@ -1,5 +1,7 @@ require "rails_helper" +# The list content and the filter are server rendered, see the request spec +# spec/requests/maps_controller_spec.rb. Only the delete confirmation needs a browser. describe "Map List" do let(:user) { create(:user) } @@ -9,10 +11,6 @@ visit my_path end - it "shows private links to maps" do - expect(page).to have_selector(:xpath, "//a[@href='/m/#{user.owned_maps.first.private_id}']") - end - it "can delete own map" do expect(user.owned_maps.count).to eq 1 expect(page).to have_css(".map-preview") @@ -22,20 +20,4 @@ expect(page).not_to have_css(".map-preview") expect(user.reload.owned_maps.count).to eq 0 end - - context "filter list" do - before { - [ create(:map, owners: [ user ], name: "Map1"), - create(:map, owners: [ user ], name: "Map2") ] - } - - it "searches in map names" do - fill_in "search", with: "Map1" - find_field("search").send_keys(:enter) - - expect(page).to have_selector(".map-preview", count: 1) - expect(page).to have_text("Map1") - expect(page).not_to have_text("Map2") - end - end end diff --git a/spec/features/translations_spec.rb b/spec/features/translations_spec.rb index dcedfb0e7..c96aafcea 100644 --- a/spec/features/translations_spec.rb +++ b/spec/features/translations_spec.rb @@ -3,35 +3,21 @@ require "gettext_i18n_rails_js/parser" require "gettext_i18n_rails_js/task" +# The Haml translation and the locale fallback are server rendered, see the request +# spec spec/requests/application_controller_spec.rb. Only the generated JS locale +# files need a browser. describe "Translations" do before do # Drop the generated JS locale dirs, then regenerate them from scratch. FileUtils.rm_rf(Rails.root.glob("app/assets/javascripts/locale/*/")) silence_stdout { GettextI18nRailsJs::Task.po_to_json } + visit root_path(locale: "de") end - context "with valid locale" do - before { visit root_path(locale: "de") } - - it "translates Haml strings" do - expect(page).to have_text("Gestalte deine eigene Karte") - end - - it "translates JavaScript strings" do - expect(page.evaluate_script("document.documentElement.lang")).to eq("de") - expect(page.evaluate_script("window.__('Delete')")).to eq("Löschen") - # the search synonyms of one category are one string - expect(page.evaluate_script("window.__('toilet|wc|restroom|lavatory')")).to include("Toilette") - end - end - - context "with invalid locale" do - before { visit root_path(locale: "xx") } - - it "falls back to the default locale" do - expect(page.evaluate_script("document.documentElement.lang")).to eq("en") - expect(page).to have_text("Create your own map") - end + it "translates JavaScript strings" do + expect(page.evaluate_script("window.__('Delete')")).to eq("Löschen") + # the search synonyms of one category are one string + expect(page.evaluate_script("window.__('toilet|wc|restroom|lavatory')")).to include("Toilette") end def silence_stdout diff --git a/spec/requests/admin_controller_spec.rb b/spec/requests/admin_controller_spec.rb new file mode 100644 index 000000000..5241c338f --- /dev/null +++ b/spec/requests/admin_controller_spec.rb @@ -0,0 +1,39 @@ +require "rails_helper" + +describe AdminController do + let(:admin) { create(:user, admin: true) } + + describe "#index" do + before do + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: admin.id }) + end + + it "lists the private link of every map" do + map = create(:map) + get "/admin" + expect(response.body).to include("/m/#{map.private_id}") + end + + it "searches in map names" do + create(:map, name: "Map1") + create(:map, name: "Map2") + get "/admin", params: { search: "Map1" } + expect(response.body).to include("Map1") + expect(response.body).not_to include("Map2") + end + end + + describe "#require_admin_user" do + it "redirects a visitor to the login page" do + get "/admin" + expect(response).to redirect_to(login_path) + end + + it "redirects a logged in non-admin to the login page" do + user = create(:user) + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + get "/admin" + expect(response).to redirect_to(login_path) + end + end +end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index ae4835e65..512b5aff2 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -10,4 +10,18 @@ expect(res).to have_http_status(200) end end + + describe "#set_gettext_locale" do + it "translates the page into a requested locale" do + get root_path(locale: "de") + expect(response.body).to include('lang="de"') + expect(response.body).to include("Gestalte deine eigene Karte") + end + + it "falls back to the default locale on an unknown locale" do + get root_path(locale: "xx") + expect(response.body).to include('lang="en"') + expect(response.body).to include("Create your own map") + end + end end diff --git a/spec/requests/frontpage_controller_spec.rb b/spec/requests/frontpage_controller_spec.rb index 08c5a3a35..0844e43c8 100644 --- a/spec/requests/frontpage_controller_spec.rb +++ b/spec/requests/frontpage_controller_spec.rb @@ -6,6 +6,19 @@ expect(get("/")).to eq(200) end + it "shows the frontpage description" do + get "/" + expect(response.body).to include("Create your own map") + end + + it "links to your maps and create for a logged in user" do + user = create(:user) + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + get "/" + expect(response.body).to include(">your maps") + expect(response.body).to include(">create") + end + it "redirects a shared map url to the map" do get "/", params: { url: "https://www.example.com/m/abc" } expect(response).to redirect_to("/m/abc") diff --git a/spec/requests/maps_controller_spec.rb b/spec/requests/maps_controller_spec.rb index 1b91b5769..9ead028fd 100644 --- a/spec/requests/maps_controller_spec.rb +++ b/spec/requests/maps_controller_spec.rb @@ -89,6 +89,127 @@ def gon_view(raw, params = {}) end end + describe "access control" do + let(:user) { create(:user) } + + def login + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + end + + context "with edit_permission private" do + let(:map) { create(:map, edit_permission: "private", owners: [ user ]) } + + it "is not accessible via link" do + get map.private_map_path + expect(response).to redirect_to(maps_path) + end + + it "is accessible for the owner" do + login + get map.private_map_path + expect(response).to have_http_status(:ok) + end + end + + context "with edit_permission link" do + let(:map) { create(:map, edit_permission: "link") } + + it "is accessible via link" do + get map.private_map_path + expect(response).to have_http_status(:ok) + end + end + + context "with view_permission private" do + let(:map) { create(:map, view_permission: "private", owners: [ user ]) } + + it "is not accessible via link" do + get map.public_map_path + expect(response).to redirect_to(maps_path) + end + + it "is accessible for the owner" do + login + get map.public_map_path + expect(response).to have_http_status(:ok) + end + end + + context "with view_permission link" do + let(:map) { create(:map, view_permission: "link") } + + it "is accessible via link" do + get map.public_map_path + expect(response).to have_http_status(:ok) + end + end + + context "with view_permission public" do + let(:map) { create(:map, view_permission: "public") } + + it "is accessible via link" do + get map.public_map_path + expect(response).to have_http_status(:ok) + end + end + end + + describe "#index" do + let(:user) { create(:user) } + + it "lists the public link of a listed map" do + map = create(:map, view_permission: "listed") + get maps_path + expect(response.body).to include("/m/#{map.public_id}") + end + + it "searches in map names" do + create(:map, name: "Map1", view_permission: "listed") + create(:map, name: "Map2", view_permission: "listed") + get maps_path, params: { search: "Map1" } + expect(response.body).to include("Map1") + expect(response.body).not_to include("Map2") + end + + it "searches for map owners" do + create(:map, name: "Map1", view_permission: "listed") + create(:map, name: "Map2", owners: [ user ], view_permission: "listed") + get maps_path, params: { search: "user:#{user.id}" } + expect(response.body).to include("Map2") + expect(response.body).not_to include("Map1") + end + + it "lists every map again without a search" do + create(:map, name: "Map1", view_permission: "listed") + create(:map, name: "Map2", view_permission: "listed") + get maps_path + expect(response.body).to include("Map1") + expect(response.body).to include("Map2") + end + end + + describe "#my" do + let(:user) { create(:user) } + + before do + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + end + + it "lists the private link of an own map" do + map = create(:map, owners: [ user ]) + get my_path + expect(response.body).to include("/m/#{map.private_id}") + end + + it "searches in map names" do + create(:map, name: "Map1", owners: [ user ]) + create(:map, name: "Map2", owners: [ user ]) + get my_path, params: { search: "Map1" } + expect(response.body).to include("Map1") + expect(response.body).not_to include("Map2") + end + end + describe "#create" do let(:user) { create(:user) } diff --git a/spec/requests/sessions_controller_spec.rb b/spec/requests/sessions_controller_spec.rb new file mode 100644 index 000000000..3d8cf5b99 --- /dev/null +++ b/spec/requests/sessions_controller_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +describe SessionsController do + describe "#new" do + it "shows the login options" do + get login_path + expect(response.body).to include("Developer Login") + end + end + + describe "#developer" do + it "shows the user info form" do + post "/auth/developer/login" + expect(response.body).to include("User Info") + end + end + + describe "#create" do + def developer_login(name: "Test User", email: "test@mapforge.org") + get "/auth/developer/callback", params: { name: name, email: email } + end + + it "creates the user and redirects to the map list" do + developer_login + expect(response).to redirect_to(my_path) + expect(User.count).to eq 1 + expect(User.first.email).to eq "test@mapforge.org" + end + + it "makes the first user an admin" do + developer_login + expect(User.first).to be_admin + end + + it "reuses the account on a second login" do + developer_login + developer_login + expect(User.count).to eq 1 + end + + it "starts a session that grants access to the own map list" do + developer_login + get my_path + expect(response).to have_http_status(:ok) + end + end + + describe "#logout" do + it "destroys the session" do + get "/auth/developer/callback", params: { name: "Test User", email: "test@mapforge.org" } + + get logout_path + expect(response).to redirect_to(root_path) + + get my_path + expect(response).to redirect_to(login_path) + end + end +end diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb new file mode 100644 index 000000000..2c203b044 --- /dev/null +++ b/spec/support/shared_contexts.rb @@ -0,0 +1,41 @@ +RSpec.shared_context "with an editable map and an elevation stub" do + let(:map) { create(:map, name: "Feature edit test") } + let(:user) { create(:user) } + + before do + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + + elevation_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_elevation.json")) + CapybaraMock.stub_request( + :post, /api\.openrouteservice\.org\/elevation\/line/ + ).to_return( + headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, + status: 200, + body: elevation_file + ) + + visit map.private_map_path + expect_map_loaded + end +end + +RSpec.shared_context "with an editable map and an overpass stub" do + subject(:map) { create(:map, name: "Layers test") } + + let(:user) { create(:user) } + + before do + overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json")) + CapybaraMock.stub_request( + :post, "https://overpass-api.de/api/interpreter" + ).to_return( + headers: { "Access-Control-Allow-Origin" => "*" }, + status: 200, + body: overpass_file + ) + + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + visit map.private_map_path + expect_map_loaded + end +end