Move /key to /panes/legend
[rails.git] / test / controllers / api / changesets / uploads_controller_test.rb
index 57bef30f34e5d602f38595d36a7f8c512fe39d02..11aa03acddd2827fa63586185bf5f7b3d4d1fb8d 100644 (file)
@@ -125,6 +125,358 @@ module Api
         assert_equal 1 * GeoRecord::SCALE, node.longitude
       end
 
+      ##
+      # try to upload with commands other than create, modify, or delete
+      def test_upload_unknown_action
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <ping>
+              <node id='1' lon='1' lat='1' changeset='#{changeset.id}' />
+            </ping>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :bad_request
+        assert_equal "Unknown action ping, choices are create, modify, delete", @response.body
+      end
+
+      ##
+      # test for issues in https://github.com/openstreetmap/trac-tickets/issues/1568
+      def test_upload_empty_changeset
+        changeset = create(:changeset)
+
+        auth_header = bearer_authorization_header changeset.user
+
+        ["<osmChange/>",
+         "<osmChange></osmChange>",
+         "<osmChange><modify/></osmChange>",
+         "<osmChange><modify></modify></osmChange>"].each do |diff|
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :success
+        end
+      end
+
+      ##
+      # test that the X-Error-Format header works to request XML errors
+      def test_upload_xml_errors
+        changeset = create(:changeset)
+        node = create(:node)
+        create(:relation_member, :member => node)
+
+        # try and delete a node that is in use
+        diff = XML::Document.new
+        diff.root = XML::Node.new "osmChange"
+        delete = XML::Node.new "delete"
+        diff.root << delete
+        delete << xml_node_for_node(node)
+
+        auth_header = bearer_authorization_header changeset.user
+        error_header = error_format_header "xml"
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header.merge(error_header)
+
+        assert_response :success
+
+        assert_dom "osmError[version='#{Settings.api_version}'][generator='#{Settings.generator}']", 1
+        assert_dom "osmError>status", 1
+        assert_dom "osmError>message", 1
+      end
+
+      # -------------------------------------
+      # Test creating elements.
+      # -------------------------------------
+
+      def test_upload_create_elements
+        user = create(:user)
+        changeset = create(:changeset, :user => user)
+        node = create(:node)
+        way = create(:way_with_nodes, :nodes_count => 2)
+        relation = create(:relation)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+              <way id='-1' changeset='#{changeset.id}'>
+                <nd ref='#{node.id}'/>
+              </way>
+            </create>
+            <create>
+              <relation id='-1' changeset='#{changeset.id}'>
+                <member type='way' role='some' ref='#{way.id}'/>
+                <member type='node' role='some' ref='#{node.id}'/>
+                <member type='relation' role='some' ref='#{relation.id}'/>
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        new_node_id, new_way_id, new_rel_id = nil
+        assert_dom "diffResult[version='#{Settings.api_version}'][generator='#{Settings.generator}']", 1 do
+          # inspect the response to find out what the new element IDs are
+          # check the old IDs are all present and negative one
+          # check the versions are present and equal one
+          assert_dom "> node", 1 do |(node_el)|
+            new_node_id = node_el["new_id"].to_i
+            assert_dom "> @old_id", "-1"
+            assert_dom "> @new_version", "1"
+          end
+          assert_dom "> way", 1 do |(way_el)|
+            new_way_id = way_el["new_id"].to_i
+            assert_dom "> @old_id", "-1"
+            assert_dom "> @new_version", "1"
+          end
+          assert_dom "> relation", 1 do |(rel_el)|
+            new_rel_id = rel_el["new_id"].to_i
+            assert_dom "> @old_id", "-1"
+            assert_dom "> @new_version", "1"
+          end
+        end
+
+        assert_equal 2, Node.find(new_node_id).tags.size, "new node should have two tags"
+        assert_equal 0, Way.find(new_way_id).tags.size, "new way should have no tags"
+        assert_equal 0, Relation.find(new_rel_id).tags.size, "new relation should have no tags"
+      end
+
+      ##
+      # upload an element with a really long tag value
+      def test_upload_create_node_with_tag_too_long
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k='foo' v='#{'x' * 256}'/>
+              </node>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Node.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :bad_request
+        end
+      end
+
+      def test_upload_create_nodes_with_invalid_placeholder_reuse_in_one_action_block
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}' version='1'/>
+              <node id='-1' lon='1' lat='1' changeset='#{changeset.id}' version='1'/>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Node.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :bad_request
+        end
+      end
+
+      def test_upload_create_nodes_with_invalid_placeholder_reuse_in_two_action_blocks
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}' version='1'/>
+            </create>
+            <create>
+              <node id='-1' lon='1' lat='1' changeset='#{changeset.id}' version='1'/>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Node.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :bad_request
+        end
+      end
+
+      def test_upload_create_way_referring_node_placeholder_defined_later
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <way id="-1" changeset="#{changeset.id}">
+                <nd ref="-1"/>
+              </way>
+              <node id="-1" lat="1" lon="2" changeset="#{changeset.id}"/>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Node.count" do
+          assert_no_difference "Way.count" do
+            post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+            assert_response :bad_request
+          end
+        end
+        assert_equal "Placeholder node not found for reference -1 in way -1", @response.body
+      end
+
+      def test_upload_create_way_referring_undefined_node_placeholder
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <way id="-1" changeset="#{changeset.id}">
+                <nd ref="-1"/>
+              </way>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Way.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :bad_request
+        end
+        assert_equal "Placeholder node not found for reference -1 in way -1", @response.body
+      end
+
+      def test_upload_create_existing_way_referring_undefined_node_placeholder
+        changeset = create(:changeset)
+        way = create(:way)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <way id="#{way.id}" changeset="#{changeset.id}" version="1">
+                <nd ref="-1"/>
+              </way>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :bad_request
+        assert_equal "Placeholder node not found for reference -1 in way #{way.id}", @response.body
+
+        way.reload
+        assert_equal 1, way.version
+      end
+
+      def test_upload_create_relation_referring_undefined_node_placeholder
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <relation id="-1" changeset="#{changeset.id}" version="1">
+                <member type="node" role="foo" ref="-1"/>
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Relation.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :bad_request
+        end
+        assert_equal "Placeholder Node not found for reference -1 in relation -1.", @response.body
+      end
+
+      def test_upload_create_existing_relation_referring_undefined_way_placeholder
+        changeset = create(:changeset)
+        relation = create(:relation)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <relation id="#{relation.id}" changeset="#{changeset.id}" version="1">
+                <member type="way" role="bar" ref="-1"/>
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :bad_request
+        assert_equal "Placeholder Way not found for reference -1 in relation #{relation.id}.", @response.body
+
+        relation.reload
+        assert_equal 1, relation.version
+      end
+
+      def test_upload_create_relations_with_circular_references
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange version='0.6'>
+            <create>
+              <relation id='-2' version='0' changeset='#{changeset.id}'>
+                <member type='relation' role='' ref='-4' />
+                <tag k='type' v='route' />
+                <tag k='name' v='AtoB' />
+              </relation>
+              <relation id='-3' version='0' changeset='#{changeset.id}'>
+                <tag k='type' v='route' />
+                <tag k='name' v='BtoA' />
+              </relation>
+              <relation id='-4' version='0' changeset='#{changeset.id}'>
+                <member type='relation' role='' ref='-2' />
+                <member type='relation' role='' ref='-3' />
+                <tag k='type' v='route_master' />
+                <tag k='name' v='master' />
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header
+
+        assert_response :bad_request
+        assert_equal "Placeholder Relation not found for reference -4 in relation -2.", @response.body
+      end
+
       # -------------------------------------
       # Test modifying elements.
       # -------------------------------------
