require 'ballonizer' require 'rspec-html-matchers' # Avoid to use equivalent-xml, the specs break with cosmetic changes this way require 'equivalent-xml' require 'stringio' require 'rexml/document' require 'nokogiri' # make the changes in the BD restricted to the example RSpec.configure do |c| c.around(:each) do |example| DB.transaction(:rollback=>:always){example.run} end end RSpec::Matchers.define :exist_in_filesystem do match do | actual | if actual.respond_to? :all? actual.all? { | filename | File.exist?(filename) } else File.exist? actual end end failure_message_for_should do | actual | if actual.respond_to? :all? "expected #{actual} to be a list of absolute filepaths for existing files or directories" else "expected #{actual} to be a absolute filepath for an existing file or directory" end end description do "be a list of absolute paths for existing files or directories" end end # TODO: check the font-size, the position and the size of the ballon # TODO: check the src of the image inside the container (not so easy # because the src is relative in the html but absolute in the database) RSpec::Matchers.define :have_ballons_as_submitted_by do | expected | match do | actual | expected.each do | img_src, ballons | expect(actual).to(have_tag('.ballonizer_image_container') do with_tag('img') ballons.each do | b | with_tag('span', { text: b['text'], with: { class: 'ballonizer_ballon' }}) end end) end end description do 'have ballons as the ones added by the last submit' end end describe Ballonizer do DB = Sequel.sqlite def self.populate_ballonizer_tables_with_test_data(db, time) db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg'}) db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/2.jpg'}) db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/3.jpg'}) db[:ballons].insert({ text: 'first ballon of first image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) db[:ballons].insert({ text: 'second ballon of first image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) db[:ballons].insert({ text: 'first ballon of second image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) db[:ballons].insert({ text: 'second ballon of second image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) db[:ballons].insert({ text: 'first ballon of third image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) db[:ballons].insert({ text: 'second ballon of third image', top: 0, left: 0, width: 0.5, height: 0.5, font_size: 16 }) # both ballons added in the first version db[:ballonized_image_versions].insert({image_id: 1, version: 1, time: time}) db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 1}) db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 2}) # second ballon added in the second version db[:ballonized_image_versions].insert({image_id: 2, version: 1, time: time}) db[:ballonized_image_ballons].insert({image_id: 2, version: 1, ballon_id: 3}) db[:ballonized_image_versions].insert({image_id: 2, version: 2, time: time}) db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 3}) db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 4}) # both added in first version, but second removed in the second version db[:ballonized_image_versions].insert({image_id: 3, version: 1, time: time}) db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 5}) db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 6}) db[:ballonized_image_versions].insert({image_id: 3, version: 2, time: time}) db[:ballonized_image_ballons].insert({image_id: 3, version: 2, ballon_id: 5}) end Ballonizer.create_tables(DB) self.populate_ballonizer_tables_with_test_data(DB, Time.at(0)) def deep_copy(v) JSON.parse(JSON.generate(v)) end # definitions to be overriden in need, but who doesn't need a *_example # counterpart (are so simple that clone and change isn't pratical) let (:mime_type) { 'application/xhtml+xml' } let (:ballonize_page_settings_arg) { {} } # Definitions ending with '_example' are to be cloned and defined in a # context without the sufix. Definitions without the sufix are used in the # specs and may require the definition of some without '_example' counterparts. let (:ballonizer_settings_example) do {} end let (:ballonizer_new_args_example) do [DB, ballonizer_settings] end # TODO: This isn't a valid Rack env, to turn it in a valid env will be # necessary to add all obrigatory 'rack.' variables described in: # http://rack.rubyforge.org/doc/SPEC.html # (the missing are the hijack related and the .errors) let (:env_example) do form_data = StringIO.new(Addressable::URI.form_encode({ ballonizer_data: JSON.generate(submit_hash) })) { 'HTTP_HOST' => 'proxysite.net', 'SCRIPT_NAME' => '', 'PATH_INFO' => '/pt_BR-ballon_translate/comic/', 'QUERY_STRING' => '', 'SERVER_NAME' => 'proxysite.net', 'SERVER_PORT' => '80', 'REQUEST_METHOD' => 'POST', 'HTTP_VERSION' => 'HTTP/1.1', 'CONTENT_LENGTH' => form_data.size.to_s, 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', 'rack.url_scheme' => 'http', 'rack.input' => form_data, 'rack.version' => [1, 0], 'rack.multithread' => false, 'rack.multiprocess' => false, 'rack.run_once' => false } end let (:original_page_example) do doc = <<-END A title END Nokogiri::XML::Document.parse(doc) end let (:page_url_example) do 'http://comic-translation.com/tr/pt-BR/a_comic/' end let (:submit_json_example) do '{"http://imgs.xkcd.com/comics/cells.png":[{"left":0,"top":0,"width":1,"height":0.23837209302325582,"font_size":15,"text":"When you see a claim that a common drug or vitamin \"kills cancer cells in a petri dish\", keep in mind:"},{"left":0.0963302752293578,"top":0.9273255813953488,"width":0.7798165137614679,"height":0.055232558139534885,"font_size":16,"text":"So does a handgun."}]}' end let (:submit_hash_example) do JSON.parse(submit_json_example) end # definitions to be overriden in specific contexts (by doing a clone of the # '_example' counterpart and changing what is needed) let (:ballonizer_settings) { ballonizer_settings_example } let (:ballonizer_new_args) { ballonizer_new_args_example } let (:env) { env_example } let (:original_page) { original_page_example.to_s } let (:page_url) { page_url_example } let (:submit_json) { submit_json_example } let (:submit_hash) { submit_hash_example } # Definition who need others (no *_example) let (:instance) { described_class.new(*ballonizer_new_args) } let (:ballonize_page_call) do lambda { instance.ballonize_page( original_page, page_url, mime_type, ballonize_page_settings_arg )} end let (:ballonized_page) do ballonize_page_call.call end # TODO: verify if the style property has the correct values describe '#ballonize_page' do subject { ballonized_page } context "when the mime-type isn't valid" do let (:mime_type) { 'a invalid mime-type' } it { expect(ballonize_page_call).to raise_error(Ballonizer::Error) } end context "when the mime-type is valid" do context '(text/html)' do let (:mime_type) { 'text/html; charset=utf8' } it { expect(ballonize_page_call).to_not raise_error } end context '(application/xhtml+xml)' do let (:mime_type) { 'application/xhtml+xml; charset=utf8' } it { expect(ballonize_page_call).to_not raise_error } end end context "when the mime-type is 'application/xhtml+xml'" do context "but the page isn't a xml" do let (:original_page) do <<-END A title END end it 'return the original (not cloned) page argument, unmodified' do expect(ballonized_page).to be(original_page) end end end context 'when the page has no img elements to ballonize' do it "don't make changes in the page" do should be_equivalent_to(original_page) end end context 'when the page has img elements to ballonize' do context 'and one of them have .to_ballonize class' do let (:original_page) do page_with_images = original_page_example.clone img1 = "A test image" img2 = "A second test image" page_with_images.at_css('body') << img1 << img2 page_with_images.to_s end it 'add a container around the img' do should have_tag('span', :with => { class: 'ballonizer_image_container' }) do with_tag('img', :with => { alt: 'A test image' }) end end context 'and it have ballons in the database' do it 'add the ballons inside the container' do # the parentheses of the 'should' are necessary, otherwise the # conditions inside the block are silently not tested should(have_tag('span', :with => { class: 'ballonizer_image_container' }) do with_tag("img[alt='A test image']") with_tag('span', { text: 'first ballon of first image', with: { class: 'ballonizer_ballon' }}) with_tag('span', { text: 'second ballon of first image', with: { class: 'ballonizer_ballon'}}) end) end end context 'and the settings define to insert css' do let (:ballonizer_settings) do ballonizer_settings_example.merge({ add_required_css: true, css_asset_path_for_link: '/assets/css/' }) end it 'insert the link tag (with the correct path)' do # the example html don't have any link tag, so if there one # it was added by the method should have_tag('link', :with => { type: 'text/css' }) # we are using xhtml, so we parse as xml link_href = Nokogiri::XML(subject).at_css('link').attr('href') expect(link_href).to match(/^\/assets\/css\//) end context 'but the settings argument override the path' do let (:ballonize_page_settings_arg) do { css_asset_path_for_link: '/other_assets/css/' } end it 'use the settings argument path' do # we are using xhtml, so we parse as xml link_href = Nokogiri::XML(subject).at_css('link').attr('href') expect(link_href).to match(/^\/other_assets\/css\//) end end end context 'and the settings define to insert js' do let (:ballonizer_settings) do ballonizer_settings_example.merge({ # this option is added to avoid a false positive in the test add_js_for_edition: false, add_required_js_libs_for_edition: true, js_asset_path_for_link: '/assets/js/' }) end it 'insert the script tag (with the correct src)' do should have_tag('script', :with => { type: 'text/javascript' }) # we are using xhtml, so we parse as xml script_src = Nokogiri::XML(subject).at_css('script').attr('src') expect(script_src).to match(/^\/assets\/js\//) end context 'but the settings argument override the path' do let (:ballonize_page_settings_arg) do { js_asset_path_for_link: '/other_assets/js/' } end it 'use the settings argument path' do # we are using xhtml, so we parse as xml script_src = Nokogiri::XML(subject).at_css('script').attr('src') expect(script_src).to match(/^\/other_assets\/js\//) end end end context 'and the settings argument override the form_handler_url' do let (:ballonize_page_settings_arg) do { form_handler_url: '/other_request_handler', add_js_for_edition: true, add_required_js_libs_for_edition: false } end it 'change the js snippet to use the argument one' do # we are using xhtml, so we parse as xml script_text = Nokogiri::XML(subject).at_css('script').text expect(script_text).to match(/\/other_request_handler/) end end end context 'and more than one of them is to be ballonized' do let (:original_page) do page_with_images = original_page_example.clone # the first image in the db is used in the other test, don't need to # be reused img2 = "the second test image" img3 = "the third test image" img4 = "the fourth test image" page_with_images.at_css('body') << img2 << img3 << img4 page_with_images.to_s end it 'add a container around the imgs' do # TODO: DRY this test should(have_tag('span', :with => { class: 'ballonizer_image_container' }) do with_tag('img', :with => { alt: 'the second test image' }) end) should(have_tag('span', :with => { class: 'ballonizer_image_container' }) do with_tag('img', :with => { alt: 'the third test image' }) end) end context 'and they have ballons in the database' do it 'add the ballons inside the containers' do # TODO: break this spec in smaller parts, this specificate more # than one thing should(have_tag('span', :with => { class: 'ballonizer_image_container' }) do # the second image have two versions, the second ballon is added # in the second version, so here we verify if the ballonize_page # recover the ballons of the last version with_tag('img', :with => { alt: 'the second test image' }) with_tag('span', { text: 'first ballon of second image', with: { class: 'ballonizer_ballon' }}) with_tag('span', { text: 'second ballon of second image', with: { class: 'ballonizer_ballon' }}) end) should(have_tag('span', :with => { class: 'ballonizer_image_container' }) do # the third image have two versions, the second ballon is removed # in the second version, so here we verify if the ballonize_page # do not use a ballon of an old version in the image with_tag('img', :with => { alt: 'the third test image' }) with_tag('span', { text: 'first ballon of third image', with: { class: 'ballonizer_ballon' }}) without_tag('span', { text: 'second ballon of third image', with: { class: 'ballonizer_ballon' }}) end) end end end context 'and no one of them have the .to_ballonize class' do let (:original_page) do page_with_images = original_page_example.clone page_with_images.at_css('body') .add_child "A test image" page_with_images.to_s end it "don't add a container around the imgs" do should_not have_tag('span', :with => { class: 'ballonizer_image_container' }) end it "don't add the links of the required javascript and css" do # the callback will be called only if a image was ballonized? should_not have_tag('link') end end end end describe '#valid_submit_json?' do subject { instance.valid_submit_json? submit_json } shared_examples 'common behavior for invalid input' do it { should be_false } it 'taint the input' do instance.valid_submit_json? submit_json expect(submit_json.tainted?).to be_true end context 'but frozen' do let (:frozen_submit_json) { deep_copy(submit_json).freeze } let (:method_call) do lambda { instance.valid_submit_json? submit_json } end it "shouldn't taint the input" do # an attempt to taint a frozen object will raise a RuntimeError expect(method_call).to_not raise_error end end context 'when the second argument is true' do let (:method_call) do lambda { instance.valid_submit_json?(submit_json, true) } end # necessary define the 'exception_type' let variable in the context # who will use this shared_examples it { expect(method_call).to raise_error(exception_type) } end end # NOTE: All cases who throw a Ballonizer::SubmitError are verified # in the #valid_submit_hash? (who is used by the #valid_submit_json?) context 'with invalid input' do context '(a malformed json)' do let (:submit_json) { 'not a valid json' } let (:exception_type) { JSON::ParserError } include_examples 'common behavior for invalid input' end context '(invalid submit data)' do let (:submit_json) { '{}' } let (:exception_type) { described_class::SubmitError } include_examples 'common behavior for invalid input' end end context 'with valid input' do it { should be_true } it 'untaint the input' do instance.valid_submit_json? submit_json.taint expect(submit_json.tainted?).to be_false end context 'but frozen' do let (:frozen_tainted_submit_json) do deep_copy(submit_json).taint.freeze end let (:method_call) do lambda { instance.valid_submit_json? submit_json } end it "shouldn't untaint the input" do # an attempt to taint a frozen object will raise a RuntimeError expect(method_call).to_not raise_error end end context 'when the second argument is true' do let (:method_call) do lambda { instance.valid_submit_json?(submit_json, true) } end it { expect(method_call).to_not raise_error } end end end # TODO: validate ballon text and url size < 255; if the url is a url; # extra fields in the hash? describe '#valid_submit_hash?' do subject { instance.valid_submit_hash? submit_hash } # To be used in all contexts where the input is invalid shared_examples 'and the second argument is true' do # If this context isn't added the message don't appear in the output # of the 'rspec -fd' and the lets and subjects are merged (what can # create some nasty bugs) context 'and the second argument is true' do let (:method_call) do lambda { instance.valid_submit_hash?(submit_hash, true) } end it { expect(method_call).to raise_error(described_class::SubmitError) } end end context "when the bounds aren't numbers between 0 and 1" do let (:submit_hash) do deep_copy(submit_hash_example)[submit_hash_example.keys.first] .first.update({ top: 10 }) end it { should be_false } include_examples 'and the second argument is true' end context "when the text isn't a non-empty String" do let (:submit_hash) do deep_copy(submit_hash_example)[submit_hash_example.keys.first] .first.update({ text: "" }) end it { should be_false } include_examples 'and the second argument is true' end [:x, :y].each do | axis | position, size = { x: ["left", "top"], y: ["top", "height"] }[axis] context "when the #{position} plus #{size} is greater than one" do let (:submit_hash) do h = deep_copy(submit_hash_example) first_ballon = h[h.keys.first].first first_ballon[position] = 0.75 first_ballon[size] = 0.5 h end it { should be_false } include_examples 'and the second argument is true' end end context "when a ballon don't have font_size" do let (:submit_hash) do deep_copy(submit_hash_example)[submit_hash_example.keys.first] .first.update({ font_size: nil }) end it { should be_false } include_examples 'and the second argument is true' end context 'when the submit contain a image without ballons' do let (:submit_hash) do { submit_hash_example.keys.first => [] } end it { should be_true } end context 'when the hash is valid' do it { should be_true } context 'and the second argument is true' do let (:method_call) do lambda { instance.valid_submit_hash?(submit_hash, true) } end it { expect(method_call).to_not raise_error } it { expect(method_call.call).to be_true } end end end describe '#process_submit_hash' do # TODO: Add context "when the submit refer to two or more images?" context 'when its the first submit of a image' do let(:submit_hash) do # the keys are String because hashs parsed from JSON are this way { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [ { 'text' => 'the first ballon of the fourth image', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }, { 'text' => 'the second ballon of the fourth image', 'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 }, ]} end let (:original_page) do page_with_images = original_page_example.clone img = "A fourth test image" page_with_images.at_css('body') << img page_with_images.to_s end it 'the ballonize_page add the ballons to the image' do instance.process_submit_hash(submit_hash, Time.at(0)) expect(ballonized_page).to have_ballons_as_submitted_by(submit_hash) end end context 'when the submit refer to a image already with ballons' do let(:submit_hash) do # the keys are String because hashs parsed from JSON are this way { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg' => [ # the "(... version)" added to avoid reutilize a ballon (what is # not the objective of the test here) { 'text' => 'the first ballon (version 2) of the first image', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }, { 'text' => 'the second ballon (version 2) of the first image', 'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 }, ]} end let (:original_page) do page_with_images = original_page_example.clone img = "A test image" page_with_images.at_css('body') << img page_with_images.to_s end it 'the ballonize_page use the new ballons' do instance.process_submit_hash(submit_hash, Time.at(0)) expect(ballonized_page).to have_ballons_as_submitted_by(submit_hash) end end end describe '#process_submit_json' do # As process_submit_json use process_submit_hash (it's only a convenience # method) we don't test the rest of its behaviour (who is already covered # in #process_submit_hash) context 'when the input is tainted' do let (:submit_json) do submit_hash_example.clone.taint end it do expect { instance.process_submit_json(submit_json) }.to raise_error(SecurityError) end end end describe '#process_submit' do # As process_submit use the #valid_submit_json? and #process_submit_hash # we only make the tests here for example and its not the ideia cover the # cases already covered in the specs of the two methods context 'when the input is invalid' do # The submit hash is used to define the env_example # (and in consequence the env) let (:submit_hash) do # this input is invalid because have no font_size { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [ { 'text' => 'test', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 } ]} end it do expect { instance.process_submit(env) }.to raise_error( described_class::SubmitError ) end end context 'when the input is valid' do # this code is almost the same as the used in #process_submit_hash, # think in some way to DRY up this let(:submit_hash) do { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [ { 'text' => 'the first ballon of the fourth image', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5, 'font_size' => 16 } ]} end let (:original_page) do page_with_images = original_page_example.clone img = "A fourth test image" page_with_images.at_css('body') << img page_with_images.to_s end it 'the ballonize_page add the ballons to the image' do instance.process_submit(env) expect(ballonized_page).to have_ballons_as_submitted_by(submit_hash) end end end describe '#js_load_snippet' do subject { instance.js_load_snippet(settings_arg) } let (:settings_arg) { Hash.new } let (:ballonizer_settings) do ballonizer_settings_example.merge({ form_handler_url: '/request_handler' }) end it 'return contains the :form_handler_url setting' do should match(instance.settings[:form_handler_url]) end context 'when the settings argument is passed' do let (:settings_arg) do { form_handler_url: '/other_request_handler' } end it 'use the argument one' do should match(settings_arg[:form_handler_url]) should_not match(instance.settings[:form_handler_url]) end end end describe '#css_html_links' do shared_examples 'using settings parameter' do context 'and a path is passed by the settings hash argument' do it 'use the path from the argument' do css_links = instance.css_html_links({ css_asset_path_for_link: '/other_assets/css/' }) REXML::Document.new("#{css_links}") .root.children.each do | e | expect(e.attributes['href']).to match(/^\/other_assets\/css\//) end end end end context "when the path for the css isn't configured" do it 'returns nil' do expect(instance.css_html_links).to eq nil end include_examples 'using settings parameter' end context 'when the path for the css is configured' do let (:ballonizer_settings) do ballonizer_settings_example.merge({ css_asset_path_for_link: '/assets/css/' }) end it 'return a HTML string with links prefixed by the path' do REXML::Document.new("#{instance.css_html_links}") .root.children.each do | e | expect(e.attributes['href']).to match(/^\/assets\/css\//) end end include_examples 'using settings parameter' end end describe '#js_libs_html_links' do shared_examples 'using settings parameter' do context 'and a path is passed by the settings hash argument' do it 'use the path from the argument' do js_scripts = instance.js_libs_html_links({ js_asset_path_for_link: '/other_assets/js/' }) REXML::Document.new("#{js_scripts}") .root.children.each do | e | expect(e.attributes['src']).to match(/^\/other_assets\/js\//) end end end end context "when the path for the js libs isn't configured" do it 'returns nil' do expect(instance.js_libs_html_links).to eq nil end include_examples 'using settings parameter' end context 'when the path for the js libs is configured' do let (:ballonizer_settings) do ballonizer_settings_example.merge({ js_asset_path_for_link: '/assets/js/' }) end it 'return a HTML string with links prefixed by the path' do REXML::Document.new("#{instance.js_libs_html_links}") .root.children.each do | e | expect(e.attributes['src']).to match(/^\/assets\/js\//) end end include_examples 'using settings parameter' end end describe '.asset_load_paths' do it 'return an array of paths who exist in the gem root dir' do # TODO: change to __dir__ when the 2.0 become widely adopted spec_dir = File.dirname(File.realpath(__FILE__)) gem_root_dir = File.expand_path('../', spec_dir) absolute_load_paths = described_class.asset_load_paths.map do | path | File.expand_path(path, gem_root_dir) end expect(absolute_load_paths).to exist_in_filesystem end end describe '.asset_absolute_paths' do it 'return an array of absolute filepaths who exist' do expect(described_class.asset_absolute_paths).to exist_in_filesystem end end describe '.asset_logical_paths' do it 'return the last part of the asset_absolute_paths' do logical_paths = described_class.asset_logical_paths absolute_paths = described_class.asset_absolute_paths are_last_part_of_absolute = logical_paths.all? do | lp | absolute_paths.any? { | ap | ap.end_with? lp } end expect(are_last_part_of_absolute).to be_true end end describe '.assets_app' do let (:envs) do described_class.asset_logical_paths.map do | asset_logical_path | env_example.merge({ 'HTTP_HOST' => 'example.net', 'SERVER_NAME' => 'example.net', 'PATH_INFO' => asset_logical_path, 'REQUEST_METHOD' => 'GET', 'rack.input' => nil, 'CONTENT_TYPE' => nil, 'CONTENT_LENGTH' => nil }) end end it 'provide the css and js libs required by the gem' do envs.each do | env | expect(described_class.assets_app.call(env)[0]).to eq 200 end end end end