@@ -201,6 +553,818 @@ module Api
         assert_equal 0, relation.tags.size, "relation #{relation.id} should now have no tags"
         assert_equal [["Way", way.id, "some"], ["Node", node.id, "some"], ["Relation", other_relation.id, "some"]], relation.members
       end
+
+      ##
+      # upload multiple versions of the same element in the same diff.
+      def test_upload_modify_multiple_node_versions
+        node = create(:node)
+        changeset = create(:changeset)
+
+        # change the location of a node multiple times, each time referencing
+        # the last version. doesn't this depend on version numbers being
+        # sequential?
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <node id='#{node.id}' lon='0.0' lat='0.0' changeset='#{changeset.id}' version='1'/>
+              <node id='#{node.id}' lon='0.1' lat='0.0' changeset='#{changeset.id}' version='2'/>
+              <node id='#{node.id}' lon='0.1' lat='0.1' changeset='#{changeset.id}' version='3'/>
+              <node id='#{node.id}' lon='0.1' lat='0.2' changeset='#{changeset.id}' version='4'/>
+              <node id='#{node.id}' lon='0.2' lat='0.2' changeset='#{changeset.id}' version='5'/>
+              <node id='#{node.id}' lon='0.3' lat='0.2' changeset='#{changeset.id}' version='6'/>
+              <node id='#{node.id}' lon='0.3' lat='0.3' changeset='#{changeset.id}' version='7'/>
+              <node id='#{node.id}' lon='0.9' lat='0.9' changeset='#{changeset.id}' version='8'/>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        assert_dom "diffResult>node", 8
+
+        node.reload
+        assert_equal 9, node.version
+        assert_equal 0.9 * GeoRecord::SCALE, node.latitude
+        assert_equal 0.9 * GeoRecord::SCALE, node.longitude
+      end
+
+      ##
+      # upload multiple versions of the same element in the same diff, but
+      # keep the version numbers the same.
+      def test_upload_modify_duplicate_node_versions
+        node = create(:node, :latitude => 0, :longitude => 0)
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <node id='#{node.id}' lon='1' lat='1' changeset='#{changeset.id}' version='1'/>
+              <node id='#{node.id}' lon='2' lat='2' changeset='#{changeset.id}' version='1'/>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :conflict
+
+        node.reload
+        assert_equal 1, node.version
+        assert_equal 0, node.latitude
+        assert_equal 0, node.longitude
+      end
+
+      ##
+      # try to upload some elements without specifying the version
+      def test_upload_modify_missing_node_version
+        node = create(:node, :latitude => 0, :longitude => 0)
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <node id='#{node.id}' lon='1' lat='1' changeset='#{changeset.id}'/>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :bad_request
+
+        node.reload
+        assert_equal 1, node.version
+        assert_equal 0, node.latitude
+        assert_equal 0, node.longitude
+      end
+
+      ##
+      # create a diff which references several changesets, which should cause
+      # a rollback and none of the diff gets committed
+      def test_upload_modify_with_references_to_different_changesets
+        changeset1 = create(:changeset)
+        changeset2 = create(:changeset, :user => changeset1.user)
+        node1 = create(:node)
+        node2 = create(:node)
+
+        # simple diff to create a node way and relation using placeholders
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <node id='#{node1.id}' lon='0' lat='0' changeset='#{changeset1.id}' version='1'/>
+            </modify>
+            <modify>
+              <node id='#{node2.id}' lon='0' lat='0' changeset='#{changeset2.id}' version='1'/>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset1.user
+
+        post api_changeset_upload_path(changeset1), :params => diff, :headers => auth_header
+
+        assert_response :conflict
+
+        assert_nodes_are_equal(node1, Node.find(node1.id))
+        assert_nodes_are_equal(node2, Node.find(node2.id))
+      end
+
+      ##
+      # upload a valid changeset which has a mixture of whitespace
+      # to check a bug https://github.com/openstreetmap/trac-tickets/issues/1565
+      def test_upload_modify_with_mixed_whitespace
+        changeset = create(:changeset)
+        node = create(:node)
+        way = create(:way_with_nodes, :nodes_count => 2)
+        relation = create(:relation)
+        other_relation = create(:relation)
+        create(:relation_tag, :relation => relation)
+
+        diff = <<~CHANGESET
+          <osmChange>
+          <modify><node id='#{node.id}' lon='0' lat='0' changeset='#{changeset.id}'
+            version='1'></node>
+            <node id='#{node.id}' lon='1' lat='1' changeset='#{changeset.id}' version='2'><tag k='k' v='v'/></node></modify>
+          <modify>
+          <relation id='#{relation.id}' changeset='#{changeset.id}' version='1'><member
+            type='way' role='some' ref='#{way.id}'/><member
+              type='node' role='some' ref='#{node.id}'/>
+            <member type='relation' role='some' ref='#{other_relation.id}'/>
+            </relation>
+          </modify></osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        assert_dom "diffResult>node", 2
+        assert_dom "diffResult>relation", 1
+
+        assert_equal 1, Node.find(node.id).tags.size, "node #{node.id} should now have one tag"
+        assert_equal 0, Relation.find(relation.id).tags.size, "relation #{relation.id} should now have no tags"
+      end
+
+      def test_upload_modify_unknown_node_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<modify><node id='-1' lon='0' lat='0' changeset='#{changeset.id}' version='1'/></modify>"
+        end
+      end
+
+      def test_upload_modify_unknown_way_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<modify><way id='-1' changeset='#{changeset.id}' version='1'/></modify>"
+        end
+      end
+
+      def test_upload_modify_unknown_relation_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<modify><relation id='-1' changeset='#{changeset.id}' version='1'/></modify>"
+        end
+      end
+
+      # -------------------------------------
+      # Test deleting elements.
+      # -------------------------------------
+
+      ##
+      # test a complex delete where we delete elements which rely on each other
+      # in the same transaction.
+      def test_upload_delete_elements
+        changeset = create(:changeset)
+        super_relation = create(:relation)
+        used_relation = create(:relation)
+        used_way = create(:way)
+        used_node = create(:node)
+        create(:relation_member, :relation => super_relation, :member => used_relation)
+        create(:relation_member, :relation => super_relation, :member => used_way)
+        create(:relation_member, :relation => super_relation, :member => used_node)
+
+        diff = XML::Document.new
+        diff.root = XML::Node.new "osmChange"
+        delete = XML::Node.new "delete"
+        diff.root << delete
+        delete << xml_node_for_relation(super_relation)
+        delete << xml_node_for_relation(used_relation)
+        delete << xml_node_for_way(used_way)
+        delete << xml_node_for_node(used_node)
+        %w[node way relation].each do |type|
+          delete.find("//osmChange/delete/#{type}").each do |n|
+            n["changeset"] = changeset.id.to_s
+          end
+        end
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header
+
+        assert_response :success
+
+        assert_dom "diffResult", 1 do
+          assert_dom "> node", 1
+          assert_dom "> way", 1
+          assert_dom "> relation", 2
+        end
+
+        assert_not Node.find(used_node.id).visible
+        assert_not Way.find(used_way.id).visible
+        assert_not Relation.find(super_relation.id).visible
+        assert_not Relation.find(used_relation.id).visible
+      end
+
+      ##
+      # test uploading a delete with no lat/lon, as they are optional in the osmChange spec.
+      def test_upload_delete_node_without_latlon
+        node = create(:node)
+        changeset = create(:changeset)
+
+        diff = "<osmChange><delete><node id='#{node.id}' version='#{node.version}' changeset='#{changeset.id}'/></delete></osmChange>"
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        assert_dom "diffResult", 1 do
+          assert_dom "> node", 1 do
+            assert_dom "> @old_id", node.id.to_s
+            assert_dom "> @new_id", 0
+            assert_dom "> @new_version", 0
+          end
+        end
+
+        node.reload
+        assert_not node.visible
+      end
+
+      ##
+      # test that deleting stuff in a transaction doesn't bypass the checks
+      # to ensure that used elements are not deleted.
+      def test_upload_delete_referenced_elements
+        changeset = create(:changeset)
+        relation = create(:relation)
+        other_relation = create(:relation)
+        used_way = create(:way)
+        used_node = create(:node)
+        create(:relation_member, :relation => relation, :member => used_way)
+        create(:relation_member, :relation => relation, :member => used_node)
+
+        diff = XML::Document.new
+        diff.root = XML::Node.new "osmChange"
+        delete = XML::Node.new "delete"
+        diff.root << delete
+        delete << xml_node_for_relation(other_relation)
+        delete << xml_node_for_way(used_way)
+        delete << xml_node_for_node(used_node)
+        %w[node way relation].each do |type|
+          delete.find("//osmChange/delete/#{type}").each do |n|
+            n["changeset"] = changeset.id.to_s
+          end
+        end
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header
+
+        assert_response :precondition_failed
+        assert_equal "Precondition failed: Way #{used_way.id} is still used by relations #{relation.id}.", @response.body
+
+        assert Node.find(used_node.id).visible
+        assert Way.find(used_way.id).visible
+        assert Relation.find(relation.id).visible
+        assert Relation.find(other_relation.id).visible
+      end
+
+      ##
+      # test that a conditional delete of an in use object works.
+      def test_upload_delete_if_unused
+        changeset = create(:changeset)
+        super_relation = create(:relation)
+        used_relation = create(:relation)
+        used_way = create(:way)
+        used_node = create(:node)
+        create(:relation_member, :relation => super_relation, :member => used_relation)
+        create(:relation_member, :relation => super_relation, :member => used_way)
+        create(:relation_member, :relation => super_relation, :member => used_node)
+
+        diff = XML::Document.new
+        diff.root = XML::Node.new "osmChange"
+        delete = XML::Node.new "delete"
+        diff.root << delete
+        delete["if-unused"] = ""
+        delete << xml_node_for_relation(used_relation)
+        delete << xml_node_for_way(used_way)
+        delete << xml_node_for_node(used_node)
+        %w[node way relation].each do |type|
+          delete.find("//osmChange/delete/#{type}").each do |n|
+            n["changeset"] = changeset.id.to_s
+          end
+        end
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header
+
+        assert_response :success
+
+        assert_dom "diffResult[version='#{Settings.api_version}'][generator='#{Settings.generator}']", 1 do
+          assert_dom "> node", 1 do
+            assert_dom "> @old_id", used_node.id.to_s
+            assert_dom "> @new_id", used_node.id.to_s
+            assert_dom "> @new_version", used_node.version.to_s
+          end
+          assert_dom "> way", 1 do
+            assert_dom "> @old_id", used_way.id.to_s
+            assert_dom "> @new_id", used_way.id.to_s
+            assert_dom "> @new_version", used_way.version.to_s
+          end
+          assert_dom "> relation", 1 do
+            assert_dom "> @old_id", used_relation.id.to_s
+            assert_dom "> @new_id", used_relation.id.to_s
+            assert_dom "> @new_version", used_relation.version.to_s
+          end
+        end
+
+        assert Node.find(used_node.id).visible
+        assert Way.find(used_way.id).visible
+        assert Relation.find(used_relation.id).visible
+      end
+
+      def test_upload_delete_with_multiple_blocks_and_if_unused
+        changeset = create(:changeset)
+        node = create(:node)
+        way = create(:way)
+        create(:way_node, :way => way, :node => node)
+        alone_node = create(:node)
+
+        diff = <<~CHANGESET
+          <osmChange version='0.6'>
+            <delete version="0.6">
+              <node id="#{node.id}" version="#{node.version}" changeset="#{changeset.id}"/>
+            </delete>
+            <delete version="0.6" if-unused="true">
+              <node id="#{alone_node.id}" version="#{alone_node.version}" changeset="#{changeset.id}"/>
+            </delete>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff.to_s, :headers => auth_header
+
+        assert_response :precondition_failed
+
+        assert_equal "Precondition failed: Node #{node.id} is still used by ways #{way.id}.", @response.body
+      end
+
+      def test_upload_delete_unknown_node_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<delete><node id='-1' changeset='#{changeset.id}' version='1'/></delete>"
+        end
+      end
+
+      def test_upload_delete_unknown_way_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<delete><way id='-1' changeset='#{changeset.id}' version='1'/></delete>"
+        end
+      end
+
+      def test_upload_delete_unknown_relation_placeholder
+        check_upload_results_in_not_found do |changeset|
+          "<delete><relation id='-1' changeset='#{changeset.id}' version='1'/></delete>"
+        end
+      end
+
+      # -------------------------------------
+      # Test combined element changes.
+      # -------------------------------------
+
+      ##
+      # upload something which creates new objects and inserts them into
+      # existing containers using placeholders.
+      def test_upload_create_and_insert_elements
+        way = create(:way)
+        node = create(:node)
+        relation = create(:relation)
+        create(:way_node, :way => way, :node => node)
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+            </create>
+            <modify>
+              <way id='#{way.id}' changeset='#{changeset.id}' version='1'>
+                <nd ref='-1'/>
+                <nd ref='#{node.id}'/>
+              </way>
+              <relation id='#{relation.id}' changeset='#{changeset.id}' version='1'>
+                <member type='way' role='some' ref='#{way.id}'/>
+                <member type='node' role='some' ref='-1'/>
+                <member type='relation' role='some' ref='#{relation.id}'/>
+              </relation>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        new_node_id = nil
+        assert_dom "diffResult[version='#{Settings.api_version}'][generator='#{Settings.generator}']", 1 do
+          assert_dom "> node", 1 do |(node_el)|
+            new_node_id = node_el["new_id"].to_i
+          end
+          assert_dom "> way", 1
+          assert_dom "> relation", 1
+        end
+
+        assert_equal 2, Node.find(new_node_id).tags.size, "new node should have two tags"
+        assert_equal [new_node_id, node.id], Way.find(way.id).nds, "way nodes should match"
+        Relation.find(relation.id).members.each do |type, id, _role|
+          assert_equal new_node_id, id, "relation should contain new node" if type == "node"
+        end
+      end
+
+      ##
+      # test that a placeholder can be reused within the same upload.
+      def test_upload_create_modify_delete_node_reusing_placeholder
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k="foo" v="bar"/>
+              </node>
+            </create>
+            <modify>
+              <node id='-1' lon='1' lat='1' changeset='#{changeset.id}' version='1'/>
+            </modify>
+            <delete>
+              <node id='-1' lon='2' lat='2' changeset='#{changeset.id}' version='2'/>
+            </delete>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_difference "Node.count", 1 do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :success
+        end
+
+        assert_dom "diffResult>node", 3
+        assert_dom "diffResult>node[old_id='-1']", 3
+
+        node = Node.last
+        assert_equal 3, node.version
+        assert_not node.visible
+      end
+
+      def test_upload_create_and_duplicate_delete
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id="-1" lat="39" lon="116" changeset="#{changeset.id}" />
+            </create>
+            <delete>
+              <node id="-1" version="1" changeset="#{changeset.id}" />
+              <node id="-1" version="1" changeset="#{changeset.id}" />
+            </delete>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_no_difference "Node.count" do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :gone
+        end
+      end
+
+      def test_upload_create_and_duplicate_delete_if_unused
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id="-1" lat="39" lon="116" changeset="#{changeset.id}" />
+            </create>
+            <delete if-unused="true">
+              <node id="-1" version="1" changeset="#{changeset.id}" />
+              <node id="-1" version="1" changeset="#{changeset.id}" />
+            </delete>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        assert_difference "Node.count", 1 do
+          post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+          assert_response :success
+        end
+
+        assert_dom "diffResult>node", 3
+        assert_dom "diffResult>node[old_id='-1']", 3
+        assert_dom "diffResult>node[new_version='1']", 1
+        assert_dom "diffResult>node[new_version='2']", 1
+
+        node = Node.last
+        assert_equal 2, node.version
+        assert_not node.visible
+      end
+
+      # -------------------------------------
+      # Test bounding boxes.
+      # -------------------------------------
+
+      def test_upload_bbox_of_widely_spaced_nodes
+        user = create(:user)
+
+        # create an old changeset to ensure we have the maximum rate limit
+        create(:changeset, :user => user, :created_at => Time.now.utc - 28.days)
+
+        changeset = create(:changeset, :user => user)
+
+        # upload some widely-spaced nodes, spiralling positive and negative
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='-20' lat='-10' changeset='#{changeset.id}'/>
+              <node id='-10' lon='20'  lat='10' changeset='#{changeset.id}'/>
+              <node id='-2' lon='-40' lat='-20' changeset='#{changeset.id}'/>
+              <node id='-11' lon='40'  lat='20' changeset='#{changeset.id}'/>
+              <node id='-3' lon='-60' lat='-30' changeset='#{changeset.id}'/>
+              <node id='-12' lon='60'  lat='30' changeset='#{changeset.id}'/>
+              <node id='-4' lon='-80' lat='-40' changeset='#{changeset.id}'/>
+              <node id='-13' lon='80'  lat='40' changeset='#{changeset.id}'/>
+              <node id='-5' lon='-100' lat='-50' changeset='#{changeset.id}'/>
+              <node id='-14' lon='100'  lat='50' changeset='#{changeset.id}'/>
+              <node id='-6' lon='-120' lat='-60' changeset='#{changeset.id}'/>
+              <node id='-15' lon='120'  lat='60' changeset='#{changeset.id}'/>
+              <node id='-7' lon='-140' lat='-70' changeset='#{changeset.id}'/>
+              <node id='-16' lon='140'  lat='70' changeset='#{changeset.id}'/>
+              <node id='-8' lon='-160' lat='-80' changeset='#{changeset.id}'/>
+              <node id='-17' lon='160'  lat='80' changeset='#{changeset.id}'/>
+              <node id='-9' lon='-179.9' lat='-89.9' changeset='#{changeset.id}'/>
+              <node id='-18' lon='179.9'  lat='89.9' changeset='#{changeset.id}'/>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        # check that the changeset bbox is within bounds
+        changeset.reload
+        assert_operator changeset.min_lon, :>=, -180 * GeoRecord::SCALE, "Minimum longitude (#{changeset.min_lon / GeoRecord::SCALE}) should be >= -180 to be valid."
+        assert_operator changeset.max_lon, :<=, 180 * GeoRecord::SCALE, "Maximum longitude (#{changeset.max_lon / GeoRecord::SCALE}) should be <= 180 to be valid."
+        assert_operator changeset.min_lat, :>=, -90 * GeoRecord::SCALE, "Minimum latitude (#{changeset.min_lat / GeoRecord::SCALE}) should be >= -90 to be valid."
+        assert_operator changeset.max_lat, :<=, 90 * GeoRecord::SCALE, "Maximum latitude (#{changeset.max_lat / GeoRecord::SCALE}) should be <= 90 to be valid."
+      end
+
+      def test_upload_bbox_of_moved_node
+        changeset = create(:changeset)
+        node = create(:node, :lat => 1.0, :lon => 2.0)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <node id='#{node.id}' lat='1.1' lon='2.1' changeset='#{changeset.id}' version='1'/>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        # check the bbox
+        changeset.reload
+        assert_equal 1.0 * GeoRecord::SCALE, changeset.min_lat, "min_lat should be 1.0 degrees"
+        assert_equal 2.0 * GeoRecord::SCALE, changeset.min_lon, "min_lon should be 2.0 degrees"
+        assert_equal 1.1 * GeoRecord::SCALE, changeset.max_lat, "max_lat should be 1.1 degrees"
+        assert_equal 2.1 * GeoRecord::SCALE, changeset.max_lon, "max_lon should be 2.1 degrees"
+      end
+
+      def test_upload_bbox_of_extended_way
+        way = create(:way)
+        initial_node = create(:node, :lat => 1.1, :lon => 2.1)
+        create(:way_node, :way => way, :node => initial_node)
+        added_node = create(:node, :lat => 1.3, :lon => 2.3)
+        changeset = create(:changeset)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <modify>
+              <way id='#{way.id}' changeset='#{changeset.id}' version='1'>
+                <nd ref='#{initial_node.id}'/>
+                <nd ref='#{added_node.id}'/>
+              </way>
+            </modify>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :success
+
+        # check the bbox
+        changeset.reload
+        assert_equal 1.1 * GeoRecord::SCALE, changeset.min_lat, "min_lat should be 1.1 degrees"
+        assert_equal 2.1 * GeoRecord::SCALE, changeset.min_lon, "min_lon should be 2.1 degrees"
+        assert_equal 1.3 * GeoRecord::SCALE, changeset.max_lat, "max_lat should be 1.3 degrees"
+        assert_equal 2.3 * GeoRecord::SCALE, changeset.max_lon, "max_lon should be 2.3 degrees"
+      end
+
+      # -------------------------------------
+      # Test upload rate/size limits.
+      # -------------------------------------
+
+      def test_upload_initial_rate_limit
+        user = create(:user)
+        node = create(:node)
+        way = create(:way_with_nodes, :nodes_count => 2)
+        relation = create(:relation)
+
+        # create a changeset that puts us near the initial rate limit
+        changeset = create(:changeset, :user => user,
+                                       :created_at => Time.now.utc - 5.minutes,
+                                       :num_changes => Settings.initial_changes_per_hour - 2)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+              <way id='-1' changeset='#{changeset.id}'>
+                <nd ref='#{node.id}'/>
+              </way>
+            </create>
+            <create>
+              <relation id='-1' changeset='#{changeset.id}'>
+                <member type='way' role='some' ref='#{way.id}'/>
+                <member type='node' role='some' ref='#{node.id}'/>
+                <member type='relation' role='some' ref='#{relation.id}'/>
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :too_many_requests, "upload did not hit rate limit"
+      end
+
+      def test_upload_maximum_rate_limit
+        user = create(:user)
+        node = create(:node)
+        way = create(:way_with_nodes, :nodes_count => 2)
+        relation = create(:relation)
+
+        # create a changeset to establish our initial edit time
+        changeset = create(:changeset, :user => user,
+                                       :created_at => Time.now.utc - 28.days)
+
+        # create changeset to put us near the maximum rate limit
+        total_changes = Settings.max_changes_per_hour - 2
+        while total_changes.positive?
+          changes = [total_changes, Changeset::MAX_ELEMENTS].min
+          changeset = create(:changeset, :user => user,
+                                         :created_at => Time.now.utc - 5.minutes,
+                                         :num_changes => changes)
+          total_changes -= changes
+        end
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0' lat='0' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+              <way id='-1' changeset='#{changeset.id}'>
+                <nd ref='#{node.id}'/>
+              </way>
+            </create>
+            <create>
+              <relation id='-1' changeset='#{changeset.id}'>
+                <member type='way' role='some' ref='#{way.id}'/>
+                <member type='node' role='some' ref='#{node.id}'/>
+                <member type='relation' role='some' ref='#{relation.id}'/>
+              </relation>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :too_many_requests, "upload did not hit rate limit"
+      end
+
+      def test_upload_initial_size_limit
+        user = create(:user)
+
+        # create a changeset that puts us near the initial size limit
+        changeset = create(:changeset, :user => user,
+                                       :min_lat => (-0.5 * GeoRecord::SCALE).round, :min_lon => (0.5 * GeoRecord::SCALE).round,
+                                       :max_lat => (0.5 * GeoRecord::SCALE).round, :max_lon => (2.5 * GeoRecord::SCALE).round)
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='0.9' lat='2.9' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :payload_too_large, "upload did not hit size limit"
+      end
+
+      def test_upload_size_limit_after_one_week
+        user = create(:user)
+
+        # create a changeset to establish our initial edit time
+        create(:changeset, :user => user, :created_at => Time.now.utc - 7.days)
+
+        # create a changeset that puts us near the initial size limit
+        changeset = create(:changeset, :user => user, :bbox => [0.5, -0.5, 2.5, 0.5])
+
+        diff = <<~CHANGESET
+          <osmChange>
+            <create>
+              <node id='-1' lon='35' lat='35' changeset='#{changeset.id}'>
+                <tag k='foo' v='bar'/>
+                <tag k='baz' v='bat'/>
+              </node>
+            </create>
+          </osmChange>
+        CHANGESET
+
+        auth_header = bearer_authorization_header user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :payload_too_large, "upload did not hit size limit"
+      end
+
+      private
+
+      def check_upload_results_in_not_found(&)
+        changeset = create(:changeset)
+        diff = "<osmChange>#{yield changeset}</osmChange>"
+        auth_header = bearer_authorization_header changeset.user
+
+        post api_changeset_upload_path(changeset), :params => diff, :headers => auth_header
+
+        assert_response :not_found
+        changeset.reload
+        assert_equal 0, changeset.num_changes
+      end
     end
   end
 end