diff options
Diffstat (limited to 'Flow/tests')
117 files changed, 9784 insertions, 0 deletions
diff --git a/Flow/tests/browser/README.md b/Flow/tests/browser/README.md new file mode 100644 index 00000000..36319498 --- /dev/null +++ b/Flow/tests/browser/README.md @@ -0,0 +1 @@ +Please see https://github.com/wikimedia/mediawiki-selenium for instructions on how to run tests. diff --git a/Flow/tests/browser/features/action_menu_permalink.feature b/Flow/tests/browser/features/action_menu_permalink.feature new file mode 100644 index 00000000..d6197117 --- /dev/null +++ b/Flow/tests/browser/features/action_menu_permalink.feature @@ -0,0 +1,31 @@ +@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Actions menu Permalink + + Background: + Given I am logged in + And I am on Flow page + + Scenario: Topic Actions menu Permalink + Given I have created a Flow topic with title "Permalinktest" + When I click the Topic Actions link + And I click Permalink from the Actions menu + And I am viewing Topic page + Then I see only one topic on the page + And the top post should have a heading which contains "Permalinktest" + + Scenario: Actions menu Permalink + Given I have created a Flow topic with title "PermalinkReplyTest" + And I add 3 comments to the Topic + When I click the Post Actions link on the 3rd comment on the topic + And I click the Post Actions link on the 3rd comment on the topic + And I click Permalink from the 3rd comment Post Actions menu + And I am viewing Topic page + Then I see only one topic on the page + And the highlighted comment should contain the text for the 3rd comment + + Scenario: Old style topic permalink + Given I have created a Flow topic with title "Permalinktest" + When I go to an old style permalink to my topic + And I am viewing Topic page + Then I see only one topic on the page + And the top post should have a heading which contains "Permalinktest" diff --git a/Flow/tests/browser/features/anon_interface.feature b/Flow/tests/browser/features/anon_interface.feature new file mode 100644 index 00000000..37eb3647 --- /dev/null +++ b/Flow/tests/browser/features/anon_interface.feature @@ -0,0 +1,9 @@ +@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @phantomjs @test2.wikipedia.org +Feature: Check the interface for anonymous users + + Scenario: Anon does not see block or actions + Given I am on Flow page + And I have created a Flow topic + # which is not hidden (this is implicit from the above step) + When I see a flow creator element + Then the block author link should not be visible diff --git a/Flow/tests/browser/features/edit_existing.feature b/Flow/tests/browser/features/edit_existing.feature new file mode 100644 index 00000000..bbbb24c8 --- /dev/null +++ b/Flow/tests/browser/features/edit_existing.feature @@ -0,0 +1,22 @@ +@clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Edit existing title + + Assumes that the test Flow page has at least two topics (with posts). + + Background: + Given I am logged in + And I am on Flow page + And I have created a Flow topic + + Scenario: Edit an existing title + When I click the Edit title action + And I edit the title field with Title edited + And I save the new title + Then the top post should have a heading which contains "Title edited" + + @phantomjs + Scenario: Edit existing post + When I click Edit post + And I edit the post field with Post edited + And I save the new post + Then the saved post should contain Post edited diff --git a/Flow/tests/browser/features/flow_in_recent_changes.feature b/Flow/tests/browser/features/flow_in_recent_changes.feature new file mode 100644 index 00000000..d8ba83ff --- /dev/null +++ b/Flow/tests/browser/features/flow_in_recent_changes.feature @@ -0,0 +1,18 @@ +@clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Flow updates are in Recent Changes + + Background: + Given I am logged in + And I am on Flow page + And I have created a Flow topic with title "New topic should be in Recent Changes" + + Scenario: New topic is in Recent Changes + When I navigate to the Recent Changes page + Then the new topic should be in the Recent Changes page + + Scenario: Edited topic is in Recent Changes + When I click the Edit title action + And I edit the title field with Title should be in Recent Changes + And I save the new title + And I navigate to the Recent Changes page + Then the new title should be in the Recent Changes page diff --git a/Flow/tests/browser/features/flow_logged_in.feature b/Flow/tests/browser/features/flow_logged_in.feature new file mode 100644 index 00000000..596db0aa --- /dev/null +++ b/Flow/tests/browser/features/flow_logged_in.feature @@ -0,0 +1,33 @@ +@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @login @test2.wikipedia.org +Feature: Create new topic logged in + + It requires the cldr extension, a "Flow QA" page, and a "Selenium user" who has + permission to flow-delete (usually 'sysop'/administrator user right), to + flow-suppress (usually the 'oversight' user right), and to block (usually 'sysop'). + If the Selenium_user's Flow editor is VisualEditor, then the flow_page + definitions have to change. + + Background: + Given I am logged in + And I have created a Flow topic + + Scenario: Add new Flow topic and show author and block links + Given the author link is visible + And the talk to author link is not visible + And the block author link is not visible + # hover doesn't work in IE, bug 67723 + When I hover over the author link + Then the talk to author link should be visible + And the block author link should be visible + + Scenario: Post Actions + When I click the Post Actions link + Then I should see a Hide button + And I should see a Delete button + And I should see a Suppress button + + Scenario: Topic Actions + When I click the Topic Actions link + Then I should see a Hide topic button + And I should see a Delete topic button + And I should see a Suppress topic button diff --git a/Flow/tests/browser/features/flow_no_javascript.feature b/Flow/tests/browser/features/flow_no_javascript.feature new file mode 100644 index 00000000..439bbd6c --- /dev/null +++ b/Flow/tests/browser/features/flow_no_javascript.feature @@ -0,0 +1,21 @@ +@custom-browser @en.wikipedia.beta.wmflabs.org @firefox @login @test2.m.wikipedia.org +Feature: Basic site for legacy devices + + Background: + Given I am using user agent "Mozilla/4.0 (compatible; MSIE 5.5b1; Mac_PowerPC)" + And I am on a Flow page without JavaScript + + Scenario: I post a new topic without JavaScript + When I see the form to post a new topic + And I click Add topic no javascript + And I enter a no javascript topic title of "Selenium no javascript title" + And I enter a no javascript topic body of "Selenium no javascript body" + And I save a no javascript new topic + Then the page contains my no javascript topic + And the page contains my no javascript body + + Scenario: I reply to a topic without JavaScript + When I see the form to reply to a topic + And I enter a no javascript reply of "Selenium no javascript reply" + And I save a no javascript reply + Then the page contains my no javascript reply diff --git a/Flow/tests/browser/features/lock_unlock_topics.feature b/Flow/tests/browser/features/lock_unlock_topics.feature new file mode 100644 index 00000000..062c3049 --- /dev/null +++ b/Flow/tests/browser/features/lock_unlock_topics.feature @@ -0,0 +1,48 @@ +@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @login @test2.wikipedia.org +Feature: Lock and unlock topics + + Background: + Given I am logged in + + @wip + Scenario: Locked topics have no reply links + Given I am on Flow page + And I have created a Flow topic + And the top post has been locked + When I expand the top post + Then the original message for the top post should have no reply link + And the original message for the top post should have no edit link + + @internet_explorer_10 + Scenario: Locking a topic and then changing your mind + Given I am on Flow page + And I have created a Flow topic + When I click the Topic Actions link + And I click the Lock topic button + And I cancel the lock/unlock topic form + Then the top post should be an open discussion + And I should not see the lock/unlock form + + @internet_explorer_10 + Scenario: Locking a topic + Given I am on Flow page + And I have created a Flow topic + When I click the Topic Actions link + And I click the Lock topic button + And I type "This is a bikeshed" as the reason + And I submit the lock/unlock topic form + Then the top post should be a locked discussion + And the reason of the first topic should be "This is a bikeshed" + And the content of the top post should be visible + + # Close-then-unlock doesn't work in IE, it caches the API response (bug 69160). + Scenario: Opening a topic + Given I am on Flow page + And I have created a Flow topic + And the top post has been locked + And I click the Topic Actions link + And I click the Unlock topic button + When I type "Fun discussion" as the reason + And I submit the lock/unlock topic form + Then the top post should be an open discussion + And the content of the top post should be visible diff --git a/Flow/tests/browser/features/moderation.feature b/Flow/tests/browser/features/moderation.feature new file mode 100644 index 00000000..3fa14415 --- /dev/null +++ b/Flow/tests/browser/features/moderation.feature @@ -0,0 +1,44 @@ +@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Moderation + + Assumes Flow is enabled for the User_talk namespace. + + Background: + Given I am logged in + And I am on Flow page + + Scenario: Deleting a topic + Given I have created a Flow topic with title "Deletemeifyoudare" + When I click the Topic Actions link + And I click the Delete topic button + And I see a dialog box + And I give reason for deletion as being "He's a naughty boy" + And I click Delete topic + Then the top post should be marked as deleted + + Scenario: Suppressing a topic + Given I have created a Flow topic with title "Suppressmeifyoudare" + When I click the Topic Actions link + And I click the Suppress topic button + And I see a dialog box + And I give reason for suppression as being "Quelling the peasants" + And I click Suppress topic + Then the top post should be marked as suppressed + + Scenario: Cancelling a dialog without text + Given I have created a Flow topic with title "Testing cancel deletion of topic" + When I click the Topic Actions link + And I click the Delete topic button + And I see a dialog box + And I cancel the dialog + Then I do not see the dialog box + + Scenario: Cancelling a dialog with text + Given I have created a Flow topic with title "Testing cancel deletion of topic" + When I click the Topic Actions link + And I click the Delete topic button + And I see a dialog box + And I give reason for suppression as being "About to change my mind" + And I cancel the dialog + And I confirm + Then I do not see the dialog box diff --git a/Flow/tests/browser/features/new_topic.feature b/Flow/tests/browser/features/new_topic.feature new file mode 100644 index 00000000..78494bbd --- /dev/null +++ b/Flow/tests/browser/features/new_topic.feature @@ -0,0 +1,15 @@ +@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @phantomjs @test2.wikipedia.org +Feature: Creating a new topic + + Background: + Given I am on Flow page + + Scenario: Cannot create a new topic without content + When I type "Anonymous user topic creation test" into the new topic title field + Then the Save New Topic button should be disabled + + Scenario: Add new Flow topic as anonymous user + When I have created a Flow topic with title "Anonymous user topic creation" + # TODO the terminology below is terrible, posts don't have headings. It's the top topic's title and first post. + Then the top post should have a heading which contains "Anonymous user topic creation" + And the top post should have content which contains "Anonymous user topic creation" diff --git a/Flow/tests/browser/features/post_links.feature b/Flow/tests/browser/features/post_links.feature new file mode 100644 index 00000000..1ad2dfcd --- /dev/null +++ b/Flow/tests/browser/features/post_links.feature @@ -0,0 +1,12 @@ +@chrome @clean @ee-prototype.wmflabs.org @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Follow user links + + Scenario: User links takes me to the user page + Given I am logged in + And I am on Flow page + And I have created a Flow topic + And I see a flow creator element + When I click the flow creator element + Then I am on my user page + + diff --git a/Flow/tests/browser/features/reply.feature b/Flow/tests/browser/features/reply.feature new file mode 100644 index 00000000..a9bc50ea --- /dev/null +++ b/Flow/tests/browser/features/reply.feature @@ -0,0 +1,30 @@ +@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Replying + + Background: + Given I am logged in + And I am on Flow page + + @phantomjs + Scenario: I can reply + Given I have created a Flow topic with title "Reply test" + When I reply with comment "Boom boom shake shake the room" + Then the top post's first reply should contain the text "Boom boom shake shake the room" + + @phantomjs + Scenario: Replying updates watched state + Given I have created a Flow topic with title "Reply watch test" + And I am not watching my new Flow topic + When I reply with comment "I want to watch this title" + Then I should see an unwatch link on the topic + +# TODO maybe should test simple Cancelling reply as well. + + Scenario: Previewing reply, continue editing, then cancel leaves usable form + Given I have created a Flow topic with title "Reply preview test" + When I start a reply with comment "my form lies over the ocean" + And I click the Preview button + And I click the Keep editing button + And I click the Cancel button and confirm the dialog + And I start a reply with comment "bring back my form to me" + Then I should see the topic reply form diff --git a/Flow/tests/browser/features/reply_moderation.feature b/Flow/tests/browser/features/reply_moderation.feature new file mode 100644 index 00000000..c52a5954 --- /dev/null +++ b/Flow/tests/browser/features/reply_moderation.feature @@ -0,0 +1,17 @@ +@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Reply moderation + + Background: + Given I am logged in + And I am on Flow page + + Scenario: Hiding a comment + Given I have created a Flow topic with title "Hide comment test" + And I add 3 comments to the Topic + When I click the Post Actions link on the 3rd comment on the topic + And I click Hide comment button + And I see a dialog box + And I give reason for hiding as being "Shhhh!" + And I click the Hide button in the dialog + Then the 3rd comment should be marked as hidden + And the content of the 3rd comment should not be visible diff --git a/Flow/tests/browser/features/sorting_topics.feature b/Flow/tests/browser/features/sorting_topics.feature new file mode 100644 index 00000000..a1bcc8ea --- /dev/null +++ b/Flow/tests/browser/features/sorting_topics.feature @@ -0,0 +1,19 @@ +@chrome @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @phantomjs @test2.wikipedia.org +Feature: Sorting topics + + Background: + Given I am on Flow page + + Scenario: Switch topic sorting to Recently Active Topics + When I click Newest topics link + And I click Recently active topics choice + Then the Flow page should show Recently active topics link + And the Flow page should not show Newest topics link + + Scenario: Switch topic sorting to Recently Active Topics and then back to Newest topics + When I click Newest topics link + And I click Recently active topics choice + And I click Recently active topics link + And I click Newest topics choice + Then the Flow page should show Newest topics link + And the Flow page should not show Recently active topics link diff --git a/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb b/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb new file mode 100644 index 00000000..614e47db --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb @@ -0,0 +1,41 @@ +When(/^I add (\d+) comments to the Topic$/) do |number| + number.to_i.times do + @saved_random = Random.new.rand.to_s + step 'I reply with comment "' + 'Selenium comment ' + @saved_random + '"' + end +end + +When(/^I click Permalink from the Actions menu$/) do + on(FlowPage).permalink_button_element.when_present.click +end + +When(/^I click Permalink from the 3rd comment Post Actions menu$/) do + on(FlowPage).actions_link_permalink_3rd_comment_element.when_present.click +end + +When(/^I click the Post Actions link on the 3rd comment on the topic$/) do + on(FlowPage) do |page| + page.third_post_actions_link_element.when_present.focus + page.third_post_actions_link_element.click + end +end + +When(/^I go to an old style permalink to my topic$/) do + on(FlowPage) do |curPage| + work_flow_id = curPage.flow_first_topic_element.attribute('data-flow-id') + visit(FlowOldPermalinkPage, using_params: { workflow_id: work_flow_id }) + end +end + +Then(/^I see only one topic on the page$/) do + on(FlowPage) do |page| + # We should have the a post with a heading + expect(page.flow_first_topic_heading_element.when_present).to be_visible + # but this should match nothing - there is only one topic. + expect(page.flow_second_topic_heading_element).not_to be_visible + end +end + +Then(/^the highlighted comment should contain the text for the 3rd comment$/) do + expect(on(FlowPage).highlighted_post).to match @saved_random +end diff --git a/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb b/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb new file mode 100644 index 00000000..e743ea96 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/edit_existing_steps.rb @@ -0,0 +1,47 @@ +When(/^I click Edit post$/) do + on(FlowPage) do |page| + page.edit_post_element.when_present.click + end +end + +When(/^I click the Edit title action$/) do + on(FlowPage) do |page| + page.topic_actions_link_element.when_present.click + page.edit_title_button_element.when_present.click + end +end + +When(/^I edit the post field with (.+)$/) do |edited_post| + on(FlowPage) do |page| + # Take focus away from menu + page.post_edit_element.when_present.click + page.post_edit_element.when_present.send_keys(edited_post + @random_string) + end +end + +When(/^I edit the title field with (.+)$/) do |edited_title| + on(FlowPage) do |page| + @edited_topic_string = edited_title + @random_string + # Take focus away from menu + page.title_edit_element.when_present.click + page.title_edit_element.when_present.send_keys(@edited_topic_string) + end +end + +When(/^I save the new post/) do + on(FlowPage) do |page| + page.change_post_save_element.when_present.click + page.change_post_save_element.when_not_present + end +end + +When(/^I save the new title$/) do + on(FlowPage) do |page| + page.change_title_save_element.when_present.click + page.flow_first_topic_heading_element.when_present + end +end + +Then(/^the saved post should contain (.+)$/) do |edited_post| + expect(on(FlowPage).flow_first_topic_body).to match(edited_post + @random_string) +end diff --git a/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb b/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb new file mode 100644 index 00000000..7828377b --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb @@ -0,0 +1,11 @@ +When(/^I navigate to the Recent Changes page$/) do + visit(RecentChangesPage) +end + +Then(/^the new topic should be in the Recent Changes page$/) do + expect(on(RecentChangesPage).recent_changes_element.when_present.text).to match @topic_string +end + +Then(/^the new title should be in the Recent Changes page$/) do + expect(on(RecentChangesPage).recent_changes_element.when_present.text).to match @edited_topic_string +end diff --git a/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb b/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb new file mode 100644 index 00000000..765c4589 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb @@ -0,0 +1,65 @@ +# This test has no javascript +# Therefore this test has no AJAX +# Therefore it should run without any "when_present" clauses +# If you need a "when_present" to make the test run, that is a bug + +Given(/^I am on a Flow page without JavaScript$/) do + visit(FlowPage) +end + +Given(/^I am using user agent "(.+)"$/) do |user_agent| + @user_agent = user_agent + @browser = browser(test_name(@scenario), { user_agent: user_agent }) + $session_id = @browser.driver.instance_variable_get(:@bridge).session_id +end + +When(/^I click Add topic no javascript$/) do + on(FlowPage).no_javascript_topic_title_text_element.click +end + +When(/^I enter a no javascript reply of "(.*?)"$/) do |no_javascript_reply| + @no_javascript_reply = no_javascript_reply + on(FlowPage).no_javascript_reply_form_element.send_keys "#{@no_javascript_reply} #{@random_string}" +end + +When(/^I enter a no javascript topic body of "(.*?)"$/) do |no_javascript_topic_body| + @no_javascript_topic_body = no_javascript_topic_body + on(FlowPage).no_javascript_topic_body_text_element.send_keys "#{@no_javascript_topic_body} #{@random_string}" +end + +When(/^I enter a no javascript topic title of "(.*?)"$/) do |no_javascript_topic_title| + @no_javascript_topic_title = no_javascript_topic_title + on(FlowPage).no_javascript_topic_title_text_element.send_keys "#{@no_javascript_topic_title} #{@random_string}" +end + +When(/^I save a no javascript new topic$/) do + on(FlowPage).no_javascript_add_topic_element.click +end + +When(/^I save a no javascript reply$/) do + on(FlowPage).no_javascript_reply_element.click +end + +When(/^I see the form to post a new topic$/) do + on(FlowPage) do |page| + page.no_javascript_start_topic_element.click + end +end + +When(/^I see the form to reply to a topic$/) do + on(FlowPage) do |page| + page.no_javascript_start_reply_element.click + end +end + +Then(/^the page contains my no javascript body$/) do + expect(on(FlowPage).no_javascript_page_content_body).to match "#{@no_javascript_topic_body} #{@random_string}" +end + +Then(/^the page contains my no javascript topic$/) do + expect(on(FlowPage).no_javascript_page_content_title).to match "#{@no_javascript_topic_title} #{@random_string}" +end + +Then(/^the page contains my no javascript reply$/) do + expect(on(FlowPage).no_javascript_page_flow_topics).to match "#{@no_javascript_reply} #{@random_string}" +end diff --git a/Flow/tests/browser/features/step_definitions/flow_steps.rb b/Flow/tests/browser/features/step_definitions/flow_steps.rb new file mode 100644 index 00000000..55509d2e --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/flow_steps.rb @@ -0,0 +1,167 @@ +Given(/^I am on a new board$/) do + visit NewFlowPage +end + +Given(/^I am on Flow page$/) do + visit FlowPage + step "The Flow page is fully loaded" + step "page has no ResourceLoader errors" +end + +# @todo: Rewrite to use more generic step below +Given(/^I have created a Flow topic$/) do + step "I have created a Flow topic with title \"Title of Flow topic\"" +end + +Given(/^I have created a Flow topic with title "(.+)"$/) do |title| + step "I am on Flow page" + step "I type \"" + title + "\" into the new topic title field" + step "I type \"" + title + "\" into the new topic content field" + step "I click New topic save" +end + +Given(/^the author link is visible$/) do + on(FlowPage).author_link_element.when_present.when_present +end + +Given(/^the block author link is not visible$/) do + on(FlowPage).usertools_block_user_link_element.when_not_visible +end + +Given(/^The Flow page is fully loaded$/) do + on(FlowPage).new_topic_body_element.when_not_visible +end + +Given(/^the talk to author link is not visible$/) do + on(FlowPage).usertools_talk_link_element.when_not_visible +end + +When(/^I am viewing Topic page$/) do + on(FlowPage).wait_until { @browser.url =~ /Topic/ } +end + +When(/^I click New topic save$/) do + on(FlowPage) do |page| + page.new_topic_save_element.when_present.click + + # Wait for the save to finish, at which point the button will hide + page.new_topic_save_element.when_not_visible(10); # Bug 71476 - Saving a new topic can take >5s on beta labs + end +end + +When(/^I click the Delete topic button$/) do + on(FlowPage).topic_delete_button_element.when_present.click +end + +When(/^I click the flow creator element$/) do + on(FlowPage).author_link_element.click +end + +When(/^I click the Hide topic button$/) do + on(FlowPage).topic_hide_button_element.when_present.click +end + +When(/^I click the Post Actions link$/) do + on(FlowPage).post_actions_link_element.when_present.click +end + +When(/^I click the Suppress topic button$/) do + on(FlowPage).topic_suppress_button_element.when_present.click +end + +When(/^I click the Topic Actions link$/) do + on(FlowPage).topic_actions_link_element.when_present.click +end + +When(/^I hover over the author link$/) do + on(FlowPage).author_link_element.hover +end + +When(/^I see a flow creator element$/) do + on(FlowPage).author_link_element.should be_visible +end + +When(/^I type "(.+)" into the new topic content field$/) do |flow_body| + body_string = flow_body + @random_string + @automated_test_marker + on(FlowPage).new_topic_body_element.when_present.send_keys(body_string) +end + +When(/^I type "(.+)" into the new topic title field$/) do |flow_title| + @automated_test_marker = " browsertest edit" + on(FlowPage) do |page| + @topic_string = flow_title + @random_string + @automated_test_marker + page.new_topic_title_element.when_present.click + page.new_topic_title_element.when_present.focus + page.new_topic_title_element.when_present.send_keys(@topic_string) + end +end + +Then(/^I am on my user page$/) do + # Get the title of the page without '_' characters + text = 'User:' + ENV["MEDIAWIKI_USER"].gsub(/_/, ' ') + expect(on(UserPage).first_heading_element.text).to match(text) +end + +Then(/^I should see a Delete button$/) do + expect(on(FlowPage).delete_button_element).to be_visible +end + +Then(/^I should see a Delete topic button$/) do + expect(on(FlowPage).topic_delete_button_element.when_present).to be_visible +end + +Then(/^I should see a Hide button$/) do + expect(on(FlowPage).hide_button_element.when_present).to be_visible +end + +Then(/^I should see a Hide topic button$/) do + expect(on(FlowPage).topic_hide_button_element.when_present).to be_visible +end + +Then(/^I should see a Suppress button$/) do + expect(on(FlowPage).suppress_button_element).to be_visible +end + +Then(/^I should see a Suppress topic button$/) do + expect(on(FlowPage).topic_suppress_button_element.when_present).to be_visible +end + +Then(/^the block author link should not be visible$/) do + expect(on(FlowPage).usertools_block_user_link_element).not_to be_visible +end + +Then(/^the block author link should be visible$/) do + expect(on(FlowPage).usertools_block_user_link_element.when_present).to be_visible +end + +Then(/^the content of the top post should be visible$/) do + expect(on(FlowPage).flow_first_topic_body_element.when_present).to be_visible +end + +Then(/^the content of the top post should not be visible$/) do + expect(on(FlowPage).flow_first_topic_body_element).not_to be_visible +end + +Then(/^the Save New Topic button should be disabled$/) do + val = on(FlowPage).new_topic_save_element.attribute("disabled") + expect(val).to eq("true") +end + +Then(/^the talk to author link should be visible$/) do + expect(on(FlowPage).usertools_talk_link_element.when_present).to be_visible +end + +Then(/^the top post should have a heading which contains "(.+)"$/) do |text| + on(FlowPage) do |page| + page.flow_first_topic_heading_element.when_present + expect(page.flow_first_topic_heading).to match(text) + end +end + +Then(/^the top post should have content which contains "(.+)"$/) do |text| + expect(on(FlowPage).flow_first_topic_body).to match(text) +end + +Then(/^the top post should not have a heading which contains "(.+)"$/) do |text| + expect(on(FlowPage).flow_first_topic_heading).not_to match(text) +end diff --git a/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb b/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb new file mode 100644 index 00000000..787170f2 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb @@ -0,0 +1,75 @@ +Given(/^I click the Lock topic button$/) do + on(FlowPage) do |page| + page.topic_lock_button_element.when_present.focus + page.topic_lock_button_element.click + end +end + +Given(/^I click the Unlock topic button$/) do + on(FlowPage) do |page| + page.topic_unlock_button_element.when_present.focus + page.topic_unlock_button_element.click + end +end + +Given(/^the top post has been locked$/) do + step 'I click the Topic Actions link' + step 'I click the Lock topic button' + step 'I type "This is a bikeshed" as the reason' + step 'I submit the lock/unlock topic form' +end + +When(/^I cancel the lock\/unlock topic form$/) do + on(FlowPage).topic_lock_form_cancel_button_element.when_present.click +end + +When(/^I expand the top post$/) do + on(FlowPage).flow_first_topic_heading_element.when_present.click +end + +When(/^I submit the lock\/unlock topic form$/) do + on(FlowPage) do |page| + page.topic_lock_form_lock_button_element.when_present.click + page.topic_lock_form_lock_button_element.when_not_present + end +end + +When(/^I type "(.*?)" as the reason$/) do |reason| + on(FlowPage) do |page| + page.topic_lock_form_reason_element.when_present.clear + # Focus textarea so that any menus that have been clicked lose their focus. In Chrome these might disrupt the test as + # elements may be masked and not clickable. + page.topic_lock_form_reason_element.click + page.topic_lock_form_reason_element.send_keys(reason) + end +end + +Then(/^I should not see the lock\/unlock form$/) do + on(FlowPage) do |page| + page.topic_lock_form_element.when_not_present + expect(page.topic_lock_form_element).not_to be_visible + end +end + +Then(/the original message for the top post should have no edit link$/) do + expect(on(FlowPage).flow_first_topic_original_post_edit_element).not_to be_visible +end + +Then(/^the original message for the top post should have no reply link$/) do + expect(on(FlowPage).flow_first_topic_original_post_reply_element).not_to be_visible +end + +Then(/^the reason of the first topic should be "(.*?)"$/) do |text| + expect(on(FlowPage).flow_reason_element.text).to match text +end + +Then(/^the top post should be a locked discussion$/) do + expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present).to be_visible +end + +Then(/^the top post should be an open discussion$/) do + on(FlowPage) do |page| + page.flow_first_topic_moderation_msg_element.when_not_present + expect(page.flow_first_topic_moderation_msg_element).not_to be_visible + end +end diff --git a/Flow/tests/browser/features/step_definitions/moderation_steps.rb b/Flow/tests/browser/features/step_definitions/moderation_steps.rb new file mode 100644 index 00000000..c6421164 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/moderation_steps.rb @@ -0,0 +1,47 @@ +When(/^I cancel the dialog$/) do + on(FlowPage).dialog_cancel_element.when_present.click +end + +When(/^I click Delete topic$/) do + on(FlowPage).dialog_submit_delete_element.when_present.click +end + +When(/^I click Hide topic$/) do + on(FlowPage).dialog_submit_hide_element.when_present.click +end + +When(/^I click Suppress topic$/) do + on(FlowPage).dialog_submit_suppress_element.when_present.click +end + +When(/^I give reason for deletion as being "(.*?)"$/) do |delete_reason| + on(FlowPage).dialog_input_element.when_present.send_keys(delete_reason) +end + +When(/^I give reason for hiding as being "(.*?)"$/) do |hide_reason| + on(FlowPage).dialog_input_element.when_present.send_keys(hide_reason) +end + +When(/^I give reason for suppression as being "(.*?)"$/) do |suppress_reason| + on(FlowPage).dialog_input_element.when_present.send_keys(suppress_reason) +end + +When(/^I see a dialog box$/) do + on(FlowPage).dialog_element.when_present +end + +Then(/^I confirm$/) do + on(FlowPage).confirm(true) {} +end + +Then(/^I do not see the dialog box$/) do + on(FlowPage).dialog_element.when_not_present +end + +Then(/^the top post should be marked as deleted$/) do + expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present.text).to match("This topic has been deleted") +end + +Then(/^the top post should be marked as suppressed$/) do + expect(on(FlowPage).flow_first_topic_moderation_msg_element.when_present.text).to match("This topic has been suppressed") +end diff --git a/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb b/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb new file mode 100644 index 00000000..b9f607f6 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb @@ -0,0 +1,24 @@ +When(/^I click Hide comment button$/) do + on(FlowPage) do |page| + page.actions_link_hide_3rd_comment_element.when_present.focus + page.actions_link_hide_3rd_comment_element.click + end +end + +When(/^I click the Hide button in the dialog$/) do + on(FlowPage) do |page| + page.dialog_submit_hide_element.click + page.dialog_submit_hide_element.when_not_present + end +end + +Then(/^the 3rd comment should be marked as hidden$/) do + on(FlowPage) do |page| + page.third_reply_element.when_present + expect(page.third_reply_moderation_msg).to match('This comment was hidden') + end +end + +Then(/^the content of the 3rd comment should not be visible$/) do + expect(on(FlowPage).third_reply_content_element).not_to be_visible +end diff --git a/Flow/tests/browser/features/step_definitions/reply_steps.rb b/Flow/tests/browser/features/step_definitions/reply_steps.rb new file mode 100644 index 00000000..857b6876 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/reply_steps.rb @@ -0,0 +1,65 @@ +Given(/^I am not watching my new Flow topic$/) do + on(FlowPage) do |page| + page.first_topic_unwatch_link_element.when_present.click + page.first_topic_unwatch_link_element.when_not_visible + end +end + +When(/^I click the Cancel button and confirm the dialog$/) do + on(FlowPage) do |page| + page.confirm(true) do + page.new_reply_cancel_element.when_present.click + end + end +end + +When(/^I click the Keep editing button$/) do + on(FlowPage) do |page| + page.keep_editing_element.when_present.click + # Keep editing returns to the Preview button. + page.wait_until { page.new_reply_preview_element.visible? } + end +end + +When(/^I click the Preview button$/) do + on(FlowPage) do |page| + page.new_reply_preview_element.when_present.click + page.wait_until { page.preview_warning_element.visible? } + end +end + +When(/^I reply with comment "(.*?)"$/) do |content| + on(FlowPage) do |page| + page.new_reply_save_element.when_not_present + page.new_reply_input_element.when_present.click + page.new_reply_input_element.send_keys(content) + page.new_reply_save_element.when_present.click + page.new_reply_save_element.when_not_present + end +end + +When(/^I start a reply with comment "(.*?)"$/) do |content| + on(FlowPage) do |page| + page.new_reply_save_element.when_not_present + page.new_reply_input_element.when_present.click + page.new_reply_input_element.send_keys(content) + end +end + +Then(/^I should see an unwatch link on the topic$/) do + expect(on(FlowPage).first_topic_unwatch_link_element).to be_visible +end + +Then(/^the top post's first reply should contain the text "(.+)"$/) do |text| + on(FlowPage) do |page| + page.new_reply_save_element.when_not_present + expect(page.first_reply_body).to match(text) + end +end + +Then(/^I should see the topic reply form$/) do + on(FlowPage) do |page| + page.wait_until { page.new_reply_input_element.visible? } + expect(page.new_reply_input_element).to be_visible + end +end diff --git a/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb b/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb new file mode 100644 index 00000000..eb8b18bf --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb @@ -0,0 +1,34 @@ +When(/^I click Newest topics choice$/) do + on(FlowPage).newest_topics_choice_element.when_present.click +end + +When(/^I click Newest topics link$/) do + on(FlowPage).newest_topics_link_element.when_present.click +end + +When(/^I click Recently active topics choice$/) do + on(FlowPage).recently_active_topics_choice_element.when_present.click +end + +When(/^I click Recently active topics link$/) do + on(FlowPage) do |page| + page.recently_active_topics_choice_element.when_not_visible + page.recently_active_topics_link_element.when_present.click + end +end + +Then(/^the Flow page should not show Recently active topics link$/) do + expect(on(FlowPage).recently_active_topics_link_element.when_not_visible).not_to be_visible +end + +Then(/^the Flow page should show Recently active topics link$/) do + expect(on(FlowPage).recently_active_topics_link_element.when_present).to be_visible +end + +Then(/^the Flow page should not show Newest topics link$/) do + expect(on(FlowPage).newest_topics_link_element.when_not_visible).not_to be_visible +end + +Then(/^the Flow page should show Newest topics link$/) do + expect(on(FlowPage).newest_topics_link_element.when_present).to be_visible +end diff --git a/Flow/tests/browser/features/step_definitions/thank_steps.rb b/Flow/tests/browser/features/step_definitions/thank_steps.rb new file mode 100644 index 00000000..d448258e --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/thank_steps.rb @@ -0,0 +1,37 @@ +Given(/^the "(.*?)" page has a new unmoderated topic created by me$/) do |title| + client = on(APIPage).client + client.log_in(ENV["MEDIAWIKI_USER"], ENV["MEDIAWIKI_PASSWORD"]) + client.action('flow', token_type: 'edit', submodule: 'new-topic', page: title, nttopic: 'Thank me please!', ntcontent: 'Hello') +end + +Given(/^the most recent topic on "(.*?)" is written by another user$/) do |title| + client = on(APIPage).client + username = 'Selenium Flow user 2' + begin + client.create_account(username, ENV["MEDIAWIKI_PASSWORD"]) + rescue MediawikiApi::ApiError + puts 'Assuming user ' + username + ' already exists since was unable to create.' + end + + client.log_in(username, ENV["MEDIAWIKI_PASSWORD"]) + client.action('flow', token_type: 'edit', submodule: 'new-topic', page: title, nttopic: 'Thank me please!', ntcontent: 'Hello') +end + +When(/^I click on the Thank button$/) do + on(FlowPage).thank_button_element.click +end + +When(/^I see a Thank button$/) do + on(FlowPage).thank_button_element.when_present +end + +Then(/^I should not see a Thank button$/) do + expect(on(FlowPage).thank_button_element).not_to be_visible +end + +Then(/^I should see the Thank button be replaced with Thanked button$/) do + on(FlowPage) do |page| + expect(page.thanked_button_element.when_present).to be_visible + expect(page.thank_button_element).not_to be_visible + end +end diff --git a/Flow/tests/browser/features/step_definitions/watch_steps.rb b/Flow/tests/browser/features/step_definitions/watch_steps.rb new file mode 100644 index 00000000..57ca38a4 --- /dev/null +++ b/Flow/tests/browser/features/step_definitions/watch_steps.rb @@ -0,0 +1,64 @@ +Given(/^I am not watching the Flow board$/) do + on(FlowPage) do |page| + page.board_unwatch_link_element.when_present.click unless page.board_watch_link_element.visible? + end +end + +Given(/^I am not watching the Flow topic$/) do + on(FlowPage).first_topic_unwatch_link_element.when_present.click +end + +Given(/^I am watching the Flow topic$/) do + on(FlowPage).first_topic_unwatch_link_element.when_present +end + +Given(/^I am watching the Flow board$/) do + on(FlowPage) do |page| + page.board_watch_link_element.when_present.click unless page.board_unwatch_link_element.visible? + end +end + +When(/^I click the Unwatch Board link$/) do + on(FlowPage).board_unwatch_link_element.when_present.click +end + +When(/^I click the Unwatch Topic link$/) do + on(FlowPage).first_topic_unwatch_link_element.when_present.click +end + +When(/^I click the Watch Board link$/) do + on(FlowPage).board_watch_link_element.when_present.click +end + +When(/^I click the Watch Topic link$/) do + on(FlowPage).first_topic_watch_link_element.when_present.click +end + +Then(/^I should see the Unwatch Topic link$/) do + expect(on(FlowPage).first_topic_unwatch_link_element.when_present).to be_visible +end + +Then(/^I should not see any watch links$/) do + on(FlowPage) do |page| + expect(page.board_watch_link_element).not_to be_visible + expect(page.first_topic_watch_link_element).not_to be_visible + end +end + +Then(/^I should see the Unwatch Board link$/) do + on(FlowPage) do |page| + page.board_watch_link_element.when_not_visible + expect(page.board_unwatch_link_element).to be_visible + end +end + +Then(/^I should see the Watch Board link$/) do + expect(on(FlowPage).board_watch_link_element.when_present).to be_visible +end + +Then(/^I should see the Watch Topic link$/) do + on(FlowPage) do |page| + page.first_topic_unwatch_link_element.when_not_visible + expect(page.first_topic_watch_link_element).to be_visible + end +end diff --git a/Flow/tests/browser/features/support/env.rb b/Flow/tests/browser/features/support/env.rb new file mode 100644 index 00000000..55b30cef --- /dev/null +++ b/Flow/tests/browser/features/support/env.rb @@ -0,0 +1,10 @@ +require "mediawiki_api" +require "mediawiki_selenium" + +if ENV['PAGE_WAIT_TIMEOUT'] + PageObject.default_page_wait = ENV['PAGE_WAIT_TIMEOUT'].to_i +end + +if ENV['ELEMENT_WAIT_TIMEOUT'] + PageObject.default_element_wait = ENV['ELEMENT_WAIT_TIMEOUT'].to_i +end diff --git a/Flow/tests/browser/features/support/hooks.rb b/Flow/tests/browser/features/support/hooks.rb new file mode 100644 index 00000000..5ab6259d --- /dev/null +++ b/Flow/tests/browser/features/support/hooks.rb @@ -0,0 +1,3 @@ +# Allow running of bundle exec cucumber --dry-run -f stepdefs +require "mediawiki_selenium" +require 'page-object' diff --git a/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb b/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb new file mode 100644 index 00000000..d550a27b --- /dev/null +++ b/Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb @@ -0,0 +1,7 @@ +class FlowOldPermalinkPage + include PageObject + include URL + + @params = { page: 'Talk:Flow QA', workflow_id: 'no workflow' } + page_url URL.url(params[:page]) + "?workflow=<%=params[:workflow_id]%>" +end diff --git a/Flow/tests/browser/features/support/pages/flow_page.rb b/Flow/tests/browser/features/support/pages/flow_page.rb new file mode 100644 index 00000000..4a052247 --- /dev/null +++ b/Flow/tests/browser/features/support/pages/flow_page.rb @@ -0,0 +1,239 @@ +class WikiPage + include PageObject + a(:logout, css: "#pt-logout a") +end + +class FlowPage < WikiPage + include URL + # MEDIAWIKI_URL must have this in $wgFlowOccupyPages array or $wgFlowOccupyNamespaces. + page_url URL.url("Talk:Flow_QA") + + # board header + a(:edit_header_link, title: "Edit header") + div(:header_content, css: ".flow-board-header-detail-view p", index: 0) + form(:edit_header_form, css: ".flow-board-header-edit-view form") + textarea(:edit_header_textbox, css: ".flow-board-header-edit-view textarea") + + a(:author_link, css: ".flow-author a", index: 0) + a(:cancel_button, text: "Cancel") + + # XXX (mattflaschen, 2014-06-24): This is broken; there is no + # flow-topic-reply-form anywhere in Flow outside this file. + # Also, this should be named to distinguish between top-level posts and regular replies. + textarea(:comment_field, css: 'form.flow-topic-reply-form > textarea[name="topic_content"]') + button(:comment_reply_save, css: "form.flow-topic-reply-form .flow-reply-submit") + div(:flow_topics, class: "flow-topics") + + # Dialogs + div(:dialog, css: ".flow-ui-modal") + textarea(:dialog_input, name: "topic_reason") + button(:dialog_cancel, css: "a.mw-ui-destructive:nth-child(2)") + button(:dialog_submit_delete, text: "Delete") + button(:dialog_submit_hide, text: "Hide") + button(:dialog_submit_suppress, text: "Suppress") + + # Posts + ## Highlighted post + div(:highlighted_post, css: ".flow-post-highlighted") + + ## First topic + div(:flow_first_topic, css: ".flow-topic", index: 0) + h2(:flow_first_topic_heading, css: ".flow-topic h2", index: 0) + # todo this is poor naming, it's really the first_topic_first_post_content + div(:flow_first_topic_body, css: ".flow-topic .flow-post-content", index: 0) + div(:flow_first_topic_moderation_msg) do |page| + page.flow_first_topic_element.div_element(css: "div.flow-topic-titlebar div.flow-moderated-topic-title") + end + + div(:flow_first_topic_summary) do |page| + page.flow_first_topic_element.div_element(css: ".flow-topic-summary") + end + div(:flow_first_topic_original_post, css: ".flow-post", index: 0) + a(:flow_first_topic_original_post_edit) do |page| + page.flow_first_topic_original_post_element.link_element(text: "Edit") + end + a(:flow_first_topic_original_post_reply) do |page| + page.flow_first_topic_original_post_element.link_element(text: "Reply") + end + div(:flow_second_topic_heading, css: ".flow-topic", index: 1) + + ### Hover over username behaviour + span(:usertools, css: '.mw-usertoollinks') + a(:usertools_talk_link) do |page| + page.usertools_element.link_element(text: 'Talk') + end + a(:usertools_block_user_link) do |page| + page.usertools_element.link_element(text: 'block') + end + + ### First Topic actions menu + + # For topic collapsing testing + # Works around CSS descendant selector problem (https://github.com/cheezy/page-object/issues/222) + div(:first_moderated_topic, css: '.flow-topic.flow-topic-moderated', index: 0) + + div(:first_moderated_topic_titlebar) do |page| + page.first_moderated_topic_element.div_element(css: '.flow-topic-titlebar') + end + + div(:first_moderated_message) do |page| + page.first_moderated_topic_titlebar_element.div_element(css: '.flow-moderated-topic-title') + end + + h2(:first_moderated_topic_title) do |page| + page.first_moderated_topic_titlebar_element.h2_element(class: 'flow-topic-title') + end + + div(:first_moderated_topic_post_content) do |page| + page.first_moderated_topic_element.div_element(class: 'flow-post', index: 0).div_element(class: 'flow-post-main').div_element(class: 'flow-post-content') + end + + # Topic actions menu (all belonging to the first post) + a(:topic_actions_link, css: ".flow-topic .flow-topic-titlebar .flow-menu-js-drop a", index: 0) + ul(:topic_actions_menu, css: ".flow-topic .flow-topic-titlebar .flow-menu ul", index: 0) + a(:topic_hide_button) do |page| + page.topic_actions_menu_element.link_element(text: "Hide topic") + end + a(:topic_delete_button) do |page| + page.topic_actions_menu_element.link_element(text: "Delete topic") + end + a(:topic_suppress_button) do |page| + page.topic_actions_menu_element.link_element(text: "Suppress topic") + end + a(:permalink_button) do |page| + page.topic_actions_menu_element.link_element(text: "Permalink") + end + a(:edit_title_button) do |page| + page.topic_actions_menu_element.link_element(text: "Edit title") + end + a(:topic_lock_button) do |page| + page.topic_actions_menu_element.link_element(title: "Lock topic") + end + a(:topic_unlock_button) do |page| + page.topic_actions_menu_element.link_element(title: "Unlock topic") + end + + ## Lock topic workflow + form(:topic_lock_form, css: ".flow-edit-form") + textarea(:topic_lock_form_reason, css: ".flow-edit-form textarea") + button(:topic_lock_form_lock_button, css: ".flow-edit-form .mw-ui-constructive") + button(:topic_lock_form_cancel_button, css: ".flow-edit-form .mw-ui-destructive") + div(:flow_reason, class: "flow-moderated-topic-reason") + + ### Editing title of first topic + text_field(:title_edit, css: ".flow-topic-titlebar form .mw-ui-input", index: 0) + button(:change_title_save, css: ".flow-topic-titlebar form .mw-ui-constructive") + + ### Post meta actions + span(:post_meta_actions, css: ".flow-post .flow-post-meta-actions", index: 0) + a(:edit_post) do |page| + page.post_meta_actions_element.link_element(title: "Edit") + end + a(:thank_button) do |page| + page.post_meta_actions_element.link_element(css: ".mw-thanks-flow-thank-link", index: 0) + end + span(:thanked_button) do |page| + page.post_meta_actions_element.span_element(css: ".mw-thanks-flow-thanked", index: 0) + end + + ### First post of first topic actions menu + a(:post_actions_link, css: ".flow-topic .flow-post .flow-menu-js-drop a", index: 0) + ul(:post_actions_menu, css: ".flow-topic .flow-post .flow-menu ul", index: 0) + a(:hide_button) do |page| + page.post_actions_menu_element.link_element(title: "Hide") + end + a(:delete_button) do |page| + page.post_actions_menu_element.link_element(title: "Delete") + end + a(:suppress_button) do |page| + page.post_actions_menu_element.link_element(title: "Suppress") + end + + ### Replies to top post + #### 1st reply + # @todo: This is broken. It should be clearly possible to distinguish between the top reply and + # the top post. There is an element .flow-replies which appears to be empty. + div(:first_reply, css: '.flow-post', index: 1) + div(:first_reply_body) do |page| + page.first_reply_element.div_element(css: '.flow-post-content') + end + + #### 3rd reply + # @todo: Should be index: 2, but sadly no way to distinguish replies from original post + div(:third_reply, css: '.flow-post', index: 3) + div(:third_reply_moderation_msg) do |page| + page.third_reply_element.span_element(css: '.flow-moderated-post-content', index: 0) + end + div(:third_reply_content) do |page| + page.third_reply_element.div_element(css: '.flow-post-content', index: 0) + end + + a(:third_post_actions_link, css: ".flow-topic .flow-post .flow-menu-js-drop a", index: 3) + ul(:third_post_actions_menu, css: ".flow-topic .flow-post .flow-menu ul", index: 3) + a(:actions_link_permalink_3rd_comment) do |page| + page.third_post_actions_menu_element.link_element(text: "Permalink") + end + a(:actions_link_hide_3rd_comment) do |page| + page.third_post_actions_menu_element.link_element(text: "Hide") + end + + # New topic creation + form(:new_topic_form, css: ".flow-newtopic-form") + text_field(:new_topic_title, name: "topiclist_topic") + textarea(:new_topic_body, name: "topiclist_content") + button(:new_topic_cancel, css: ".flow-newtopic-form .mw-ui-destructive") + button(:new_topic_preview, css: ".flow-newtopic-form .mw-ui-progressive") + # FIXME: Remove flow-ui-constructive reference when cache has cleared + button(:new_topic_save, css: ".flow-newtopic-form .mw-ui-constructive, .flow-newtopic-form .flow-ui-constructive") + + # Replying + # TODO (mattflaschen, 2014-06-24): Should distinguish between + # top-level replies to the topic, and replies to regular posts + form(:new_reply_form, css: ".flow-reply-form") + # Is an input when not focused, textarea when focused + textarea(:new_reply_input, css: ".flow-reply-form .mw-ui-input") + button(:new_reply_cancel, css: ".flow-reply-form .mw-ui-destructive") + button(:new_reply_preview, css: ".flow-reply-form .mw-ui-progressive") + button(:new_reply_save, css: ".flow-reply-form .mw-ui-constructive") + button(:keep_editing, text: "Keep editing") + div(:preview_warning, css: ".flow-preview-warning") + + # Editing post workflow + text_area(:post_edit, css: ".flow-edit-post-form textarea") + button(:change_post_save, css: ".flow-edit-post-form .mw-ui-constructive") + + button(:preview_button, class: "mw-ui-button flow-preview-submit") + div(:small_spinner, class: "mw-spinner mw-spinner-small mw-spinner-inline") + + button(:edit_header_save, text: "Save header") + + # No javascript elements + button(:no_javascript_add_topic, text: "Add topic") + div(:no_javascript_page_content_body, class: "flow-post-content") + div(:no_javascript_page_content_title, class: "flow-topic-titlebar") + div(:no_javascript_page_flow_topics, class: "flow-topics") + button(:no_javascript_reply, text: "Reply") + textarea(:no_javascript_reply_form, name: "topic_content") + a(:no_javascript_start_reply, href: /action=reply/) + a(:no_javascript_start_topic, href: /action=new-topic/) + textarea(:no_javascript_topic_body_text, name: "topiclist_content") + text_field(:no_javascript_topic_title_text, name: "topiclist_topic") + + # Sorting + a(:newest_topics_link, text: "Newest topics") + a(:recently_active_topics_choice, href: /topiclist_sortby=updated/) + a(:recently_active_topics_link, text: "Recently active topics") + a(:newest_topics_choice, href: /topiclist_sortby=newest/) + + ## Watch and unwatch links + div(:first_topic_watchlist_container, css: ".flow-topic-watchlist", index: 0) + a(:first_topic_watch_link) do |page| + page.first_topic_watchlist_container_element.link_element(css: ".flow-watch-link-watch") + end + a(:first_topic_unwatch_link) do |page| + page.first_topic_watchlist_container_element.link_element(css: ".flow-watch-link-unwatch") + end + + a(:board_unwatch_link, href: /Flow_QA&action=unwatch/) + a(:board_watch_link, href: /Flow_QA&action=watch/) +end diff --git a/Flow/tests/browser/features/support/pages/new_flow_page.rb b/Flow/tests/browser/features/support/pages/new_flow_page.rb new file mode 100644 index 00000000..16ed9c6e --- /dev/null +++ b/Flow/tests/browser/features/support/pages/new_flow_page.rb @@ -0,0 +1,7 @@ +require "page-object" + +class NewFlowPage < FlowPage + include URL + # MEDIAWIKI_URL must have User_talk in $wgFlowOccupyNamespaces. + page_url URL.url("User_talk:New page " + Array.new(8) { [*'0'..'9', *'a'..'z', *'A'..'Z'].sample }.join) +end diff --git a/Flow/tests/browser/features/support/pages/recent_changes_page.rb b/Flow/tests/browser/features/support/pages/recent_changes_page.rb new file mode 100644 index 00000000..5941295b --- /dev/null +++ b/Flow/tests/browser/features/support/pages/recent_changes_page.rb @@ -0,0 +1,8 @@ +class RecentChangesPage + include PageObject + + include URL + page_url URL.url('Special:RecentChanges') + + div(:recent_changes, class: 'mw-changeslist') +end diff --git a/Flow/tests/browser/features/support/pages/user_page.rb b/Flow/tests/browser/features/support/pages/user_page.rb new file mode 100644 index 00000000..baf8b769 --- /dev/null +++ b/Flow/tests/browser/features/support/pages/user_page.rb @@ -0,0 +1,9 @@ +class UserPage + include PageObject + + include URL + # MEDIAWIKI_URL must have this in $wgFlowOccupyPages array or $wgFlowOccupyNamespaces. + page_url URL.url("User talk:ENV['MEDIAWIKI_USER']") + + h1(:first_heading, id: "firstHeading") +end diff --git a/Flow/tests/browser/features/thank.feature b/Flow/tests/browser/features/thank.feature new file mode 100644 index 00000000..a3479223 --- /dev/null +++ b/Flow/tests/browser/features/thank.feature @@ -0,0 +1,23 @@ +@chrome @clean @en.wikipedia.beta.wmflabs.org @firefox @internet_explorer_10 @login @test2.wikipedia.org +Feature: Thank author of a Flow post + + Scenario: Anon does not see Thank button + Given the "Talk:Flow QA" page has a new unmoderated topic created by me + And I am on Flow page + Then I should not see a Thank button + + @login + Scenario: Thank the user + Given I am logged in + And the most recent topic on "Talk:Flow QA" is written by another user + And I am on Flow page + And I see a Thank button + When I click on the Thank button + Then I should see the Thank button be replaced with Thanked button + + @login + Scenario: I cannot thank my own post + Given I am logged in + And the "Talk:Flow QA" page has a new unmoderated topic created by me + And I am on Flow page + Then I should not see a Thank button diff --git a/Flow/tests/browser/features/watch.feature b/Flow/tests/browser/features/watch.feature new file mode 100644 index 00000000..b7460834 --- /dev/null +++ b/Flow/tests/browser/features/watch.feature @@ -0,0 +1,36 @@ +@test2.wikipedia.org @en.wikipedia.beta.wmflabs.org @phantomjs +Feature: Watching/Unwatching Boards and Topics + + Scenario: Watch topic + Given I am logged in + And I am on Flow page + And I have created a Flow topic + And I am not watching the Flow topic + When I click the Watch Topic link + Then I should see the Unwatch Topic link + + Scenario: Unwatch topic + Given I am logged in + And I am on Flow page + And I have created a Flow topic + And I am watching the Flow topic + When I click the Unwatch Topic link + Then I should see the Watch Topic link + + Scenario: Watch board + Given I am logged in + And I am on Flow page + And I am not watching the Flow board + When I click the Watch Board link + Then I should see the Unwatch Board link + + Scenario: Unwatch board + Given I am logged in + And I am on Flow page + And I am watching the Flow board + When I click the Unwatch Board link + Then I should see the Watch Board link + + Scenario: No watch links for anonymous users + When I am on Flow page + Then I should not see any watch links diff --git a/Flow/tests/externals/phantomjs-qunit-runner.js b/Flow/tests/externals/phantomjs-qunit-runner.js new file mode 100644 index 00000000..4b1d38b1 --- /dev/null +++ b/Flow/tests/externals/phantomjs-qunit-runner.js @@ -0,0 +1,127 @@ +/* + * QtWebKit-powered headless test runner using PhantomJS + * + * PhantomJS binaries: http://phantomjs.org/download.html + * Requires PhantomJS 1.6+ (1.7+ recommended) + * + * Run with: + * phantomjs runner.js [url-of-your-qunit-testsuite] + * + * e.g. + * phantomjs runner.js http://localhost/qunit/test/index.html + */ + +/*jshint latedef:false */ +/*global phantom:false, require:false, console:false, window:false, QUnit:false */ + +(function() { + 'use strict'; + + var args = require('system').args; + + // arg[0]: scriptName, args[1...]: arguments + if (args.length !== 2) { + console.error('Usage:\n phantomjs runner.js [url-of-your-qunit-testsuite]'); + phantom.exit(1); + } + + var url = args[1], + page = require('webpage').create(); + + // Route `console.log()` calls from within the Page context to the main Phantom context (i.e. current `this`) + page.onConsoleMessage = function(msg) { + console.log(msg); + }; + + page.onInitialized = function() { + page.evaluate(addLogging); + }; + + page.onCallback = function(message) { + var result, + failed; + + if (message) { + if (message.name === 'QUnit.done') { + result = message.data; + failed = !result || result.failed; + + phantom.exit(failed ? 1 : 0); + } + } + }; + + page.open(url, function(status) { + if (status !== 'success') { + console.error('Unable to access network: ' + status); + phantom.exit(1); + } else { + // Cannot do this verification with the 'DOMContentLoaded' handler because it + // will be too late to attach it if a page does not have any script tags. + var qunitMissing = page.evaluate(function() { return (typeof QUnit === 'undefined' || !QUnit); }); + if (qunitMissing) { + console.error('The `QUnit` object is not present on this page.'); + phantom.exit(1); + } + + // Do nothing... the callback mechanism will handle everything! + } + }); + + function addLogging() { + window.document.addEventListener('DOMContentLoaded', function() { + var current_test_assertions = []; + + QUnit.log(function(details) { + var response; + + // Ignore passing assertions + if (details.result) { + return; + } + + response = details.message || ''; + + if (typeof details.expected !== 'undefined') { + if (response) { + response += ', '; + } + + response += 'expected: ' + details.expected + ', but was: ' + details.actual; + if (details.source) { + response += "\n" + details.source; + } + } + + current_test_assertions.push('Failed assertion: ' + response); + }); + + QUnit.testDone(function(result) { + var i, + len, + name = result.module + ': ' + result.name; + + if (result.failed) { + console.log('Test failed: ' + name); + + for (i = 0, len = current_test_assertions.length; i < len; i++) { + console.log(' ' + current_test_assertions[i]); + } + } + + current_test_assertions.length = 0; + }); + + QUnit.done(function(result) { + console.log('Took ' + result.runtime + 'ms to run ' + result.total + ' tests. ' + result.passed + ' passed, ' + result.failed + ' failed.'); + + if (typeof window.callPhantom === 'function') { + window.callPhantom({ + 'name': 'QUnit.done', + 'data': result + }); + } + }); + }, false); + } +})(); diff --git a/Flow/tests/phpunit/Block/TopicListTest.php b/Flow/tests/phpunit/Block/TopicListTest.php new file mode 100644 index 00000000..887f58f7 --- /dev/null +++ b/Flow/tests/phpunit/Block/TopicListTest.php @@ -0,0 +1,58 @@ +<?php + +namespace Flow\Tests\Block; + +use Flow\Block\TopicListBlock; +use Flow\Container; +use Flow\Model\Workflow; +use Title; +use User; + +class TopicListTest extends \MediaWikiTestCase { + + public function testSortByOption() { + $user = User::newFromId( 1 ); + $user->setOption( 'flow-topiclist-sortby', '' ); + + $ctx = $this->getMock( 'IContextSource' ); + $ctx->expects( $this->any() ) + ->method( 'getUser' ) + ->will( $this->returnValue( $user ) ); + + $workflow = Workflow::create( 'discussion', Title::newFromText( 'Talk:Flow_QA' ) ); + $block = new TopicListBlock( $workflow, Container::get( 'storage' ) ); + $block->init( $ctx, 'view' ); + + $res = $block->renderApi( array( + ) ); + $this->assertEquals( 'newest', $res['sortby'], 'With no sortby defaults to newest' ); + + $res = $block->renderApi( array( + 'sortby' => 'foo', + ) ); + $this->assertEquals( 'newest', $res['sortby'], 'With invalid sortby defaults to newest' ); + + $res = $block->renderApi( array( + 'sortby' => 'updated', + ) ); + $this->assertEquals( 'updated', $res['sortby'], 'With sortby updated output changes to updated' ); + $res = $block->renderApi( array( + ) ); + $this->assertEquals( 'newest', $res['sortby'], 'Sort still defaults to newest' ); + + $res = $block->renderApi( array( + 'sortby' => 'updated', + 'savesortby' => '1', + ) ); + $this->assertEquals( 'updated', $res['sortby'], 'Request saving sortby option' ); + + $res = $block->renderApi( array( + ) ); + $this->assertEquals( 'updated', $res['sortby'], 'Default sortby now changed to updated' ); + + $res = $block->renderApi( array( + 'sortby' => '', + ) ); + $this->assertEquals( 'updated', $res['sortby'], 'Default sortby with blank sortby still uses user default' ); + } +} diff --git a/Flow/tests/phpunit/BlockFactoryTest.php b/Flow/tests/phpunit/BlockFactoryTest.php new file mode 100644 index 00000000..3413da99 --- /dev/null +++ b/Flow/tests/phpunit/BlockFactoryTest.php @@ -0,0 +1,69 @@ +<?php + +namespace Flow\Tests; + +use Flow\BlockFactory; +use Flow\NotificationController; + +/** + * @group Flow + */ +class BlockFactoryTest extends FlowTestCase { + + public function provideDataCreateBlocks() { + return array ( + array( 'discussion', array( 'Flow\Block\HeaderBlock', 'Flow\Block\TopicListBlock', 'Flow\Block\BoardHistoryBlock' ) ), + array( 'topic', array( 'Flow\Block\TopicBlock', 'Flow\Block\TopicSummaryBlock' ) ), + ); + } + + /** + * @dataProvider provideDataCreateBlocks + */ + public function testCreateBlocks( $workflowType, $expectedResults ) { + $factory = $this->createBlockFactory(); + $workflow = $this->mockWorkflow( $workflowType ); + + $blocks = $factory->createBlocks( $workflow ); + $this->assertEquals( count( $blocks ), count( $expectedResults ) ); + + $results = array(); + foreach ( $blocks as $obj ) { + $results[] = get_class( $obj ); + } + $this->assertEquals( $results, $expectedResults ); + } + + /** + * @expectedException \Flow\Exception\InvalidInputException + */ + public function testCreateBlocksWithInvalidInputException() { + $factory = $this->createBlockFactory(); + $workflow = $this->mockWorkflow( 'a-bad-database-flow-workflow' ); + // Trigger InvalidInputException + $factory->createBlocks( $workflow ); + } + + protected function createBlockFactory() { + $storage = $this->getMockBuilder( '\Flow\Data\ManagerGroup' ) + ->disableOriginalConstructor() + ->getMock(); + + $rootPostLoader = $this->getMockBuilder( '\Flow\Repository\RootPostLoader' ) + ->disableOriginalConstructor() + ->getMock(); + + return new BlockFactory( $storage, $rootPostLoader ); + } + + protected function mockWorkflow( $type ) { + $workflow = $this->getMockBuilder( '\Flow\Model\Workflow' ) + ->disableOriginalConstructor() + ->getMock(); + $workflow->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $type ) ); + + return $workflow; + } +} diff --git a/Flow/tests/phpunit/Collection/PostCollectionTest.php b/Flow/tests/phpunit/Collection/PostCollectionTest.php new file mode 100644 index 00000000..629f48a9 --- /dev/null +++ b/Flow/tests/phpunit/Collection/PostCollectionTest.php @@ -0,0 +1,125 @@ +<?php + +namespace Flow\Tests\Collection; + +use Flow\Collection\PostCollection; +use Flow\Tests\PostRevisionTestCase; + +/** + * @group Flow + * @group Database + */ +class PostCollectionTest extends PostRevisionTestCase { + /** + * @var array + */ + protected $tablesUsed = array( 'flow_revision', 'flow_tree_revision' ); + + protected function setUp() { + parent::setUp(); + + // recent changes isn't fully setup here, just skip it + $this->clearExtraLifecycleHandlers(); + + // generate a post with multiple revisions + $revision = $this->generateObject( array( + 'rev_content' => 'first revision', + ) ); + $this->store( $revision ); + + $revision = $this->generateObject( array( + 'rev_content' => 'second revision', + 'rev_change_type' => 'edit-post', + 'rev_parent_id' => $revision->getRevisionId()->getBinary(), + 'tree_rev_descendant_id' => $revision->getPostId()->getBinary(), + 'rev_type_id' => $revision->getPostId()->getBinary(), + ) ); + $this->store( $revision ); + + $revision = $this->generateObject( array( + 'rev_content' => 'third revision', + 'rev_change_type' => 'edit-post', + 'rev_parent_id' => $revision->getRevisionId()->getBinary(), + 'tree_rev_descendant_id' => $revision->getPostId()->getBinary(), + 'rev_type_id' => $revision->getPostId()->getBinary(), + ) ); + $this->store( $revision ); + } + + public function testGetCollection() { + $revision = $this->revisions[0]; + $collection = $revision->getCollection(); + $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection ); + } + + public function testNewFromId() { + $uuidPost = $this->revisions[0]->getPostId(); + $collection = PostCollection::newFromId( $uuidPost ); + $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection ); + } + + public function testNewFromRevision() { + $revision = $this->revisions[0]; + $collection = PostCollection::newFromRevision( $revision ); + $this->assertInstanceOf( 'Flow\Collection\PostCollection', $collection ); + } + + public function testGetRevision() { + $collection = $this->revisions[0]->getCollection(); + + $expected = $this->revisions[1]; + $revision = $collection->getRevision( $expected->getRevisionId() ); + $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision ); + $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) ); + } + + public function testGetLastRevision() { + $collection = $this->revisions[0]->getCollection(); + + $expected = end( $this->revisions ); + $revision = $collection->getLastRevision(); + + $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision ); + $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) ); + } + + public function testGetFirstRevision() { + $collection = $this->revisions[1]->getCollection(); + + $expected = reset( $this->revisions ); + $revision = $collection->getFirstRevision(); + + $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision ); + $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) ); + } + + public function testGetNextRevision() { + $start = $this->revisions[0]; + $collection = $start->getCollection(); + + $expected = $this->revisions[1]; + $revision = $collection->getNextRevision( $start ); + + $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision ); + $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) ); + } + + public function testGetPrevRevision() { + $start = $this->revisions[1]; + $collection = $start->getCollection(); + + $expected = $this->revisions[0]; + $revision = $collection->getPrevRevision( $start ); + + $this->assertInstanceOf( 'Flow\Model\PostRevision', $revision ); + $this->assertTrue( $expected->getRevisionId()->equals( $revision->getRevisionId() ) ); + } + + public function testGetAllRevision() { + $collection = $this->revisions[1]->getCollection(); + + $revisions = $collection->getAllRevisions(); + + $this->assertEquals( count( $this->revisions ), count( $revisions ) ); + } +} diff --git a/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php b/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php new file mode 100644 index 00000000..e95fa589 --- /dev/null +++ b/Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php @@ -0,0 +1,290 @@ +<?php + +namespace Flow\Tests\Collection; + +use Flow\Container; +use Flow\FlowActions; +use Flow\Model\PostRevision; +use Flow\Model\AbstractRevision; +use Flow\RevisionActionPermissions; +use Flow\Tests\PostRevisionTestCase; +use Block; +use User; + +/** + * @group Database + * @group Flow + */ +class RevisionCollectionPermissionsTest extends PostRevisionTestCase { + /** + * @var array + */ + protected $tablesUsed = array( 'flow_revision', 'flow_tree_revision' ); + + /** + * @var FlowActions + */ + protected $actions; + + /** + * Map of action name to moderation status, as helper for + * $this->generateRevision() + * + * @var array + */ + protected $moderation = array( + 'restore-post' => AbstractRevision::MODERATED_NONE, + 'hide-post' => AbstractRevision::MODERATED_HIDDEN, + 'delete-post' => AbstractRevision::MODERATED_DELETED, + 'suppress-post' => AbstractRevision::MODERATED_SUPPRESSED, + ); + + /** + * @var User + */ + protected + $blockedUser, + $anonUser, + $unconfirmedUser, + $confirmedUser, + $sysopUser, + $oversightUser; + + /** + * @var Block + */ + protected $block; + + protected function setUp() { + parent::setUp(); + + $this->clearExtraLifecycleHandlers(); + + // We don't want local config getting in the way of testing whether or + // not our permissions implementation works well. + // This will load default $wgGroupPermissions + Flow settings, so we can + // test if permissions work well, regardless of any custom config. + global $IP, $wgFlowGroupPermissions; + $wgGroupPermissions = array(); + require "$IP/includes/DefaultSettings.php"; + $wgGroupPermissions = array_merge_recursive( $wgGroupPermissions, $wgFlowGroupPermissions ); + $this->setMwGlobals( 'wgGroupPermissions', $wgGroupPermissions ); + + // When external store is used, data is written to "blobs" table, which + // by default doesn't exist - let's just not use externalstorage in test + $this->setMwGlobals( 'wgFlowExternalStore', false ); + + // load actions object + $this->actions = Container::get( 'flow_actions' ); + + // block a user + $blockedUser = $this->blockedUser(); + $this->block = new Block( $blockedUser->getName(), $blockedUser->getID() ); + $this->block->insert(); + // ensure that block made it into the database + wfGetDB( DB_MASTER )->commit( __METHOD__, 'flush' ); + } + + /** + * Provides User, permissions test action, and revision actions (with + * expected permission results for test action). + * + * Basically: a new post is created and the actions in $actions are + * performed. After that, we'll check if $action is allowed on all of those + * revisions, with the expected true/false value from $actions as result. + * + * @return array + */ + public function permissionsProvider() { + return array( + // irregardless of current status, if a user has no permissions for + // a specific revision, he can't see it + array( $this->confirmedUser(), 'view', array( + // Key is the moderation action; value is the 'view' permission + // for that corresponding revision after all moderation is done. + // In this case, a post will be created with 3 revisions: + // [1] create post, [2] suppress, [3] restore + // After creating all revisions, all of these will be tested for + // 'view' permissions against that specific revision. Here: + // [1] should be visible (this + last rev not suppressed) + // [2] should not (was suppressed) + // [3] should be visible again (undid suppression) + array( 'new-post' => true ), + array( 'suppress-post' => false ), + array( 'restore-post' => true ), + ) ), + array( $this->oversightUser(), 'view', array( + array( 'new-post' => true ), + array( 'suppress-post' => true ), + array( 'restore-post' => true ), + ) ), + + // last moderation status should always bubble down to previous revs + array( $this->confirmedUser(), 'view', array( + array( 'new-post' => false ), + array( 'suppress-post' => false ), + array( 'restore-post' => false ), + array( 'suppress-post' => false ), + ) ), + array( $this->oversightUser(), 'view', array( + array( 'new-post' => true ), + array( 'suppress-post' => true ), + array( 'restore-post' => true ), + array( 'suppress-post' => true ), + ) ), + + // bug 61715 + array( $this->confirmedUser(), 'history', array( + array( 'new-post' => false ), + array( 'suppress-post' => false ), + ) ), + array( $this->confirmedUser(), 'history', array( + array( 'new-post' => true ), + array( 'suppress-post' => false ), + array( 'restore-post' => false ), + ) ), + ); + } + + /** + * @dataProvider permissionsProvider + */ + public function testPermissions( User $user, $permissionAction, $actions ) { + $permissions = new RevisionActionPermissions( $this->actions, $user ); + + // we'll have to process this in 2 steps: first do all of the actions, + // so we have a full tree of moderated revisions + $revision = null; + $revisions = array(); + $debug = array(); + foreach ( $actions as $action ) { + $expect = current( $action ); + $action = key( $action ); + $debug[] = $action . ':' . ( $expect ? 'true' : 'false' ); + $revisions[] = $revision = $this->generateRevision( $action, $revision ); + } + + // commit pending db transaction + Container::get( 'db.factory' )->getDB( DB_MASTER )->commit( __METHOD__, 'flush' ); + + $debug = implode( ' ', $debug ); + // secondly, iterate all revisions & see if expected permissions line up + foreach ( $actions as $action ) { + $expected = current( $action ); + $revision = array_shift( $revisions ); + $this->assertEquals( + $expected, + $permissions->isAllowed( $revision, $permissionAction ), + 'User ' . $user->getName() . ' should ' . ( $expected ? '' : 'not ' ) . 'be allowed action ' . $permissionAction . ' on revision ' . key( $action ) . ' : ' . $debug . ' : ' . json_encode( $revision::toStorageRow( $revision ) ) + ); + } + } + + protected function blockedUser() { + if ( !$this->blockedUser ) { + $this->blockedUser = User::newFromName( 'UTFlowBlockee' ); + $this->blockedUser->addToDatabase(); + // note: the block will be added in setUp & deleted in tearDown; + // otherwise this is just any regular user + } + + return $this->blockedUser; + } + + protected function anonUser() { + if ( !$this->anonUser ) { + $this->anonUser = new User; + } + + return $this->anonUser; + } + + protected function unconfirmedUser() { + if ( !$this->unconfirmedUser ) { + $this->unconfirmedUser = User::newFromName( 'UTFlowUnconfirmed' ); + $this->unconfirmedUser->addToDatabase(); + $this->unconfirmedUser->addGroup( 'user' ); + } + + return $this->unconfirmedUser; + } + + protected function confirmedUser() { + if ( !$this->confirmedUser ) { + $this->confirmedUser = User::newFromName( 'UTFlowConfirmed' ); + $this->confirmedUser->addToDatabase(); + $this->confirmedUser->addGroup( 'autoconfirmed' ); + } + + return $this->confirmedUser; + } + + protected function sysopUser() { + if ( !$this->sysopUser ) { + $this->sysopUser = User::newFromName( 'UTFlowSysop' ); + $this->sysopUser->addToDatabase(); + $this->sysopUser->addGroup( 'sysop' ); + } + + return $this->sysopUser; + } + + protected function oversightUser() { + if ( !$this->oversightUser ) { + $this->oversightUser = User::newFromName( 'UTFlowOversight' ); + $this->oversightUser->addToDatabase(); + $this->oversightUser->addGroup( 'oversight' ); + } + + return $this->oversightUser; + } + + /** + * @param string $action + * @param AbstractRevision|null $parent + * @param array $overrides + * @return PostRevision + */ + public function generateRevision( $action, AbstractRevision $parent = null, array $overrides = array() ) { + $overrides['rev_change_type'] = $action; + + if ( $parent ) { + $overrides['rev_parent_id'] = $parent->getRevisionId()->getBinary(); + $overrides['tree_rev_descendant_id'] = $parent->getPostId()->getBinary(); + $overrides['rev_type_id'] = $parent->getPostId()->getBinary(); + } + + switch ( $action ) { + case 'restore-post': + $overrides += array( + 'rev_mod_state' => $this->moderation[$action], // AbstractRevision::MODERATED_NONE + 'rev_mod_user_id' => null, + 'rev_mod_user_ip' => null, + 'rev_mod_timestamp' => null, + 'rev_mod_reason' => 'unit test', + ); + break; + + case 'hide-post': + case 'delete-post': + case 'suppress-post': + $overrides += array( + 'rev_mod_state' => $this->moderation[$action], // AbstractRevision::MODERATED_(HIDDEN|DELETED|SUPPRESSED) + 'rev_mod_user_id' => 1, + 'rev_mod_user_ip' => null, + 'rev_mod_timestamp' => wfTimestampNow(), + 'rev_mod_reason' => 'unit test', + ); + break; + + default: + // nothing special + break; + } + + $revision = $this->generateObject( $overrides ); + $this->store( $revision ); + + return $revision; + } +} diff --git a/Flow/tests/phpunit/ContainerTest.php b/Flow/tests/phpunit/ContainerTest.php new file mode 100644 index 00000000..9dc7a1d8 --- /dev/null +++ b/Flow/tests/phpunit/ContainerTest.php @@ -0,0 +1,44 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; + +/** + * @group Flow + */ +class ContainerTest extends FlowTestCase { + + public function testInstantiateAll() { + $this->setMwGlobals( 'wgTitle', \Title::newMainPage() ); + $container = Container::getContainer(); + + foreach ( $container->keys() as $key ) { + $this->assertNotNull( $container[$key], $key ); + } + } + + public function objectManagerKeyProvider() { + $tests = array(); + foreach ( array_unique( Container::get( 'storage.manager_list' ) ) as $key ) { + $tests[] = array( $key ); + } + return $tests; + } + + /** + * @dataProvider objectManagerKeyProvider + */ + public function testSomething( $key ) { + $c = Container::getContainer(); + $this->assertNotNull( $c[$key] ); + foreach ( $c["$key.indexes"] as $pos => $index ) { + $this->assertInstanceOf( 'Flow\Data\Index', $index, "At $key.indexes[$pos]" ); + } + if ( isset( $c["$key.listeners"] ) ) { + foreach ( $c["$key.listeners"] as $pos => $listener ) { + $this->assertInstanceOf( "Flow\Data\LifecycleHandler", $listener, "At $key.listeners[$pos]" ); + } + } + } +} diff --git a/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php b/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php new file mode 100644 index 00000000..1b5368eb --- /dev/null +++ b/Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php @@ -0,0 +1,273 @@ +<?php + +namespace Flow\Tests; + +use BagOStuff; +use EmptyBagOStuff; +use Flow\Data\BagOStuff\BufferedBagOStuff; +use HashBagOStuff; +use MediaWikiTestCase; +use MultiWriteBagOStuff; +use ObjectCache; + +/** + * @group Flow + */ +class BufferedBagOStuffTest extends MediaWikiTestCase { + /** + * @var BagOStuff + */ + protected $cache; + + /** + * @var BufferedBagOStuff + */ + protected $bufferedCache; + + /** + * Array of keys used in these tests, so we can clear them on tearDown. + * + * @var string[] + */ + protected $keys = array( 'key', 'key2' ); + + protected function setUp() { + parent::setUp(); + + // type defined through parameter + if ( $this->getCliArg( 'use-bagostuff' ) ) { + $name = $this->getCliArg( 'use-bagostuff' ); + + $this->cache = ObjectCache::newFromId( $name ); + } else { + // no type defined - use simple hash + $this->cache = new HashBagOStuff; + } + + $this->bufferedCache = new BufferedBagOStuff( $this->cache ); + $this->bufferedCache->begin(); + } + + protected function tearDown() { + // make sure all keys written to in any of these tests are deleted from + // the real cache + foreach ( $this->keys as $key ) { + $this->cache->delete( $key ); + } + + parent::tearDown(); + } + + public function testGetAndSet() { + $this->bufferedCache->set( 'key', 'value' ); + + // check that the value is only set on bufferedCache, not yet on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + + $this->bufferedCache->commit(); + + // check that the value is also set on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value', $this->cache->get( 'key' ) ); + } + + public function testAdd() { + $this->bufferedCache->add( 'key', 'value' ); + + // check that the value is only set on bufferedCache, not yet on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + + $this->bufferedCache->commit(); + + // check that the value is also set on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value', $this->cache->get( 'key' ) ); + } + + public function testAddFailImmediately() { + $this->cache->set( 'key', 'value' ); + $this->bufferedCache->add( 'key', 'value-2' ); + + $this->bufferedCache->commit(); + + // check that the value is not added on bufferedCache, nor on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value', $this->cache->get( 'key' ) ); + } + + public function testAddFailDeferred() { + $this->bufferedCache->add( 'key', 'value' ); + + // something else directly sets the key in the meantime... + $this->cache->set( 'key', 'value-2' ); + + // check that the value has been added to buffered cache but not yet to real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value-2', $this->cache->get( 'key' ) ); + + $this->bufferedCache->commit(); + + // check that the value failed to add and the key was properly cleared + $this->assertEquals( false, $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + } + + public function testDelete() { + $this->cache->set( 'key', 'value' ); + + $this->bufferedCache->delete( 'key' ); + + // check that the value has been deleted from bufferedcache (only) + $this->assertEquals( false, $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value', $this->cache->get( 'key' ) ); + + $this->bufferedCache->commit(); + + // check that the value has also been deleted from real cache + $this->assertEquals( false, $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + } + + public function testGetMulti() { + $localValues = array( + 'key' => 'value', + ); + $cacheValues = array( + 'key2' => 'value2', + ); + + foreach ( $localValues as $key => $value ) { + $this->bufferedCache->set( $key, $value ); + } + + foreach ( $cacheValues as $key => $value ) { + $this->cache->set( $key, $value ); + } + + // check that we're able to read the values from both buffered & real cache + $this->assertEquals( $localValues + $cacheValues, $this->bufferedCache->getMulti( array_keys( $localValues + $cacheValues ) ) ); + + // tearDown will cleanup everything that's been stored via buffered cache, + // however, this one went directly to real cache - clean up! + $this->cache->delete( 'key2' ); + } + + public function testSetMulti() { + $this->bufferedCache->setMulti( array( + 'key' => 'value', + 'key2' => 'value2', + ) ); + + // check that the values are only set on bufferedCache, not yet on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value2', $this->bufferedCache->get( 'key2' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key2' ) ); + + $this->bufferedCache->commit(); + + // check that the values are also set on real cache + $this->assertEquals( 'value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'value2', $this->bufferedCache->get( 'key2' ) ); + $this->assertEquals( 'value', $this->cache->get( 'key' ) ); + $this->assertEquals( 'value2', $this->cache->get( 'key2' ) ); + } + + public function testMerge() { + $this->cache->set( 'key', 'value' ); + + $callback = function( \BagOStuff $cache, $key, $value ) { + return 'merged-value'; + }; + $this->bufferedCache->merge( 'key', $callback ); + + $this->bufferedCache->commit(); + + // check that the values are merged both in buffered & real cache + $this->assertEquals( 'merged-value', $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( 'merged-value', $this->cache->get( 'key' ) ); + } + + // Can't make a test for merge to fail immediately: the buffered part is + // only in memory, for the current process. There is no way something else + // will be able to overwrite something in there. + + public function testMergeFailDelayed() { + /* + * Test concurrent merges by forking this process, if: + * - not manually called with --use-bagostuff + * - pcntl_fork is supported by the system + * - cache type will correctly support calls over forks + */ + $fork = (bool) $this->getCliArg( 'use-bagostuff' ); + $fork &= function_exists( 'pcntl_fork' ); + $fork &= !$this->cache instanceof HashBagOStuff; + $fork &= !$this->cache instanceof EmptyBagOStuff; + $fork &= !$this->cache instanceof MultiWriteBagOStuff; + + if ( !$fork ) { + $this->markTestSkipped( "Unable to fork, can't test merge" ); + } + + $this->cache->set( 'key', 'value' ); + + $callback = function ( \BagOStuff $cache, $key, $value ) { + // prepend merged to whatever is in cache + return 'merged-' . (string) $value; + }; + $this->bufferedCache->merge( 'key', $callback, 0, 1 ); + + // callback should take awhile now so that we can test concurrent merge attempts + $pid = pcntl_fork(); + if ( $pid == -1 ) { + // can't fork, ignore this test... + } elseif ( $pid ) { + // wait a little, making sure that the child process is calling merge + usleep( 3000 ); + + // attempt a merge - this should fail to persist to real cache + $this->bufferedCache->commit(); + + // make sure the child's merge is completed and verify + usleep( 3000 ); + + // check that the values failed to merge + $this->assertEquals( false, $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + } else { + $this->bufferedCache->commit(); + + // before exiting, tear down - MediaWikiTestCase will check for it + // on destruct & throw an exception if it wasn't called + $this->tearDown(); + + // Note: I'm not even going to check if the merge worked, I'll + // compare values in the parent process to test if this merge worked. + // I'm just going to exit this child process, since I don't want the + // child to output any test results (would be rather confusing to + // have test output twice) + exit; + } + } + + public function testRollback() { + $this->cache->set( 'key', 'value' ); + + $this->bufferedCache->set( 'key', 'value-2' ); + $this->bufferedCache->add( 'key2', 'value-2' ); + + // something else directly sets the key in the meantime... + $this->cache->set( 'key2', 'value' ); + + $this->bufferedCache->commit(); + + // both changes should have been "rolled back" and both keys should've + // been cleared, in both buffered & real cache + $this->assertEquals( false, $this->bufferedCache->get( 'key' ) ); + $this->assertEquals( false, $this->bufferedCache->get( 'key2' ) ); + $this->assertEquals( false, $this->cache->get( 'key' ) ); + $this->assertEquals( false, $this->cache->get( 'key2' ) ); + } +} diff --git a/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php b/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php new file mode 100644 index 00000000..a386be08 --- /dev/null +++ b/Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php @@ -0,0 +1,31 @@ +<?php + +namespace Flow\Tests; + +use Flow\Data\BagOStuff\LocalBufferedBagOStuff; +use HashBagOStuff; +use ObjectCache; + +/** + * Runs the exact same set of tests as BufferedBagOStuffTest, but with a + * LocalBufferedBagOStuff object (where get requests are also cached) + * @group Flow + */ +class LocalBufferedBagOStuffTest extends BufferedBagOStuffTest { + protected function setUp() { + parent::setUp(); + + // type defined through parameter + if ( $this->getCliArg( 'use-bagostuff' ) ) { + $name = $this->getCliArg( 'use-bagostuff' ); + + $this->cache = ObjectCache::newFromId( $name ); + } else { + // no type defined - use simple hash + $this->cache = new HashBagOStuff; + } + + $this->bufferedCache = new LocalBufferedBagOStuff( $this->cache ); + $this->bufferedCache->begin(); + } +} diff --git a/Flow/tests/phpunit/Data/BufferedCacheTest.php b/Flow/tests/phpunit/Data/BufferedCacheTest.php new file mode 100644 index 00000000..945b721b --- /dev/null +++ b/Flow/tests/phpunit/Data/BufferedCacheTest.php @@ -0,0 +1,33 @@ +<?php + +namespace Flow\Tests; + +use Flow\Data\BagOStuff\LocalBufferedBagOStuff; +use Flow\Data\BufferedCache; +use HashBagOStuff; +use ObjectCache; + +/** + * Runs the exact same set of tests as BufferedBagOStuffTest, but with a + * LocalBufferedCache object (with static expiry time) + * @group Flow + */ +class BufferedCacheTest extends BufferedBagOStuffTest { + protected function setUp() { + parent::setUp(); + + // type defined through parameter + if ( $this->getCliArg( 'use-bagostuff' ) ) { + $name = $this->getCliArg( 'use-bagostuff' ); + + $this->cache = ObjectCache::newFromId( $name ); + } else { + // no type defined - use simple hash + $this->cache = new HashBagOStuff; + } + + $cache = new LocalBufferedBagOStuff( $this->cache ); + $this->bufferedCache = new BufferedCache( $cache, 30 ); + $this->bufferedCache->begin(); + } +} diff --git a/Flow/tests/phpunit/Data/CachingObjectMapperTest.php b/Flow/tests/phpunit/Data/CachingObjectMapperTest.php new file mode 100644 index 00000000..3c7ec013 --- /dev/null +++ b/Flow/tests/phpunit/Data/CachingObjectMapperTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Data\Mapper\CachingObjectMapper; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class CachingObjectManagerTest extends FlowTestCase { + + public function testReturnsSameObject() { + $mapper = $this->createMapper(); + $object = $mapper->fromStorageRow( array( 'id' => 1 ) ); + $this->assertSame( $object, $mapper->fromStorageRow( array( 'id' => 1 ) ) ); + } + + public function testAllowsNullPkOnPut() { + $this->createMapper()->toStorageRow( (object)array( 'id' => null ) ); + $this->assertTrue( true ); + } + + protected function createMapper() { + $toStorageRow = function( $object ) { return (array)$object; }; + $fromStorageRow = function( array $row, $object ) { + if ( $object === null ) { + return (object)$row; + } else { + return (object)( $row + (array)$object ); + } + }; + return new CachingObjectMapper( $toStorageRow, $fromStorageRow, array( 'id' ) ); + } +} diff --git a/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php b/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php new file mode 100644 index 00000000..c2e41057 --- /dev/null +++ b/Flow/tests/phpunit/Data/Index/FeatureIndexTest.php @@ -0,0 +1,104 @@ +<?php + +namespace Flow\Tests\Data\Index; + +use Flow\Data\Index\FeatureIndex; + +/** + * @group Flow + */ +class FeatureIndexTest extends \MediaWikiTestCase { + + public function testOffsetIdReturnsCorrectPortionOfIndexedValues() { + global $wgFlowCacheVersion; + $cache = $this->getMockBuilder( 'Flow\Data\BufferedCache' ) + ->disableOriginalConstructor() + ->getMock(); + $storage = $this->getMockBuilder( 'Flow\Data\ObjectStorage' ) + ->disableOriginalConstructor() + ->getMock(); + + $dbId = FeatureIndex::cachedDbId(); + $cache->expects( $this->any() ) + ->method( 'getMulti' ) + ->will( $this->returnValue( array( + "$dbId:foo:5:$wgFlowCacheVersion" => array( + array( 'some_row' => 40 ), + array( 'some_row' => 41 ), + array( 'some_row' => 42 ), + array( 'some_row' => 43 ), + array( 'some_row' => 44 ), + ), + ) ) ); + $storage->expects( $this->never() ) + ->method( 'findMulti' ); + + $index = new MockFeatureIndex( $cache, $storage, 'foo', array( 'bar' ) ); + + $res = $index->find( + array( 'bar' => 5 ), + array( 'offset-id' => 42 ) + ); + + $this->assertEquals( + array( + array( 'some_row' => 43, 'bar' => 5 ), + array( 'some_row' => 44, 'bar' => 5 ), + ), + array_values( $res ), + 'Returns items with some_row > provided offset-id of 42' + ); + } + + public function testReversePagination() { + global $wgFlowCacheVersion; + $cache = $this->getMockBuilder( 'Flow\Data\BufferedCache' ) + ->disableOriginalConstructor() + ->getMock(); + $storage = $this->getMockBuilder( 'Flow\Data\ObjectStorage' ) + ->disableOriginalConstructor() + ->getMock(); + + $dbId = FeatureIndex::cachedDbId(); + $cache->expects( $this->any() ) + ->method( 'getMulti' ) + ->will( $this->returnValue( array( + "$dbId:foo:5:$wgFlowCacheVersion" => array( + array( 'some_row' => 40 ), + array( 'some_row' => 41 ), + array( 'some_row' => 42 ), + array( 'some_row' => 43 ), + array( 'some_row' => 44 ), + ), + ) ) ); + $storage->expects( $this->never() ) + ->method( 'findMulti' ); + + $index = new MockFeatureIndex( $cache, $storage, 'foo', array( 'bar' ) ); + + $res = $index->find( + array( 'bar' => 5 ), + array( 'offset-id' => 43, 'offset-dir' => 'rev', 'limit' => 2 ) + ); + $this->assertEquals( + array( + array( 'some_row' => 41, 'bar' => 5 ), + array( 'some_row' => 42, 'bar' => 5 ), + ), + array_values( $res ), + 'Data should retain original sort, taking selected items from before the offset' + ); + } +} + +class MockFeatureIndex extends FeatureIndex { + public function getLimit() { return 42; } + public function queryOptions() { return array(); } + public function limitIndexSize( array $values ) { return $values; } + public function addToIndex( array $indexed, array $row ) {} + public function removeFromIndex( array $indexed, array $row ) {} + + // not abstract, but override for convenience + public function getSort() { return array( 'some_row' ); } + public function getOrder() { return 'ASC'; } +} diff --git a/Flow/tests/phpunit/Data/IndexTest.php b/Flow/tests/phpunit/Data/IndexTest.php new file mode 100644 index 00000000..02f69496 --- /dev/null +++ b/Flow/tests/phpunit/Data/IndexTest.php @@ -0,0 +1,111 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Container; +use Flow\Data\BagOStuff\BufferedBagOStuff; +use Flow\Data\BufferedCache; +use Flow\Data\Index\FeatureIndex; +use Flow\Data\Index\TopKIndex; +use Flow\Data\Index\UniqueFeatureIndex; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class IndexTest extends FlowTestCase { + + public function testShallow() { + global $wgFlowCacheTime; + + $bag = new BufferedBagOStuff( new \HashBagOStuff ); + $cache = new BufferedCache( $bag, $wgFlowCacheTime ); + + // As we are only testing the cached result, storage should never be called + // not sure how to test that + $storage = $this->getMock( 'Flow\\Data\\ObjectStorage' ); + + $unique = new UniqueFeatureIndex( + $cache, $storage, 'unique', + array( 'id' ) + ); + + $secondary = new TopKIndex( + $cache, $storage, 'secondary', + array( 'name' ), // keys indexed in this array + array( + 'shallow' => $unique, + 'sort' => 'id', + ) + ); + + $db = FeatureIndex::cachedDbId(); + $v = Container::get( 'cache.version' ); + $bag->set( "$db:unique:1:$v", array( array( 'id' => 1, 'name' => 'foo', 'other' => 'ppp' ) ) ); + $bag->set( "$db:unique:2:$v", array( array( 'id' => 2, 'name' => 'foo', 'other' => 'qqq' ) ) ); + $bag->set( "$db:unique:3:$v", array( array( 'id' => 3, 'name' => 'baz', 'other' => 'lll' ) ) ); + + $bag->set( "$db:secondary:foo:$v", array( array( 'id' => 1 ), array( 'id' => 2 ) ) ); + $bag->set( "$db:secondary:baz:$v", array( array( 'id' => 3 ) ) ); + + $expect = array( + array( 'id' => 1, 'name' => 'foo', 'other' => 'ppp', ), + array( 'id' => 2, 'name' => 'foo', 'other' => 'qqq', ), + ); + $this->assertEquals( $expect, $secondary->find( array( 'name' => 'foo' ) ) ); + + $expect = array( + array( 'id' => 3, 'name' => 'baz', 'other' => 'lll' ), + ); + $this->assertEquals( $expect, $secondary->find( array( 'name' => 'baz' ) ) ); + } + + public function testCompositeShallow() { + global $wgFlowCacheTime; + + $bag = new BufferedBagOStuff( new \HashBagOStuff ); + $cache = new BufferedCache( $bag, $wgFlowCacheTime ); + $storage = $this->getMock( 'Flow\\Data\\ObjectStorage' ); + + $unique = new UniqueFeatureIndex( + $cache, $storage, 'unique', + array( 'id', 'ot' ) + ); + + $secondary = new TopKIndex( + $cache, $storage, 'secondary', + array( 'name' ), // keys indexed in this array + array( + 'shallow' => $unique, + 'sort' => 'id', + ) + ); + + // remember: unique index still stores an array of results to be consistent with other indexes + // even though, due to uniqueness, there is only one value per set of keys + $db = FeatureIndex::cachedDbId(); + $v = Container::get( 'cache.version' ); + $bag->set( "$db:unique:1:9:$v", array( array( 'id' => 1, 'ot' => 9, 'name' => 'foo' ) ) ); + $bag->set( "$db:unique:1:8:$v", array( array( 'id' => 1, 'ot' => 8, 'name' => 'foo' ) ) ); + $bag->set( "$db:unique:3:7:$v", array( array( 'id' => 3, 'ot' => 7, 'name' => 'baz' ) ) ); + + $bag->set( "$db:secondary:foo:$v", array( + array( 'id' => 1, 'ot' => 9 ), + array( 'id' => 1, 'ot' => 8 ), + ) ); + $bag->set( "$db:secondary:baz:$v", array( + array( 'id' => 3, 'ot' => 7 ), + ) ); + + $expect = array( + array( 'id' => 1, 'ot' => 9, 'name' => 'foo' ), + array( 'id' => 1, 'ot' => 8, 'name' => 'foo' ), + ); + $this->assertEquals( $expect, $secondary->find( array( 'name' => 'foo' ) ) ); + + $expect = array( + array( 'id' => 3, 'ot' => 7, 'name' => 'baz' ), + ); + $this->assertEquals( $expect, $secondary->find( array( 'name' => 'baz' ) ) ); + } +} diff --git a/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php b/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php new file mode 100644 index 00000000..2841a627 --- /dev/null +++ b/Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Flow\Tests\Data\Listener; + +use Flow\Container; +use Flow\Data\Listener\RecentChangesListener; +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Title; +use User; + +/** + * @group Flow + */ +class RecentChangesListenerTest extends \MediaWikiTestCase { + + public function somethingProvider() { + return array( + array( + 'New topic recent change goes to the board', + // expect + NS_MAIN, + // something + function( $workflow, $user ) { + return PostRevision::create( $workflow, $user, 'blah blah', 'wikitext' ); + } + ), + + array( + 'Reply recent change goes to the topic', + NS_TOPIC, + function( $workflow, $user ) { + $first = PostRevision::create( $workflow, $user, 'blah blah', 'wikitext' ); + return $first->reply( $workflow, $user, 'fofofo', 'wikitext' ); + }, + ), + ); + } + + /** + * @dataProvider somethingProvider + */ + public function testSomething( $message, $expect, $init ) { + $actions = Container::get( 'flow_actions' ); + $usernames = $this->getMockBuilder( 'Flow\Repository\UserNameBatch' ) + ->disableOriginalConstructor() + ->getMock(); + $rcFactory = $this->getMockBuilder( 'Flow\Data\Utils\RecentChangeFactory' ) + ->disableOriginalConstructor() + ->getMock(); + $ircFormatter = $this->getMockBuilder( 'Flow\Formatter\IRCLineUrlFormatter' ) + ->disableOriginalConstructor() + ->getMock(); + + $rc = new RecentChangesListener( $actions, $usernames, $rcFactory, $ircFormatter ); + $change = $this->getMock( 'RecentChange' ); + $rcFactory->expects( $this->once() ) + ->method( 'newFromRow' ) + ->will( $this->returnCallback( function( $obj ) use ( &$ref, $change ) { + $ref = $obj; + return $change; + } ) ); + + $title = Title::newMainPage(); + $user = User::newFromName( '127.0.0.1', false ); + $workflow = Workflow::create( 'topic', $title ); + + $revision = $init( $workflow, $user ); + + $rc->onAfterInsert( + $revision, + array( 'rev_user_id' => 0, 'rev_user_ip' => '127.0.0.1' ), + array( 'workflow' => $workflow ) + ); + $this->assertNotNull( $ref ); + $this->assertEquals( $expect, $ref->rc_namespace, $message ); + } +} diff --git a/Flow/tests/phpunit/Data/ManagerGroupTest.php b/Flow/tests/phpunit/Data/ManagerGroupTest.php new file mode 100644 index 00000000..dfbe5086 --- /dev/null +++ b/Flow/tests/phpunit/Data/ManagerGroupTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Container; +use Flow\Data\ManagerGroup; + +/** + * @group Flow + */ +class ManagerGroupTest extends \MediaWikiTestCase { + protected function mockStorage() { + $container = new Container; + foreach ( range( 'A', 'D' ) as $letter ) { + $container[$letter] = $this->getMockBuilder( 'Flow\Data\ObjectManager' ) + ->disableOriginalConstructor() + ->getMock(); + } + + $storage = new ManagerGroup( $container, array( + 'A' => 'A', + 'B' => 'B', + 'C' => 'C', + 'D' => 'D', + 'stdClass' => 'D', + ) ); + + return array( $storage, $container ); + } + + public function testClearOnlyCallsRequestedManagers() { + list( $storage, $container ) = $this->mockStorage(); + $container['A']->expects( $this->never() )->method( 'clear' ); + $container['B']->expects( $this->once() )->method( 'clear' ); + $container['C']->expects( $this->never() )->method( 'clear' ); + $container['D']->expects( $this->never() )->method( 'clear' ); + + $storage->getStorage( 'B' ); + $storage->clear(); + } + + public function testClearCallsNoManagersWhenUnused() { + list( $storage, $container ) = $this->mockStorage(); + $container['A']->expects( $this->never() )->method( 'clear' ); + $container['B']->expects( $this->never() )->method( 'clear' ); + $container['C']->expects( $this->never() )->method( 'clear' ); + $container['D']->expects( $this->never() )->method( 'clear' ); + + $storage->clear(); + } + + public function testCachePurgeCallsAppropriateManager() { + $object = new \stdClass; + + list( $storage, $container ) = $this->mockStorage(); + $container['A']->expects( $this->never() )->method( 'clear' ); + $container['B']->expects( $this->never() )->method( 'clear' ); + $container['C']->expects( $this->never() )->method( 'clear' ); + $container['D']->expects( $this->once() ) + ->method( 'cachePurge' ) + ->with( $this->identicalTo( $object ) ); + + $storage->cachePurge( $object ); + } +} + diff --git a/Flow/tests/phpunit/Data/NothingTest.php b/Flow/tests/phpunit/Data/NothingTest.php new file mode 100644 index 00000000..4cec1671 --- /dev/null +++ b/Flow/tests/phpunit/Data/NothingTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Data\Utils\SortArrayByKeys; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class FlowNothingTest extends FlowTestCase { + + public function sortArrayByKeysProvider() { + return array( + + array( + 'Basic one key sort', + // keys to sort by + array( 'id' ), + // array to sort + array( + array( 'id' => 5 ), + array( 'id' => 7 ), + array( 'id' => 6 ), + ), + // expected result + array( + array( 'id' => 5 ), + array( 'id' => 6 ), + array( 'id' => 7 ), + ), + ), + + array( + 'Multi-key sort', + // keys to sort by + array( 'id', 'qq' ), + // array to sort + array( + array( 'id' => 5, 'qq' => 4 ), + array( 'id' => 5, 'qq' => 2 ), + array( 'id' => 7, 'qq' => 1 ), + array( 'id' => 6, 'qq' => 3 ), + array( 'qq' => 9, 'id' => 4 ), + ), + // expected result + array( + array( 'qq' => 9, 'id' => 4 ), + array( 'id' => 5, 'qq' => 2 ), + array( 'id' => 5, 'qq' => 4 ), + array( 'id' => 6, 'qq' => 3 ), + array( 'id' => 7, 'qq' => 1 ), + ), + ), + + ); + } + + /** + * @dataProvider sortArrayByKeysProvider + */ + public function testSortArrayByKeys( $message, array $keys, array $array, array $sorted, $strict = true ) { + usort( $array, new SortArrayByKeys( $keys, $strict ) ); + $this->assertEquals( $sorted, $array ); + } +} diff --git a/Flow/tests/phpunit/Data/ObjectLocatorTest.php b/Flow/tests/phpunit/Data/ObjectLocatorTest.php new file mode 100644 index 00000000..59dc508a --- /dev/null +++ b/Flow/tests/phpunit/Data/ObjectLocatorTest.php @@ -0,0 +1,24 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class ObjectLocatorTest extends FlowTestCase { + + public function testUselessTest() { + $mapper = $this->getMock( 'Flow\Data\ObjectMapper' ); + $storage = $this->getMock( 'Flow\Data\ObjectStorage' ); + + $locator = new \Flow\Data\ObjectLocator( $mapper, $storage ); + + $storage->expects( $this->any() ) + ->method( 'findMulti' ) + ->will( $this->returnValue( array( array( null, null ) ) ) ); + + $this->assertEquals( array(), $locator->findMulti( array( array( 'foo' => 'random crap' ) ) ) ); + } +} diff --git a/Flow/tests/phpunit/Data/Pager/PagerTest.php b/Flow/tests/phpunit/Data/Pager/PagerTest.php new file mode 100644 index 00000000..038bfcf5 --- /dev/null +++ b/Flow/tests/phpunit/Data/Pager/PagerTest.php @@ -0,0 +1,513 @@ +<?php + +namespace Flow\Tests\Data\Pager; + +use Flow\Data\BagOStuff; +use Flow\Data\BagOStuff\LocalBufferedBagOStuff; +use Flow\Data\BufferedCache; +use Flow\Data\Index\TopKIndex; +use Flow\Data\Pager\Pager; +use stdClass; + +/** + * @group Flow + */ +class PagerTest extends \MediaWikiTestCase { + + public static function getPageResultsProvider() { + $objs = array(); + foreach ( range( 'A', 'J' ) as $letter ) { + $objs[$letter] = (object)array( 'foo' => $letter ); + } + + return array( + array( + 'Gracefully returns nothing', + // expect + array(), + // find results + array(), + // query options, + array(), + // filter + null + ), + + array( + 'Returns found objects', + // expect + array( $objs['A'], $objs['B'] ), + // find results + array( + array( $objs['A'], $objs['B'] ), + ), + // query options + array( 'pager-limit' => 10 ), + // filter + null + ), + + array( + 'Applies filter', + // expect + array( $objs['A'] ), + // find results + array( + array( $objs['A'], $objs['B'] ) + ), + // query options + array( 'pager-limit' => 10 ), + // filter + function( $found ) { + return array_filter( $found, function( $obj ) { return $obj->foo !== 'B'; } ); + }, + ), + + array( + 'Repeats query when filtered', + // expect + array( $objs['A'], $objs['D'] ), + // find results + array( + array( $objs['A'], $objs['B'], $objs['C'] ), + array( $objs['D'], $objs['E'] ), + ), + // query options + array( 'pager-limit' => 2 ), + // query filter + function( $found ) { + return array_filter( $found, function( $obj ) { + return $obj->foo !== 'B' && $obj->foo !== 'C'; + } ); + }, + ), + + array( + 'Reverse pagination with filter', + // expect + array( $objs['B'], $objs['F'], $objs['I'] ), + // find results + array( + // note thate feature index will return these in the normal + // forward sort order, the provided direction just means to + // get items before rather than after the offset. + // verified at FeatureIndexTest::testReversePagination() + array( $objs['G'], $objs['H'], $objs['I'], $objs['J'] ), + array( $objs['C'], $objs['D'], $objs['E'], $objs['F'] ), + array( $objs['A'], $objs['B'] ), + ), + // query options + array( 'pager-limit' => 3, 'pager-dir' => 'rev', 'pager-offset' => 'K' ), + // query filter + function( $found ) { + return array_filter( $found, function( $obj ) { + return in_array( $obj->foo, array( 'I', 'F', 'B', 'A' ) ); + } ); + }, + ), + ); + } + + /** + * @dataProvider getPageResultsProvider + */ + public function testGetPageResults( $message, $expect, $found, array $options, $filter ) { + $pager = new Pager( + $this->mockObjectManager( $found ), + array( 'otherthing' => 42 ), + $options + ); + $page = $pager->getPage( $filter ); + $this->assertInstanceOf( 'Flow\Data\Pager\PagerPage', $page, $message ); + $this->assertEquals( $expect, $page->getResults(), $message ); + } + + + public static function getPagingLinkOptionsProvider() { + $objs = array(); + foreach ( range( 'A', 'G' ) as $letter ) { + $objs[$letter] = (object)array( 'foo' => $letter ); + } + + return array( + array( + 'Gracefully returns nothing', + // expect + array(), + // find results + array(), + // pager options + array(), + // filter + null + ), + + array( + 'No next page with exact number of results', + // expect + array(), + // find results + array( + array( $objs['A'], $objs['B'] ), + ), + // pager options + array( 'pager-limit' => 2 ), + // filter + null + ), + + array( + 'Forward pagination when direction forward and extra result', + // expect + array( + 'fwd' => array( + 'offset-dir' => 'fwd', + 'limit' => 2, + 'offset' => 'serialized-B', + ), + ), + // find results + array( + array( $objs['A'], $objs['B'], $objs['C'] ), + ), + // pager options + array( 'pager-limit' => 2 ), + // filter + null + ), + + array( + 'Forward pagination when multi-query filtered', + // expect + array( + 'fwd' => array( + 'offset-dir' => 'fwd', + 'limit' => 2, + 'offset' => 'serialized-D', + ), + ), + // find results + array( + array( $objs['A'], $objs['B'], $objs['C'] ), + array( $objs['D'], $objs['E'] ), + ), + // pager options + array( 'pager-limit' => 2 ), + // filter + function( $found ) { + return array_filter( $found, function( $obj ) { return $obj->foo > 'B'; } ); + }, + ), + + array( + 'Multi-query edge case must issue second query', + // expect + array( + 'fwd' => array( + 'offset-dir' => 'fwd', + 'limit' => 2, + 'offset' => 'serialized-C', + ), + ), + array( + array( $objs['A'], $objs['B'], $objs['C'] ), + array( $objs['D'], $objs['E'], $objs['F'] ), + ), + array( 'pager-limit' => 2 ), + // filter + function( $found ) { + return array_filter( $found, function( $obj ) { return $obj->foo !== 'A'; } ); + }, + ), + + array( + 'Reverse pagination when offset-id is present in options', + // expect + array( + 'rev' => array( + 'offset-dir' => 'rev', + 'limit' => 2, + 'offset' => 'serialized-B', + ), + 'fwd' => array( + 'offset-dir' => 'fwd', + 'limit' => 2, + 'offset' => 'serialized-C', + ), + ), + // find results + array( + array( $objs['B'], $objs['C'], $objs['D'] ), + ), + // pager options + array( + 'pager-limit' => 2, + 'pager-offset' => 'serialized-A', + 'pager-dir' => 'fwd', + ), + // filter + null, + ), + ); + } + + /** + * @dataProvider getPagingLinkOptionsProvider + */ + public function testGetPagingLinkOptions( $message, $expect, $found, array $options, $filter ) { + $pager = new Pager( + $this->mockObjectManager( $found ), + array( 'otherthing' => 42 ), + $options + ); + $page = $pager->getPage( $filter ); + $this->assertInstanceOf( 'Flow\Data\Pager\PagerPage', $page, $message ); + $this->assertEquals( $expect, $page->getPagingLinksOptions(), $message ); + } + + public static function optionsPassedToObjectManagerFindProvider() { + return array( + array( + 'Requests one more object than pagination is for', + // expect + array( 'limit' => 3 ), + // pager options + array( + 'pager-limit' => 2, + ) + ), + + array( + 'Pager limit cannot be negative', + // expect + array( 'limit' => 2 ), + // pager options + array( 'pager-limit' => -99 ), + ), + + array( + 'Pager limit cannot exceed 500', + // expect + array( 'limit' => 2 ), + // pager options + array( 'pager-limit' => 501 ), + ), + + array( + 'Offset dir defaults to fwd', + // expect + array( 'offset-dir' => 'fwd' ), + // pager options + array(), + ), + + array( + 'Offset dir can be reversed', + // expect + array( 'offset-dir' => 'rev' ), + // pager options + array( 'pager-dir' => 'rev' ), + ), + + array( + 'Gracefully handles unknown offset dir', + // expect + array( 'offset-dir' => 'fwd' ), + // pager options + array( 'pager-dir' => 'yabba dabba do' ), + ), + + array( + 'offset-id defaults to null', + // expect + array( 'offset-id' => null ), + // pager options + array() + ), + + array( + 'initial offset-id is set by providing pager-offset', + // expect + array( 'offset-id' => 'echo and flow' ), + // pager options + array( 'pager-offset' => 'echo and flow' ), + ), + ); + } + + /** + * @dataProvider optionsPassedToObjectManagerFindProvider + */ + public function testOptionsPassedToObjectManagerFind( $message, $expect, $options ) { + $om = $this->mockObjectManager(); + $om->expects( $this->any() ) + ->method( 'find' ) + ->with( $this->anything(), $this->callback( function ( $opts ) use ( &$options ) { + $options = $opts; + return true; + } ) ); + + $pager = new Pager( + $om, + array( 'otherthing' => 42 ), + $options + ); + $page = $pager->getPage(); + + $this->assertNotNull( $options ); + $optionsString = json_encode( $options ); + foreach ( $expect as $key => $value ) { + $this->assertArrayHasKey( $key, $options, $optionsString ); + $this->assertEquals( $value, $options[$key], $optionsString ); + } + } + + public function includeOffsetProvider() { + return array( + array( + '', + // expected returned series of 'bar' values + array( 5, 4, 3, 2, 1 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => true, + ), + ), + array( + '', + // expected returned series of 'bar' values + array( 4, 3, 2, 1 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => false, + ), + ), + array( + '', + // expected returned series of 'bar' values + array( 9, 8, 7, 6, 5 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => true, + 'offset-dir' => 'rev', + 'offset-elastic' => false, + ), + ), + array( + '', + // expected returned series of 'bar' values + array( 9, 8, 7, 6 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => false, + 'offset-dir' => 'rev', + 'offset-elastic' => false, + ), + ), + array( + '', + // expected returned series of 'bar' values + array( 9, 8, 7, 6, 5, 4, 3, 2, 1 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => true, + 'offset-dir' => 'rev', + 'offset-elastic' => true, + ), + ), + array( + '', + // expected returned series of 'bar' values + array( 9, 8, 7, 6, 5, 4, 3, 2, 1 ), + // query options + array( + 'offset-id' => 5, + 'include-offset' => false, + 'offset-dir' => 'rev', + 'offset-elastic' => true, + ), + ), + + ); + } + + /** + * @dataProvider includeOffsetProvider + */ + public function testIncludeOffset( $message, $expect, $queryOptions ) { + global $wgFlowCacheVersion; + + $bag = new \HashBagOStuff(); + $innerCache = new LocalBufferedBagOStuff( $bag ); + $cache = new BufferedCache( $innerCache ); + + // preload our answer + $bag->set( wfWikiId() . ":prefix:1:$wgFlowCacheVersion", array( + array( 'foo' => 1, 'bar' => 9 ), + array( 'foo' => 1, 'bar' => 8 ), + array( 'foo' => 1, 'bar' => 7 ), + array( 'foo' => 1, 'bar' => 6 ), + array( 'foo' => 1, 'bar' => 5 ), + array( 'foo' => 1, 'bar' => 4 ), + array( 'foo' => 1, 'bar' => 3 ), + array( 'foo' => 1, 'bar' => 2 ), + array( 'foo' => 1, 'bar' => 1 ), + ) ); + + $storage = $this->getMock( 'Flow\Data\ObjectStorage' ); + + $index = new TopKIndex( + $cache, + $storage, + 'prefix', + array( 'foo' ), + array( + 'sort' => 'bar', + ) + ); + + $result = $index->find( array( 'foo' => '1' ), $queryOptions ); + foreach ( $result as $row ) { + $found[] = $row['bar']; + } + + $this->assertEquals( + $expect, + $found + ); + } + + protected function mockObjectManager( array $found = array() ) { + $index = $this->getMock( 'Flow\Data\Index' ); + $index->expects( $this->any() ) + ->method( 'getSort' ) + ->will( $this->returnValue( array( 'something' ) ) ); + $om = $this->getMockBuilder( 'Flow\Data\ObjectManager' ) + ->disableOriginalConstructor() + ->getMock(); + $om->expects( $this->any() ) + ->method( 'getIndexFor' ) + ->will( $this->returnValue( $index ) ); + $om->expects( $this->any() ) + ->method( 'serializeOffset' ) + ->will( $this->returnCallback( function( $obj, $sort ) { + return 'serialized-' . $obj->foo; + } ) ); + + if ( $found ) { + $om->expects( $this->any() ) + ->method( 'find' ) + ->will( call_user_func_array( + array( $this, 'onConsecutiveCalls' ), + array_map( array( $this, 'returnValue' ), $found ) + ) ); + } + + return $om; + } +} diff --git a/Flow/tests/phpunit/Data/RevisionStorageTest.php b/Flow/tests/phpunit/Data/RevisionStorageTest.php new file mode 100644 index 00000000..025f1d58 --- /dev/null +++ b/Flow/tests/phpunit/Data/RevisionStorageTest.php @@ -0,0 +1,138 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Data\Storage\PostRevisionStorage; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class RevisionStorageTest extends FlowTestCase { + + public static function issuesQueryCountProvider() { + return array( + array( + 'Query by rev_id issues one query', + // db queries issued + 1, + // queries + array( + array( 'rev_id' => 1 ), + array( 'rev_id' => 8 ), + array( 'rev_id' => 3 ), + ), + // query options + array( 'LIMIT' => 1 ) + ), + + array( + 'Query by rev_id issues one query with string limit', + // db queries issued + 1, + // queries + array( + array( 'rev_id' => 1 ), + array( 'rev_id' => 8 ), + array( 'rev_id' => 3 ), + ), + // query options + array( 'LIMIT' => '1' ) + ), + + array( + 'Query for most recent revision issues two queries', + // db queries issued + 2, + // queries + array( + array( 'rev_type_id' => 19 ), + array( 'rev_type_id' => 22 ), + array( 'rev_type_id' => 4 ), + array( 'rev_type_id' => 44 ), + ), + // query options + array( 'LIMIT' => 1, 'ORDER BY' => array( 'rev_id DESC' ) ), + ), + + ); + } + + /** + * @dataProvider issuesQueryCountProvider + */ + public function testIssuesQueryCount( $msg, $count, $queries, $options ) { + if ( !isset( $options['LIMIT'] ) || $options['LIMIT'] != 1 ) { + $this->fail( 'Can only generate result set for LIMIT = 1' ); + } + if ( count( $queries ) <= 2 && count( $queries ) != $count ) { + $this->fail( '<= 2 queries always issues the same number of queries' ); + } + + $result = array(); + foreach ( $queries as $query ) { + // this is not in any way a real result, but enough to get through + // the result processing + $result[] = (object)( $query + array( 'rev_id' => 42, 'tree_rev_id' => 42, 'rev_flags' => '' ) ); + } + + $treeRepo = $this->getMockBuilder( 'Flow\Repository\TreeRepository' ) + ->disableOriginalConstructor() + ->getMock(); + $factory = $this->mockDbFactory(); + // this expect is the assertion for the test + $factory->getDB( null )->expects( $this->exactly( $count ) ) + ->method( 'select' ) + ->will( $this->returnValue( $result ) ); + + $storage = new PostRevisionStorage( $factory, false, $treeRepo ); + + $storage->findMulti( $queries, $options ); + } + + public function testPartialResult() { + $treeRepo = $this->getMockBuilder( 'Flow\Repository\TreeRepository' ) + ->disableOriginalConstructor() + ->getMock(); + $factory = $this->mockDbFactory(); + $factory->getDB( null )->expects( $this->once() ) + ->method( 'select' ) + ->will( $this->returnValue( array( + (object)array( 'rev_id' => 42, 'rev_flags' => '' ) + ) ) ); + + $storage = new PostRevisionStorage( $factory, false, $treeRepo ); + + $res = $storage->findMulti( + array( + array( 'rev_id' => 12 ), + array( 'rev_id' => 42 ), + array( 'rev_id' => 17 ), + ), + array( 'LIMIT' => 1 ) + ); + + $this->assertSame( + array( + null, + array( array( 'rev_id' => 42, 'rev_flags' => '', 'rev_content_url' => null ) ), + null, + ), + $res, + 'Unfound items must be represented with null in the result array' + ); + } + + protected function mockDbFactory() { + $dbw = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + + $factory = $this->getMock( 'Flow\DbFactory' ); + $factory->expects( $this->any() ) + ->method( 'getDB' ) + ->will( $this->returnValue( $dbw ) ); + + return $factory; + } +} diff --git a/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php b/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php new file mode 100644 index 00000000..3c17fa28 --- /dev/null +++ b/Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php @@ -0,0 +1,56 @@ +<?php + +namespace Flow\Tests\Data\Storage; + +use Flow\Data\Storage\HeaderRevisionStorage; +use Flow\Model\UUID; + +/** + * @group Flow + */ +class RevisionStorageTest extends \MediaWikiTestCase { + + public function testUpdateConvertsPrimaryKeyToBinary() { + $dbw = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + $factory = $this->getMockBuilder( 'Flow\DbFactory' ) + ->disableOriginalConstructor() + ->getMock(); + $factory->expects( $this->any() ) + ->method( 'getDB' ) + ->will( $this->returnValue( $dbw ) ); + + $id = UUID::create(); + $dbw->expects( $this->once() ) + ->method( 'update' ) + ->with( + $this->equalTo( 'flow_revision' ), + $this->equalTo( array( + 'rev_mod_user_id' => 42, + ) ), + $this->equalTo( array( + 'rev_id' => $id->getBinary(), + ) ) + ) + ->will( $this->returnValue( true ) ); + $dbw->expects( $this->any() ) + ->method( 'affectedRows' ) + ->will( $this->returnValue( 1 ) ); + + // Header is bare bones implementation, sufficient for testing + // the parent class. + $storage = new HeaderRevisionStorage( $factory, /* $externalStore = */false ); + $storage->update( + array( + 'rev_id' => $id->getAlphadecimal(), + 'rev_mod_user_id' => 0, + ), + array( + 'rev_id' => $id->getAlphadecimal(), + 'rev_mod_user_id' => 42, + ) + ); + + } +} diff --git a/Flow/tests/phpunit/Data/UserNameBatchTest.php b/Flow/tests/phpunit/Data/UserNameBatchTest.php new file mode 100644 index 00000000..24fc06bc --- /dev/null +++ b/Flow/tests/phpunit/Data/UserNameBatchTest.php @@ -0,0 +1,89 @@ +<?php + +namespace Flow\Tests\Data; + +use Flow\Repository\UserNameBatch; +use Flow\Repository\UserName\UserNameQuery; +use Flow\Tests\FlowTestCase; + +/** + * @group Database + * @group Flow + */ +class UserNameBatchTest extends FlowTestCase { + + public function testAllowsAddingNames() { + $batch = new UserNameBatch( $this->createUncalledQuery() ); + $batch->add( 'fakewiki', 42, 'Whale' ); + $this->assertEquals( 'Whale', $batch->get( 'fakewiki', 42 ) ); + } + + static public function acceptsStringOrIntIdsProvider() { + return array( + array( 42, 42 ), + array( 42, '42' ), + array( '42', 42 ), + array( '42', '42' ), + ); + } + + /** + * @dataProvider acceptsStringOrIntIdsProvider + */ + public function testAcceptsStringOrIntIds( $a, $b ) { + $batch = new UserNameBatch( $this->createUncalledQuery() ); + $batch->add( 'fakewiki', $a, 'Whale' ); + $this->assertEquals( 'Whale', $batch->get( 'fakewiki', $b ) ); + } + + public function testQueueUsernames() { + $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' ); + $query->expects( $this->once() ) + ->method( 'execute' ) + ->with( 'fakewiki', array( 12, 27, 18 ) ); + + $batch = new UserNameBatch( $query ); + $batch->add( 'fakewiki', 12 ); + $batch->add( 'fakewiki', '27' ); + $batch->add( 'fakewiki', 18 ); + $batch->resolve( 'fakewiki' ); + } + + public function testMissingAsFalse() { + $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' ); + $query->expects( $this->once() ) + ->method( 'execute' ) + ->with( 'fakewiki', array( 42 ) ); + $batch = new UserNameBatch( $query ); + + $this->assertEquals( false, $batch->get( 'fakewiki', 42 ) ); + } + + public function testPartialMissingAsFalse() { + $query = $this->getMock( 'Flow\Repository\UserName\\UserNameQuery' ); + $query->expects( $this->once() ) + ->method( 'execute' ) + ->with( 'fakewiki', array( 610, 408 ) ) + ->will( $this->returnValue( array( + (object)array( 'user_id' => '408', 'user_name' => 'chuck' ) + ) ) ); + + $batch = new UserNameBatch( $query ); + $batch->add( 'fakewiki', 610 ); + $batch->add( 'fakewiki', 408 ); + + $this->assertEquals( false, $batch->get( 'fakewiki', 610 ) ); + } + + /** + * Create a mock UserNameQuery that must not be called + * @return UserNameQuery + */ + protected function createUncalledQuery() { + $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' ); + $query->expects( $this->never() ) + ->method( 'execute' ); + + return $query; + } +} diff --git a/Flow/tests/phpunit/Data/UserNameListenerTest.php b/Flow/tests/phpunit/Data/UserNameListenerTest.php new file mode 100644 index 00000000..216afac0 --- /dev/null +++ b/Flow/tests/phpunit/Data/UserNameListenerTest.php @@ -0,0 +1,51 @@ +<?php + +namespace Flow\Tests\Data; + +use Closure; +use ReflectionClass; +use Flow\Repository\UserNameBatch; +use Flow\Data\Listener\UserNameListener; +use Flow\Tests\FlowTestCase; + +/** + * @group Database + * @group Flow + */ +class UserNameListenerTest extends FlowTestCase { + + public function onAfterLoadDataProvider() { + return array ( + array( array( 'user_id' => '1', 'user_wiki' => 'frwiki' ), array( 'user_id' => 'user_wiki' ), 'frwiki', 'enwiki' ), + array( array( 'user_id' => '2' ), array( 'user_id' => null ), 'enwiki', 'enwiki' ), + array( array( 'user_id' => '3' ), array( 'user_id' => 'user_wiki' ), null ), + // Use closure because wfWikiId() in testxxx() functions appends -unittest_ at the end + array( array( 'user_id' => '4' ), array( 'user_id' => null ), function() { return wfWikiId(); } ), + ); + } + + /** + * @dataProvider onAfterLoadDataProvider + */ + public function testOnAfterLoad( array $row, array $key, $expectedWiki, $defaultWiki = null ) { + $batch = new UserNameBatch( $this->getMock( '\Flow\Repository\UserName\UserNameQuery' ) ); + $listener = new UserNameListener( $batch, $key, $defaultWiki ); + $listener->onAfterLoad( (object)$row, $row ); + + $reflection = new ReflectionClass( $batch ); + $prop = $reflection->getProperty( 'queued' ); + $prop->setAccessible( true ); + $queued = $prop->getValue( $batch ); + + if ( $expectedWiki instanceof Closure ) { + $expectedWiki = call_user_func( $expectedWiki ); + } + + if ( $expectedWiki ) { + $this->assertTrue( in_array( $row['user_id'], $queued[$expectedWiki] ) ); + } else { + $this->assertEmpty( $queued ); + } + } + +} diff --git a/Flow/tests/phpunit/FlowActionsTest.php b/Flow/tests/phpunit/FlowActionsTest.php new file mode 100644 index 00000000..5a29435a --- /dev/null +++ b/Flow/tests/phpunit/FlowActionsTest.php @@ -0,0 +1,22 @@ +<?php + +namespace Flow\Tests; + +use Flow\FlowActions; + +/** + * @group Flow + */ +class FlowActionsTest extends \MediaWikiTestCase { + + public function testAliasedTopLevelValues() { + $actions = new FlowActions( array( + 'something' => 'aliased', + 'aliased' => array( + 'real' => 'value', + ), + ) ); + + $this->assertEquals( 'value', $actions->getValue( 'something', 'real' ) ); + } +} diff --git a/Flow/tests/phpunit/FlowTestCase.php b/Flow/tests/phpunit/FlowTestCase.php new file mode 100644 index 00000000..decc326c --- /dev/null +++ b/Flow/tests/phpunit/FlowTestCase.php @@ -0,0 +1,29 @@ +<?php + +namespace Flow\Tests; + +use Status; + +use Flow\Container; +use Flow\Model\UUID; + +class FlowTestCase extends \MediaWikiTestCase { + protected function setUp() { + Container::reset(); + parent::setUp(); + } + + /** + * @param mixed $data + * @return string + */ + protected function dataToString( $data ) { + foreach ( $data as $key => $value ) { + if ( $value instanceof UUID ) { + $data[$key] = 'UUID: ' . $value->getAlphadecimal(); + } + } + + return parent::dataToString( $data ); + } +} diff --git a/Flow/tests/phpunit/Formatter/FormatterTest.php b/Flow/tests/phpunit/Formatter/FormatterTest.php new file mode 100644 index 00000000..c8945c66 --- /dev/null +++ b/Flow/tests/phpunit/Formatter/FormatterTest.php @@ -0,0 +1,150 @@ +<?php + +namespace Flow\Tests\Formatter; + +use Flow\Container; +use Flow\Formatter\FormatterRow; +use Flow\Formatter\RevisionFormatter; +use Flow\Model\UUID; +use Flow\Tests\FlowTestCase; +use Flow\UrlGenerator; +use Title; + +/** + * @group Flow + */ +class FormatterTest extends FlowTestCase { + + static public function checkUserProvider() { + $topicId = UUID::create(); + $revId = UUID::create(); + $postId = UUID::create(); + + return array( + array( + 'With only a topicId reply should not fail', + // result must contain + function( $test, $message, $result ) { + $test->assertNotNull( $result ); + $test->assertArrayHasKey( 'links', $result, $message ); + }, + // cuc_comment parameters + 'reply', $topicId, $revId, null + ), + + array( + 'With topicId and postId should not fail', + function( $test, $message, $result ) { + $test->assertNotNull( $result ); + $test->assertArrayHasKey( 'links', $result, $message ); + }, + 'reply', $topicId, $revId, $postId, + ), + ); + } + + /** + * @dataProvider checkUserProvider + */ + public function testCheckUserFormatter( $message, $test, $action, UUID $workflowId, UUID $revId, UUID $postId = null ) { + global $wgLang; + + if ( !class_exists( 'CheckUser' ) ) { + $this->markTestSkipped( 'CheckUser is not available' ); + return; + } + + $title = Title::newFromText( 'Test', NS_USER_TALK ); + $row = new FormatterRow; + $row->workflow = $this->mockWorkflow( $workflowId, $title ); + $row->revision = $this->mockRevision( $action, $revId, $postId ); + $row->currentRevision = $row->revision; + + $ctx = $this->getMock( 'IContextSource' ); + $ctx->expects( $this->any() ) + ->method( 'getLanguage' ) + ->will( $this->returnValue( $wgLang ) ); + $ctx->expects( $this->any() ) + ->method( 'msg' ) + ->will( $this->returnCallback( 'wfMessage' ) ); + + // Code uses wfWarn as a louder wfDebugLog in error conditions. + // but phpunit considers a warning a fail. + wfSuppressWarnings(); + $links = $this->createFormatter( 'Flow\Formatter\CheckUserFormatter' )->format( $row, $ctx ); + wfRestoreWarnings(); + $test( $this, $message, $links ); + } + + protected function mockWorkflow( UUID $workflowId, Title $title ) { + $workflow = $this->getMock( 'Flow\\Model\\Workflow' ); + $workflow->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( $workflowId ) ); + $workflow->expects( $this->any() ) + ->method( 'getArticleTitle' ) + ->will( $this->returnValue( $title ) ); + return $workflow; + } + + protected function mockRevision( $changeType, UUID $revId, UUID $postId = null ) { + if ( $postId ) { + $revision = $this->getMock( 'Flow\\Model\\PostRevision' ); + } else { + $revision = $this->getMock( 'Flow\\Model\\Header' ); + } + $revision->expects( $this->any() ) + ->method( 'getChangeType' ) + ->will( $this->returnValue( $changeType ) ); + $revision->expects( $this->any() ) + ->method( 'getRevisionId' ) + ->will( $this->returnValue( $revId ) ); + if ( $postId ) { + $revision->expects( $this->any() ) + ->method( 'getPostId' ) + ->will( $this->returnValue( $postId ) ); + } + return $revision; + } + + protected function createFormatter( $class ) { + $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' ) + ->disableOriginalConstructor() + ->getMock(); + $permissions->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $permissions->expects( $this->any() ) + ->method( 'getActions' ) + ->will( $this->returnValue( Container::get( 'flow_actions' ) ) ); + + $templating = $this->getMockBuilder( 'Flow\Templating' ) + ->disableOriginalConstructor() + ->getMock(); + $occupier = $this->getMockBuilder( 'Flow\OccupationController' ) + ->disableOriginalConstructor() + ->getMock(); + $urlGenerator = new UrlGenerator( $occupier ); + $templating->expects( $this->any() ) + ->method( 'getUrlGenerator' ) + ->will( $this->returnValue( $urlGenerator ) ); + + $usernames = $this->getMockBuilder( 'Flow\Repository\UserNameBatch' ) + ->disableOriginalConstructor() + ->getMock(); + + global $wgFlowMaxThreadingDepth; + $serializer = new RevisionFormatter( $permissions, $templating, $usernames, $wgFlowMaxThreadingDepth ); + + return new $class( $permissions, $serializer ); + } + + protected function dataToString( $data ) { + foreach ( $data as $key => $value ) { + if ( $value instanceof UUID ) { + $data[$key] = "UUID: " . $value->getAlphadecimal(); + } + } + return parent::dataToString( $data ); + } +} diff --git a/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php b/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php new file mode 100644 index 00000000..34bba91b --- /dev/null +++ b/Flow/tests/phpunit/Formatter/RevisionFormatterTest.php @@ -0,0 +1,165 @@ +<?php + +namespace Flow\Tests\Formatter; + +use Flow\FlowActions; +use Flow\Formatter\FormatterRow; +use Flow\Formatter\RevisionFormatter; +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use RequestContext; +use Title; +use User; + +/** + * @group Flow + */ +class RevisionFormatterTest extends \MediaWikiTestCase { + protected $user; + + public function setUp() { + parent::setUp(); + $this->user = User::newFromName( '127.0.0.1', false ); + } + + public function testMockFormatterBasicallyWorks() { + list( $formatter, $ctx ) = $this->mockFormatter(); + $result = $formatter->formatApi( $this->generateRow( 'my new topic' ), $ctx ); + $this->assertEquals( 'new-post', $result['changeType'] ); + $this->assertEquals( 'my new topic', $result['content']['content'] ); + } + + public function testFormattingEditedTitle() { + list( $formatter, $ctx ) = $this->mockFormatter(); + $row = $this->generateRow(); + $row->previousRevision = $row->revision; + $row->revision = $row->revision->newNextRevision( + $this->user, + 'replacement content', + 'wikitext', + 'edit-title', + $row->workflow->getArticleTitle() + ); + $result = $formatter->formatApi( $row, $ctx ); + $this->assertEquals( 'edit-title', $result['changeType'] ); + $this->assertEquals( 'replacement content', $result['content']['content'] ); + } + + public function testFormattingContentLength() { + $content = 'something something'; + $nextContent = 'ברוכים הבאים לוויקיפדיה!'; + + list( $formatter, $ctx, $permissions, $templating, $usernames, $actions ) = $this->mockFormatter( true ); + + $row = $this->generateRow( $content ); + $result = $formatter->formatApi( $row, $ctx ); + $this->assertEquals( + strlen( $content ), + $result['size']['new'], + 'New topic content reported correctly' + ); + $this->assertEquals( + 0, + $result['size']['old'], + 'With no previous revision the old size is 0' + ); + + $row->previousRevision = $row->revision; + // @todo newNextRevision feels too generic, there should be an editTitle method? + $row->revision = $row->currentRevision = $row->revision->newNextRevision( + $this->user, + $nextContent, + 'wikitext', + 'edit-title', + $row->workflow->getArticleTitle() + ); + $result = $formatter->formatApi( $row, $ctx ); + $this->assertEquals( + mb_strlen( $nextContent ), + $result['size']['new'], + 'After editing topic content the new size has been updated' + ); + $this->assertEquals( + mb_strlen( $content ), + $result['size']['old'], + 'After editing topic content the old size has been updated' + ); + } + + public function generateRow( $plaintext = 'titlebar content' ) { + $row = new FormatterRow; + $row->workflow = Workflow::create( 'topic', Title::newMainPage() ); + $row->rootPost = PostRevision::create( $row->workflow, $this->user, $plaintext, 'wikitext' ); + $row->revision = $row->currentRevision = $row->rootPost; + + return $row; + } + + protected function mockActions() { + return $this->getMockBuilder( 'Flow\FlowActions' ) + ->disableOriginalConstructor() + ->getMock(); + } + + protected function mockPermissions( FlowActions $actions ) { + $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' ) + ->disableOriginalConstructor() + ->getMock(); + // bit of a code smell, should pass actions directly in constructor? + $permissions->expects( $this->any() ) + ->method( 'getActions' ) + ->will( $this->returnValue( $actions ) ); + // perhaps another code smell, should have a method that does whatever this + // uses the user for + $permissions->expects( $this->any() ) + ->method( 'getUser' ) + ->will( $this->returnValue( $this->user ) ); + + return $permissions; + } + + protected function mockTemplating() { + $templating = $this->getMockBuilder( 'Flow\Templating' ) + ->disableOriginalConstructor() + ->getMock(); + $templating->expects( $this->any() ) + ->method( 'getModeratedRevision' ) + ->will( $this->returnArgument( 0 ) ); + $templating->expects( $this->any() ) + ->method( 'getContent' ) + ->will( $this->returnCallback( function( $revision, $contentFormat ) { + return $revision->getContent( $contentFormat ); + } ) ); + + return $templating; + } + + protected function mockUserNameBatch() { + return $this->getMockBuilder( 'Flow\Repository\UserNameBatch' ) + ->disableOriginalConstructor() + ->getMock(); + } + + // @todo name seems wrong, the Formatter is real everything else is mocked + public function mockFormatter( $returnAll = false ) { + $actions = $this->mockActions(); + $permissions = $this->mockPermissions( $actions ); + // formatting only proceedes when this is true + $permissions->expects( $this->any() ) + ->method( 'isAllowed' ) + ->will( $this->returnValue( true ) ); + $templating = $this->mockTemplating(); + $usernames = $this->mockUserNameBatch(); + $formatter = new RevisionFormatter( $permissions, $templating, $usernames, 3 ); + + $ctx = RequestContext::getMain(); + $ctx->setUser( $this->user ); + + + if ( $returnAll ) { + return array( $formatter, $ctx, $permissions, $templating, $usernames, $actions ); + } else { + return array( $formatter, $ctx ); + } + } +} diff --git a/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php b/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php new file mode 100644 index 00000000..8e8c778c --- /dev/null +++ b/Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php @@ -0,0 +1,121 @@ +<?php + +namespace Flow\Tests\Handlebars; + +use Flow\Container; +use Flow\TemplateHelper; +use LightnCandy; +use Symfony\Component\DomCrawler\Crawler; + +/** + * @group Flow + */ +class FlowPostMetaActionsTest extends \MediaWikiTestCase { + + /** + * The specific timestamps used inside are not anything + * in particular, they just match the post and last edit + * uuid's we use in the test. + */ + public function timestampEditedProvider() { + return array( + array( + 'never been edited', + // expected + '02:52, 1 October 2014', + // args + array( + 'isOriginalContent' => true, + 'author' => 'creator', + 'creator' => 'creator', + 'lastEditUser' => null, + ), + ), + + array( + 'last edited by post creator', + // expected + 'Edited 04:21, 9 October 2014', + // args + array( + 'isOriginalContent' => false, + 'author' => 'creator', + 'creator' => 'creator', + 'lastEditUser' => 'creator', + ), + ), + + array( + 'last edited by other than post creator', + // expected + 'Edited by author 04:21, 9 October 2014', + // args + array( + 'isOriginalContent' => false, + 'author' => 'author', + 'creator' => 'creator', + 'lastEditUser' => 'author', + ), + ), + + array( + 'most recent revision not a content edit', + // expected + 'Edited 04:21, 9 October 2014', + // args + array( + 'isOriginalContent' => false, + 'author' => 'author', + 'creator' => 'creator', + 'lastEditUser' => 'creator', + ), + ), + ); + } + + /** + * @dataProvider timestampEditedProvider + */ + public function testTimestampEdited( $message, $expect, $args ) { + if ( !class_exists( 'Symfony\Component\DomCrawler\Crawler' ) ) { + $this->markTestSkipped( 'DomCrawler component is not available.' ); + return; + } + + $crawler = $this->renderTemplate( + 'flow_post_meta_actions', + array( + 'actions' => array(), + 'postId' => 's3chebds95i0atkw', + 'lastEditId' => 's3ufwcms95i0atkw', + 'isOriginalContent' => $args['isOriginalContent'], + 'author' => array( + 'name' => $args['author'], + ), + 'creator' => array( + 'name' => $args['creator'], + ), + 'lastEditUser' => array( + 'name' => $args['lastEditUser'], + ), + ) + ); + + $text = $crawler->filter( '.flow-post-timestamp' )->text(); + // normalize whitespace + $text = trim( preg_replace( '/\s+/', ' ', $text ) ); + $this->assertStringStartsWith( $expect, $text, $message ); + } + + protected function renderTemplate( $templateName, array $args = array() ) { + $lc = Container::get( 'lightncandy' ); + $filenames = $lc->getTemplateFilenames( $templateName ); + $phpCode = $lc::compile( + file_get_contents( $filenames['template'] ), + Container::get( 'lightncandy.template_dir' ) + ); + $renderer = LightnCandy::prepare( $phpCode ); + + return new Crawler( $renderer( $args ) ); + } +} diff --git a/Flow/tests/phpunit/HookTest.php b/Flow/tests/phpunit/HookTest.php new file mode 100644 index 00000000..cfc2c913 --- /dev/null +++ b/Flow/tests/phpunit/HookTest.php @@ -0,0 +1,206 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; +use Flow\Data\Listener\RecentChangesListener; +use Flow\Model\Header; +use Flow\Model\PostRevision; +use Flow\Model\TopicListEntry; +use Flow\Model\Workflow; +use FlowHooks; +use RecentChange; +use Title; +use User; + +/** + * @group Flow + */ +class HookTest extends \MediaWikiTestCase { + protected $tablesUsed = array( + 'flow_revision', + 'flow_topic_list', + 'flow_tree_node', + 'flow_tree_revision', + 'flow_workflow', + ); + + static public function onIRCLineURLProvider() { + $user = User::newFromName( '127.0.0.1', false ); + $title = Title::newMainPage(); + + // data providers do not run in the same context as the actual test, as such we + // can't create Title objects because they can have the wrong wikiID. Instead we + // pass closures into the test that create the objects within the correct context. + $newHeader = function() use( $user ) { + $workflow = Workflow::create( 'discussion', Title::newMainPage() ); + $header = Header::create( $workflow, $user, 'header content', 'wikitext' ); + $metadata = array( + 'workflow' => $workflow, + 'revision' => $header, + ); + + Container::get( 'storage' )->put( $workflow, $metadata ); + + return $metadata; + }; + $freshTopic = function() use( $user ) { + $boardWorkflow = Workflow::create( 'discussion', Title::newMainPage() ); + $topicWorkflow = Workflow::create( 'topic', $boardWorkflow->getArticleTitle() ); + $topicList = TopicListEntry::create( $boardWorkflow, $topicWorkflow ); + $topicTitle = PostRevision::create( $topicWorkflow, $user, 'some content', 'wikitext' ); + $metadata = array( + 'workflow' => $topicWorkflow, + 'board-workflow' => $boardWorkflow, + 'topic-title' => $topicTitle, + + 'revision' => $topicTitle, + ); + + $storage = Container::get( 'storage' ); + $storage->put( $topicWorkflow, $metadata ); + $storage->put( $boardWorkflow, $metadata ); + $storage->put( $topicList, $metadata ); + $storage->put( $topicTitle, $metadata ); + + return $metadata; + }; + $replyToTopic = function() use( $freshTopic, $user ) { + $metadata = $freshTopic(); + $firstPost = $metadata['topic-title']->reply( $metadata['workflow'], $user, 'ffuts dna ylper', 'wikitext' ); + $metadata = array( + 'first-post' => $firstPost, + + 'revision' => $firstPost, + ) + $metadata; + + Container::get( 'storage.post' )->put( $firstPost, $metadata ); + + return $metadata; + }; + + return array( + array( + // test message + 'Freshly created topic', + // flow-workflow-change attribute within rc_params + $freshTopic, + // expected query parameters + array( + 'action' => 'history', + ), + ), + + array( + 'Reply to topic', + $replyToTopic, + array( + 'action' => 'history', + ), + ), + + array( + 'Edit topic title', + function() use( $freshTopic, $user, $title ) { + $metadata = $freshTopic(); + + return array( + 'revision' => $metadata['revision']->newNextRevision( $user, 'gnihtemos gnihtemos', 'wikitext', 'edit-title', $title ), + ) + $metadata; + }, + array( + 'action' => 'compare-post-revisions', + ), + ), + + array( + 'Edit post', + function() use( $replyToTopic, $user, $title ) { + $metadata = $replyToTopic(); + return array( + 'revision' => $metadata['revision']->newNextRevision( $user, 'IT\'S CAPS LOCKS DAY!', 'wikitext', 'edit-post', $title ), + ) + $metadata; + }, + array( + 'action' => 'compare-post-revisions', + ), + ), + + array( + 'Edit board header', + function() use ( $newHeader, $user, $title ) { + $metadata = $newHeader(); + return array( + 'revision' => $metadata['revision']->newNextRevision( $user, 'STILL CAPS LOCKS DAY!', 'wikitext', 'edit-header', $title ), + ) + $metadata; + }, + array( + 'action' => 'compare-header-revisions', + ), + ), + + array( + 'Moderate a post', + function() use ( $replyToTopic, $user ) { + $metadata = $replyToTopic(); + return array( + 'revision' => $metadata['revision']->moderate( + $user, + $metadata['revision']::MODERATED_DELETED, + 'delete-post', + 'something about cruise control' + ), + ) + $metadata; + }, + array( + 'action' => 'history', + ), + ), + + array( + 'Moderate a topic', + function() use ( $freshTopic, $user ) { + $metadata = $freshTopic(); + return array( + 'revision' => $metadata['revision']->moderate( + $user, + $metadata['revision']::MODERATED_HIDDEN, + 'hide-topic', + 'adorable kittens' + ), + ) + $metadata; + }, + array( + 'action' => 'history', + ), + ), + ); + } + + /** + * @dataProvider onIRCLineUrlProvider + */ + public function testOnIRCLineUrl( $message, $metadataGen, $expectedQuery ) { + $rc = new RecentChange; + $rc->mAttribs = array( + 'rc_namespace' => 0, + 'rc_title' => 'Main Page', + 'rc_source' => RecentChangesListener::SRC_FLOW, + ); + $metadata = $metadataGen(); + Container::get( 'formatter.irclineurl' )->associate( $rc, $metadata ); + + $url = 'unset'; + $query = 'unset'; + $this->assertTrue( FlowHooks::onIRCLineURL( $url, $query, $rc ) ); + $expectedQuery['title'] = $metadata['workflow']->getArticleTitle()->getPrefixedDBkey(); + + $parts = parse_url( $url ); + $this->assertArrayHasKey( 'query', $parts, $url ); + parse_str( $parts['query'], $queryParts ); + foreach ( $expectedQuery as $key => $value ) { + $this->assertEquals( $value, $queryParts[$key], "Query part $key" ); + } + $this->assertEquals( '', $query, $message ); + } +} diff --git a/Flow/tests/phpunit/Import/ConverterTest.php b/Flow/tests/phpunit/Import/ConverterTest.php new file mode 100644 index 00000000..e5cbb07d --- /dev/null +++ b/Flow/tests/phpunit/Import/ConverterTest.php @@ -0,0 +1,101 @@ +<?php + +namespace Flow\Tests\Import; + +use DatabaseBase; +use Flow\Import\Converter; +use Flow\Import\IConversionStrategy; +use Flow\Import\Importer; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Title; +use User; + +/** + * @group Flow + */ +class ConverterTest extends \MediaWikiTestCase { + public function testConstruction() { + $this->assertInstanceOf( + 'Flow\Import\Converter', + $this->createConverter() + ); + } + + public function decideArchiveTitleProvider() { + return array( + array( + 'Selects the first pattern if n=1 does exist', + // expect + 'Talk:Flow/Archive 1', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d', '%s/Archive%d' ), + // existing titles + array(), + ), + + array( + 'Selects n=2 when n=1 exists', + // expect + 'Talk:Flow/Archive 2', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d' ), + // existing titles + array( 'Talk:Flow/Archive 1' ), + ), + + array( + 'Selects the second pattern if n=1 exists', + // expect + 'Talk:Flow/Archive2', + // source title + Title::newFromText( 'Talk:Flow' ), + // formats + array( '%s/Archive %d', '%s/Archive%d' ), + // existing titles + array( 'Talk:Flow/Archive1' ), + ), + ); + } + /** + * @dataProvider decideArchiveTitleProvider + */ + public function testDecideArchiveTitle( $message, $expect, Title $source, array $formats, array $exists ) { + // flip so we can use isset + $existsByKey = array_flip( $exists ); + + $titleRepo = $this->getMock( 'Flow\Repository\TitleRepository' ); + $titleRepo->expects( $this->any() ) + ->method( 'exists' ) + ->will( $this->returnCallback( function( Title $title ) use ( $existsByKey ) { + return isset( $existsByKey[$title->getPrefixedText()] ); + } ) ); + + $result = Converter::decideArchiveTitle( $source, $formats, $titleRepo ); + $this->assertEquals( $expect, $result, $message ); + } + + protected function createConverter( + DatabaseBase $dbr = null, + Importer $importer = null, + LoggerInterface $logger = null, + User $user = null, + IConversionStrategy $strategy = null + ) { + return new Converter( + $dbr ?: wfGetDB( DB_SLAVE ), + $importer ?: $this->getMockBuilder( 'Flow\Import\Importer' ) + ->disableOriginalConstructor() + ->getMock(), + $logger ?: new NullLogger, + $user ?: User::newFromId( 1 ), + $strategy ?: $this->getMockBuilder( 'Flow\Import\IConversionStrategy' ) + ->disableOriginalConstructor() + ->getMock() + ); + } +} diff --git a/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php b/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php new file mode 100644 index 00000000..798c1812 --- /dev/null +++ b/Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php @@ -0,0 +1,33 @@ +<?php + +namespace Flow\Tests\Import; + +use Flow\Import\HistoricalUIDGenerator; +use Flow\Model\UUID; + +/** + * @group Flow + */ +class HistoricalUIDGeneratorTest extends \MediaWikiTestCase { + + public function roundTripProvider() { + $now = time(); + + return array( + array( $now - 86400 ), + array( $now - ( 365 * 86400 ) ), + ); + } + + /** + * @dataProvider roundTripProvider + */ + public function testRoundTrip( $timestamp ) { + $timestamp = wfTimestamp( TS_UNIX, $timestamp ); + $uid = HistoricalUIDGenerator::historicalTimestampedUID88( $timestamp ); + $uuid = UUID::create( $uid ); + + $returned = $uuid->getTimestampObj()->getTimestamp( TS_UNIX ); + $this->assertEquals( $timestamp, $returned ); + } +} diff --git a/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php b/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php new file mode 100644 index 00000000..8b861c87 --- /dev/null +++ b/Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php @@ -0,0 +1,157 @@ +<?php + +namespace Flow\Tests\Import\LiquidThreadsApi; + +use DatabaseBase; +use DateTime; +use DateTimeZone; +use Flow\Import\ImportSourceStore; +use Flow\Import\NullImportSourceStore; +use Flow\Import\LiquidThreadsApi\ConversionStrategy; +use Flow\Import\LiquidThreadsApi\ApiBackend; +use Title; +use WikitextContent; + +/** + * @group Flow + */ +class ConversionStrategyTest extends \MediaWikiTestCase { + + public function testCanConstruct() { + $this->assertInstanceOf( + 'Flow\Import\LiquidThreadsApi\ConversionStrategy', + $this->createStrategy() + ); + } + + public function testGeneratesMoveComment() { + $from = Title::newFromText( 'Talk:Blue_birds' ); + $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' ); + $this->assertGreaterThan( + 1, + strlen( $this->createStrategy()->getMoveComment( $from, $to ) ) + ); + } + + public function testGeneratesCleanupComment() { + $from = Title::newFromText( 'Talk:Blue_birds' ); + $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' ); + $this->assertGreaterThan( + 1, + strlen( $this->createStrategy()->getCleanupComment( $from, $to ) ) + ); + } + + public function testCreatesValidImportSource() { + $this->assertInstanceOf( + 'Flow\Import\IImportSource', + $this->createStrategy()->createImportSource( Title::newFromText( 'Talk:Blue_birds' ) ) + ); + } + + public function testReturnsValidSourceStore() { + $this->assertInstanceOf( + 'Flow\Import\ImportSourceStore', + $this->createStrategy()->getSourceStore() + ); + } + + public function testDecidesArchiveTitle() { + // we don't have control of the Title::exists() calls that are made here, + // so just assume the page doesn't exist and we get format = 0 n = 1 + $this->assertEquals( + 'Talk:Blue birds/LQT Archive 1', + $this->createStrategy() + ->decideArchiveTitle( Title::newFromText( 'Talk:Blue_birds' ) ) + ->getPrefixedText() + ); + } + + public function provideArchiveCleanupRevisionContent() { + // @todo superm401 suggested finding library that lets us control time during tests, + // would probably be better + $now = new DateTime( "now", new DateTimeZone( "GMT" ) ); + $date = $now->format( 'Y-m-d' ); + + return array( + array( + 'Blank input page', + // expect + "\n\n{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}}", + // input content + '', + ), + array( + 'Page containing lqt magic word', + // expect + "\n\n{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}}", + // input content + '{{#useliquidthreads:1}}', + ), + + array( + 'Page containing some stuff and the lqt magic word', + // expect + <<<EOD +Four score and seven years ago our fathers brought forth +on this continent, a new nation, conceived in Liberty, and +dedicated to the proposition that all men are created equal. + + +{{Archive for converted LQT page|from=Talk:Blue birds|date=$date}} +EOD + , + // input content + <<<EOD +Four score and seven years ago our fathers brought forth +on this continent, a new nation, conceived in Liberty, and +dedicated to the proposition that all men are created equal. +{{#useliquidthreads: + 1 +}} +EOD + ), + ); + } + + /** + * @dataProvider provideArchiveCleanupRevisionContent + * @param string $content + */ + public function testCreateArchiveCleanupRevisionContent( $message, $expect, $content ) { + $result = $this->createStrategy()->createArchiveCleanupRevisionContent( + new WikitextContent( $content ), + Title::newFromText( 'Talk:Blue_birds' ) + ); + if ( $result !== null ) { + $this->assertInstanceOf( 'WikitextContent', $result ); + } + $this->assertEquals( $expect, $result->getNativeData(), $message ); + } + + public function testGetPostprocessor() { + $result = $this->createStrategy()->getPostprocessor(); + + $this->assertEquals( get_class( $result ), 'Flow\Import\Postprocessor\LqtRedirector'); + } + + protected function createStrategy( + DatabaseBase $dbr = null, + ImportSourceStore $sourceStore = null, + ApiBackend $api = null + ) { + return new ConversionStrategy( + $dbr ?: wfGetDB( DB_SLAVE ), + $sourceStore ?: new NullImportSourceStore, + $api ?: $this->getMockBuilder( 'Flow\Import\LiquidThreadsApi\ApiBackend' ) + ->disableOriginalConstructor() + ->getMock(), + $this->getMockBuilder( 'Flow\UrlGenerator' ) + ->disableOriginalConstructor() + ->getMock(), + $this->getMockBuilder( 'User' ) + ->disableOriginalConstructor() + ->getMock() + ); + } +} diff --git a/Flow/tests/phpunit/Import/PageImportStateTest.php b/Flow/tests/phpunit/Import/PageImportStateTest.php new file mode 100644 index 00000000..bceaf55d --- /dev/null +++ b/Flow/tests/phpunit/Import/PageImportStateTest.php @@ -0,0 +1,116 @@ +<?php + +namespace Flow\Tests\Import; + +use Flow\Import\NullImportSourceStore; +use Flow\Import\PageImportState; +use Flow\Import\Postprocessor\ProcessorGroup; +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Psr\Log\NullLogger; +use SplQueue; +use Title; +use User; + +/** + * @group Flow + */ +class PageImportStateTest extends \MediaWikiTestCase { + + protected function createState( $returnAll = false ) { + $storage = $this->getMockBuilder( 'Flow\Data\ManagerGroup' ) + ->disableOriginalConstructor() + ->getMock(); + + $workflow = Workflow::create( + 'discussion', + Title::newMainPage() + ); + + $state = new PageImportState( + $workflow, + $storage, + new NullImportSourceStore(), + new NullLogger(), + $this->getMockBuilder( 'Flow\Data\BufferedCache' ) + ->disableOriginalConstructor() + ->getMock(), + $this->getMockBuilder( 'Flow\DbFactory' ) + ->disableOriginalConstructor() + ->getMock(), + new ProcessorGroup, + new SplQueue + ); + if ( $returnAll ) { + return array( $state, $workflow, $storage ); + } else { + return $state; + } + } + + public function testGetTimestampIdReturnsUUID() { + $state = $this->createState(); + $this->assertInstanceOf( + 'Flow\Model\UUID', + $state->getTimestampId( time() - 123456 ), + 'PageImportState::getTimestampId must return a UUID object' + ); + } + + public function testSetsWorkflowIdByTimestamp() { + list( $state, $workflow ) = $this->createState( true ); + $now = time(); + $state->setWorkflowTimestamp( $workflow, $now - 123456 ); + $this->assertEquals( + $now - 123456, + $workflow->getId()->getTimestampObj()->getTimestamp( TS_UNIX ) + ); + } + + public function testSetsOnlyRevIdByTimestampForTopicTitle() { + $state = $this->createState(); + $topicWorkflow = Workflow::create( + 'topic', + Title::newMainPage() + ); + $topicTitle = PostRevision::create( + $topicWorkflow, + User::newFromName( '127.0.0.1', false ), + 'sing song', + 'wikitext' + ); + + $now = time(); + $state->setRevisionTimestamp( $topicTitle, $now - 54321 ); + $this->assertTrue( + $topicTitle->getPostId()->equals( $topicWorkflow->getId() ), + 'Topic title postId must still match workflow id' + ); + $this->assertEquals( + $now - 54321, + $topicTitle->getRevisionId()->getTimestampObj()->getTimestamp( TS_UNIX ) + ); + } + + public function testSetsRevIdAndPostIdForReplys() { + $state = $this->createState(); + $user = User::newFromName( '127.0.0.1', false ); + $title = Title::newMainPage(); + $topicWorkflow = Workflow::create( 'topic', $title ); + $topicTitle = PostRevision::create( $topicWorkflow, $user, 'sing song', 'wikitext' ); + $reply = $topicTitle->reply( $topicWorkflow, $user, 'fantastic!', 'wikitext' ); + + $now = time(); + + $state->setRevisionTimestamp( $reply, $now - 54321 ); + $this->assertEquals( + $now - 54321, + $reply->getRevisionId()->getTimestampObj()->getTimestamp( TS_UNIX ), + 'The first reply revision must have its revision id set appropriatly' + ); + $this->assertTrue( + $reply->getPostId()->equals( $reply->getRevisionId() ), + 'The first revision of a reply shares its postId and revId' + ); + } +} diff --git a/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php b/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php new file mode 100644 index 00000000..86a2fdbf --- /dev/null +++ b/Flow/tests/phpunit/Import/TalkpageImportOperationTest.php @@ -0,0 +1,164 @@ +<?php + +namespace Flow\Tests\Import; + +use Flow\Container; +use Flow\Import\NullImportSourceStore; +use Flow\Import\PageImportState; +use Flow\Import\Postprocessor\ProcessorGroup; +use Flow\Import\TalkpageImportOperation; +use Flow\Model\Header; +use Flow\Model\PostRevision; +use Flow\Model\PostSummary; +use Flow\Model\TopicListEntry; +use Flow\Model\Workflow; +use Flow\Tests\Mock\MockImportHeader; +use Flow\Tests\Mock\MockImportPost; +use Flow\Tests\Mock\MockImportRevision; +use Flow\Tests\Mock\MockImportSource; +use Flow\Tests\Mock\MockImportSummary; +use Flow\Tests\Mock\MockImportTopic; +use Psr\Log\NullLogger; +use SplQueue; +use Title; +use User; + +/** + * @group Flow + */ +class TalkpageImportOperationTest extends \MediaWikiTestCase { + + /** + * This is a horrible test, it basically runs the whole thing + * and sees if it falls over. + */ + public function testImportDoesntCompletelyFail() { + $workflow = Workflow::create( + 'discussion', + Title::newMainPage() + ); + $storage = $this->getMockBuilder( 'Flow\Data\ManagerGroup' ) + ->disableOriginalConstructor() + ->getMock(); + $stored = array(); + $storage->expects( $this->any() ) + ->method( 'put' ) + ->will( $this->returnCallback( function( $obj ) use( &$stored ) { + $stored[] = $obj; + } ) ); + $storage->expects( $this->any() ) + ->method( 'multiPut' ) + ->will( $this->returnCallback( function( $objs ) use( &$stored ) { + $stored = array_merge( $stored, $objs ); + } ) ); + + $now = time(); + $source = new MockImportSource( + new MockImportHeader( array( + // header revisions + new MockImportRevision( array( 'createdTimestamp' => $now ) ), + ) ), + array( + new MockImportTopic( + new MockImportSummary( array( + new MockImportRevision( array( 'createdTimestamp' => $now - 250 ) ), + ) ), + array( + // topic title revisions + new MockImportRevision( array( 'createdTimestamp' => $now - 1000 ) ), + ), + array( + //replies + new MockImportPost( + array( + // revisions + new MockImportRevision( array( 'createdTimestmap' => $now - 1000 ) ), + ), + array( + // replies + new MockImportPost( + array( + // revisions + new MockImportRevision( array( + 'createdTimestmap' => $now - 500, + 'user' => User::newFromNAme( '10.0.0.2', false ), + ) ), + ), + array( + // replies + ) + ), + ) + ), + ) + ) + ) + ); + + $op = new TalkpageImportOperation( $source, Container::get( 'occupation_controller' ) ); + $store = new NullImportSourceStore; + $op->import( new PageImportState( + $workflow, + $storage, + $store, + new NullLogger(), + $this->getMockBuilder( 'Flow\Data\BufferedCache' ) + ->disableOriginalConstructor() + ->getMock(), + Container::get( 'db.factory' ), + new ProcessorGroup, + new SplQueue + ) ); + + // Count what actually came through + $storedHeader = $storedDiscussion = $storedTopics = $storedTopicListEntry = $storedSummary = $storedPosts = 0; + foreach ( $stored as $obj ) { + if ( $obj instanceof Workflow ) { + if ( $obj->getType() === 'discussion' ) { + $this->assertSame( $workflow, $obj ); + $storedDiscussion++; + } else { + $alpha = $obj->getId()->getAlphadecimal(); + if ( !isset( $seenWorkflow[$alpha] ) ) { + $seenWorkflow[$alpha] = true; + $this->assertEquals( 'topic', $obj->getType() ); + $storedTopics++; + $topicWorkflow = $obj; + } + } + } elseif ( $obj instanceof PostSummary ) { + $storedSummary++; + } elseif ( $obj instanceof PostRevision ) { + $storedPosts++; + if ( $obj->isTopicTitle() ) { + $topicTitle = $obj; + } + } elseif ( $obj instanceof TopicListEntry ) { + $storedTopicListEntry++; + } elseif ( $obj instanceof Header ) { + $storedHeader++; + } else { + $this->fail( 'Unexpected object stored:' . get_class( $obj ) ); + } + } + + // Verify we wrote the expected objects to storage + + $this->assertEquals( 1, $storedHeader ); + + $this->assertEquals( 1, $storedDiscussion ); + $this->assertEquals( 1, $storedTopics ); + $this->assertEquals( 1, $storedTopicListEntry ); + $this->assertEquals( 1, $storedSummary ); + $this->assertEquals( 3, $storedPosts ); + + // This total expected number of insertions should match the sum of the left assertEquals parameters above. + $this->assertCount( 8, array_unique( array_map( 'spl_object_hash', $stored ) ) ); + + // Other special cases we need to check + $this->assertTrue( + $topicTitle->getPostId()->equals( $topicWorkflow->getId() ), + 'Root post id must match its workflow' + ); + } +} diff --git a/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php b/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php new file mode 100644 index 00000000..4208a352 --- /dev/null +++ b/Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php @@ -0,0 +1,96 @@ +<?php + +namespace Flow\Tests\Import\Wikitext; + +use DateTime; +use DateTimeZone; +use Flow\Import\ImportSourceStore; +use Flow\Import\NullImportSourceStore; +use Flow\Import\Wikitext\ConversionStrategy; +use Parser; +use Title; +use WikitextContent; + +/** + * @group Flow + */ +class ConversionStrategyTest extends \MediaWikiTestCase { + public function testCanConstruct() { + $this->assertInstanceOf( + 'Flow\Import\Wikitext\ConversionStrategy', + $this->createStrategy() + ); + } + + public function testGeneratesMoveComment() { + $from = Title::newFromText( 'Talk:Blue_birds' ); + $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' ); + $this->assertGreaterThan( + 1, + strlen( $this->createStrategy()->getMoveComment( $from, $to ) ) + ); + } + + public function testGeneratesCleanupComment() { + $from = Title::newFromText( 'Talk:Blue_birds' ); + $to = Title::newFromText( 'Talk:Blue_birds/Archive 4' ); + $this->assertGreaterThan( + 1, + strlen( $this->createStrategy()->getCleanupComment( $from, $to ) ) + ); + } + + public function testCreatesValidImportSource() { + $this->assertInstanceOf( + 'Flow\Import\IImportSource', + $this->createStrategy()->createImportSource( Title::newFromText( 'Talk:Blue_birds' ) ) + ); + } + + public function testReturnsValidSourceStore() { + $this->assertInstanceOf( + 'Flow\Import\ImportSourceStore', + $this->createStrategy()->getSourceStore() + ); + } + + public function testDecidesArchiveTitle() { + // we don't have control of the Title::exists() calls that are made here, + // so just assume the page doesn't exist and we get format = 0 n = 1 + $this->assertEquals( + 'Talk:Blue birds/Archive 1', + $this->createStrategy() + ->decideArchiveTitle( Title::newFromText( 'Talk:Blue_birds' ) ) + ->getPrefixedText() + ); + } + + public function testCreateArchiveCleanupRevisionContent() { + // @todo superm401 suggested finding library that lets us control time during tests, + // would probably be better + $now = new DateTime( "now", new DateTimeZone( "GMT" ) ); + $date = $now->format( 'Y-m-d' ); + + $result = $this->createStrategy()->createArchiveCleanupRevisionContent( + new WikitextContent( "Four score and..." ), + Title::newFromText( 'Talk:Blue_birds' ) + ); + $this->assertInstanceOf( 'WikitextContent', $result ); + $this->assertEquals( + "{{Archive for converted wikitext talk page|from=Talk:Blue birds|date=$date}}\n\nFour score and...", + $result->getNativeData() + ); + } + + protected function createStrategy( + Parser $parser = null, + ImportSourceStore $sourceStore = null + ) { + global $wgParser; + + return new ConversionStrategy( + $parser ?: $wgParser, + $sourceStore ?: new NullImportSourceStore + ); + } +} diff --git a/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php b/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php new file mode 100644 index 00000000..3f6571a8 --- /dev/null +++ b/Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php @@ -0,0 +1,50 @@ +<?php + +namespace Flow\Tests\Import\Wikitext; + +use DateTime; +use DateTimeZone; +use Flow\Import\Wikitext\ImportSource; +use Parser; +use Title; +use WikiPage; +use WikitextContent; + +/** + * @group Flow + * @group Database + */ +class ImportSourceTest extends \MediaWikiTestCase { + + protected $tablesUsed = array( 'page', 'revision' ); + + public function testGetHeader() { + $now = new DateTime( "now", new DateTimeZone( "GMT" ) ); + $date = $now->format( 'Y-m-d' ); + + // create a page with some content + $status = WikiPage::factory( Title::newMainPage() ) + ->doEditContent( + new WikitextContent( "This is some content\n" ), + "and an edit summary" + ); + if ( !$status->isGood() ) { + $this->fail( $status->getMessage()->plain() ); + } + + $source = new ImportSource( Title::newMainPage(), new Parser ); + $header = $source->getHeader(); + $this->assertNotNull( $header ); + $this->assertGreaterThan( 1, strlen( $header->getObjectKey() ) ); + + $revisions = iterator_to_array( $header->getRevisions() ); + $this->assertCount( 1, $revisions ); + + $revision = reset( $revisions ); + $this->assertInstanceOf( 'Flow\Import\IObjectRevision', $revision ); + $this->assertEquals( + "This is some content\n\n{{Wikitext talk page converted to Flow|archive=Main Page|date=$date}}", + $revision->getText() + ); + } +} diff --git a/Flow/tests/phpunit/LinksTableTest.php b/Flow/tests/phpunit/LinksTableTest.php new file mode 100644 index 00000000..afdaaade --- /dev/null +++ b/Flow/tests/phpunit/LinksTableTest.php @@ -0,0 +1,473 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; +use Flow\Data\ManagerGroup; +use Flow\Data\Listener\ReferenceRecorder; +use Flow\Exception\WikitextException; +use Flow\LinksTableUpdater; +use Flow\Model\AbstractRevision; +use Flow\Model\Workflow; +use Flow\Parsoid\ReferenceExtractor; +use Flow\Parsoid\ReferenceFactory; +use Flow\Parsoid\Utils; +use ParserOutput; +use Title; + +/** + * @group Flow + * @group Database + */ +class LinksTableTest extends PostRevisionTestCase { + /** + * @var array + */ + protected $tablesUsed = array( 'flow_ext_ref', 'flow_wiki_ref', 'flow_revision', 'flow_tree_revision', 'flow_workflow' ); + + /** + * @var ManagerGroup + */ + protected $storage; + + /** + * @var ReferenceExtractor + */ + protected $extractor; + + /** + * @var ReferenceRecorder + */ + protected $recorder; + + /** + * @var LinksTableUpdater + */ + protected $updater; + + public function setUp() { + parent::setUp(); + $this->storage = Container::get( 'storage' ); + $this->extractor = Container::get( 'reference.extractor' ); + $this->recorder = Container::get( 'reference.recorder' ); + $this->updater = Container::get( 'reference.updater.links-tables' ); + + // Check for Parsoid + try { + Utils::convert( 'html', 'wikitext', 'Foo', $this->workflow->getOwnerTitle() ); + } catch ( WikitextException $excep ) { + $this->markTestSkipped( 'Parsoid not enabled' ); + } + + // These tests don't provide sufficient data to properly run all listeners + $this->clearExtraLifecycleHandlers(); + } + + protected function generatePost( $overrides ) { + $parentRevision = $this->generateObject(); + + $revision = $this->generateObject( $overrides + array( + 'tree_parent_id' => $parentRevision->getRevisionId(), + ) ); + + return $revision; + } + + protected static function getTestTitle() { + return Title::newFromText( 'UTPage' ); + } + + public static function provideGetReferencesFromRevisionContent() { + return array( + array( + '[[Foo]]', + array( + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Foo', + ), + ), + ), + array( + '[http://www.google.com Foo]', + array( + array( + 'factoryMethod' => 'createUrlReference', + 'refType' => 'link', + 'value' => 'http://www.google.com', + ), + ), + ), + array( + '[[File:Foo.jpg]]', + array( + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'file', + 'value' => 'File:Foo.jpg', + ), + ), + ), + array( + '{{Foo}}', + array( + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'template', + 'value' => 'Template:Foo', + ), + ), + ), + array( + '{{Foo}} [[Foo]] [[File:Foo.jpg]] {{Foo}} [[Bar]]', + array( + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'template', + 'value' => 'Template:Foo', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Foo', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'file', + 'value' => 'File:Foo.jpg', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Bar', + ), + ), + ) + ); + } + + /** + * @dataProvider provideGetReferencesFromRevisionContent + */ + public function testGetReferencesFromRevisionContent( $content, $expectedReferences ) { + $content = Utils::convert( 'wikitext', 'html', $content, $this->workflow->getOwnerTitle() ); + $revision = $this->generatePost( array( 'rev_content' => $content ) ); + + $expectedReferences = $this->expandReferences( $this->workflow, $revision, $expectedReferences ); + + $foundReferences = $this->recorder->getReferencesFromRevisionContent( $this->workflow, $revision ); + + $this->assertReferenceListsEqual( $expectedReferences, $foundReferences ); + } + + /** + * @dataProvider provideGetReferencesFromRevisionContent + */ + public function testGetReferencesAfterRevisionInsert( $content, $expectedReferences ) { + $content = Utils::convert( 'wikitext', 'html', $content, $this->workflow->getOwnerTitle() ); + $revision = $this->generatePost( array( 'rev_content' => $content ) ); + + // Save to storage to test if ReferenceRecorder listener picks this up + $this->store( $revision ); + + $expectedReferences = $this->expandReferences( $this->workflow, $revision, $expectedReferences ); + + // References will be stored as linked from Topic:<id> + $title = Title::newFromText( $revision->getPostId()->getAlphadecimal(), NS_TOPIC ); + + // Retrieve references from storage + $foundReferences = $this->updater->getReferencesForTitle( $title ); + + $this->assertReferenceListsEqual( $expectedReferences, $foundReferences ); + } + + public static function provideGetExistingReferences() { + return array( /* list of test runs */ + array( /* list of arguments */ + array( /* list of references */ + array( /* list of parameters */ + 'factoryMethod' => 'createWikiReference', + 'refType' => 'template', + 'value' => 'Template:Foo', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Foo', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'file', + 'value' => 'File:Foo.jpg', + ), + array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Bar', + ), + ), + ), + ); + } + + /** + * @dataProvider provideGetExistingReferences + */ + public function testGetExistingReferences( array $references ) { + list( $workflow, $revision, $title ) = $this->getBlandTestObjects(); + + $references = $this->expandReferences( $workflow, $revision, $references ); + + $this->storage->multiPut( $references ); + + $foundReferences = $this->recorder + ->getExistingReferences( $revision->getRevisionType(), $revision->getCollectionId() ); + + $this->assertReferenceListsEqual( $references, $foundReferences ); + } + + public static function provideReferenceDiff() { + $references = self::getSampleReferences(); + + return array( + // Just adding a few + array( + array(), + array( + $references['fooLink'], + $references['barLink'] + ), + array( + $references['fooLink'], + $references['barLink'], + ), + array(), + ), + // Removing one + array( + array( + $references['fooLink'], + $references['barLink'] + ), + array( + $references['fooLink'], + ), + array( + ), + array( + $references['barLink'], + ), + ), + // Equality robustness + array( + array( + $references['fooLink'], + ), + array( + $references['FooLink'], + ), + array( + ), + array( + ), + array( // test is only valid if Foo and foo are same page + 'wgCapitalLinks' => true, + ) + ), + // Inequality robustness + array( + array( + $references['fooLink'], + ), + array( + $references['barLink'], + ), + array( + $references['barLink'], + ), + array( + $references['fooLink'], + ), + ), + ); + } + + /** + * @dataProvider provideReferenceDiff + */ + public function testReferenceDiff( $old, $new, $expectedAdded, $expectedRemoved, $globals = array() ) { + if ( $globals ) { + $this->setMwGlobals( $globals ); + } + list( $workflow, $revision, $title ) = $this->getBlandTestObjects(); + + foreach( array( 'old', 'new', 'expectedAdded', 'expectedRemoved' ) as $varName ) { + $$varName = $this->expandReferences( $workflow, $revision, $$varName ); + } + + list( $added, $removed ) = $this->recorder->referencesDifference( $old, $new ); + + $this->assertReferenceListsEqual( $added, $expectedAdded ); + $this->assertReferenceListsEqual( $removed, $expectedRemoved ); + } + + public static function provideMutateParserOutput() { + $references = self::getSampleReferences(); + + return array( + array( + array( // references + $references['fooLink'], + $references['fooTemplate'], + $references['googleLink'], + $references['fooImage'], + ), + array( + 'getLinks' => array( + NS_MAIN => array( 'Foo' => 0, ), + ), + 'getTemplates' => array( + NS_TEMPLATE => array( 'Foo' => 0, ), + ), + 'getImages' => array( + 'Foo.jpg' => true, + ), + 'getExternalLinks' => array( + 'http://www.google.com' => true, + ), + ), + ), + array( + array( + $references['subpageLink'], + ), + array( + 'getLinks' => array( + // NS_MAIN is the namespace of static::getTestTitle() + NS_MAIN => array( static::getTestTitle()->getDBkey() . '/Subpage' => 0, ) + ), + ), + ), + ); + } + + /** + * @dataProvider provideMutateParserOutput + */ + public function testMutateParserOutput( $references, $expectedItems ) { + list( $workflow, $revision, $title ) = $this->getBlandTestObjects(); + + /* + * Because the data provider is static, we can't access $this->workflow + * in there. Once of the things being tested is a subpage link. + * Thus, we would have to provide the correct namespace & title for + * $this->workflow->getArticleTitle(), under which the subpage will be + * created. + * Let's work around this by overwriting $workflow->title to a "known" + * value, so that we can hardcode that into the expected return value in + * the static provider. + */ + $title = static::getTestTitle(); + $reflectionWorkflow = new \ReflectionObject( $workflow ); + $reflectionProperty = $reflectionWorkflow->getProperty( 'title' ); + $reflectionProperty->setAccessible( true ); + $reflectionProperty->setValue( $workflow, $title ); + + $references = $this->expandReferences( $workflow, $revision, $references ); + $parserOutput = new \ParserOutput; + + // Clear the LinksUpdate to allow clean testing + foreach( array_keys( $expectedItems ) as $fieldName ) { + $parserOutput->$fieldName = array(); + } + + $this->updater->mutateParserOutput( $title, $parserOutput, $references ); + + foreach( $expectedItems as $method => $content ) { + $this->assertEquals( $content, $parserOutput->$method(), $method ); + } + } + + protected function getBlandTestObjects() { + return array( + /* workflow = */ $this->workflow, + /* revision = */ $this->revision, + /* title = */ $this->workflow->getArticleTitle(), + ); + } + + protected function expandReferences( Workflow $workflow, AbstractRevision $revision, array $references ) { + $referenceObjs = array(); + $factory = new ReferenceFactory( $workflow, $revision->getRevisionType(), $revision->getCollectionId() ); + + foreach( $references as $ref ) { + $referenceObjs[] = $factory->{$ref['factoryMethod']}( $ref['refType'], $ref['value'] ); + } + + return $referenceObjs; + } + + protected static function getSampleReferences() { + return array( + 'fooLink' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Foo', + ), + 'subpageLink' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => '/Subpage', + ), + 'FooLink' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'foo', + ), + 'barLink' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Bar', + ), + 'fooTemplate' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'template', + 'value' => 'Template:Foo', + ), + 'googleLink' => array( + 'factoryMethod' => 'createUrlReference', + 'refType' => 'link', + 'value' => 'http://www.google.com' + ), + 'fooImage' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'file', + 'value' => 'File:Foo.jpg', + ), + 'foreignFoo' => array( + 'factoryMethod' => 'createWikiReference', + 'refType' => 'link', + 'value' => 'Foo', + ), + ); + } + + protected function flattenReferenceList( $input ) { + $list = array(); + + foreach( $input as $reference ) { + $list[$reference->getUniqueIdentifier()] = $reference; + } + + ksort( $list ); + return array_keys( $list ); + } + + protected function assertReferenceListsEqual( $input1, $input2 ) { + $list1 = $this->flattenReferenceList( $input1 ); + $list2 = $this->flattenReferenceList( $input2 ); + + $this->assertEquals( $list1, $list2 ); + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportHeader.php b/Flow/tests/phpunit/Mock/MockImportHeader.php new file mode 100644 index 00000000..c451cce5 --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportHeader.php @@ -0,0 +1,34 @@ +<?php + +namespace Flow\Tests\Mock; + +use ArrayIterator; +use Flow\Import\IImportHeader; + +class MockImportHeader implements IImportHeader { + /** + * @var MockImportRevision + */ + protected $revisions; + + /** + * @param MockImportRevision[] $revisions + */ + public function __construct( array $revisions ) { + $this->revisions = $revisions; + } + + /** + * {@inheritDoc} + */ + public function getRevisions() { + return new ArrayIterator( $this->revisions ); + } + + /** + * {@inheritDoc} + */ + public function getObjectKey() { + return 'mock-header:1'; + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportPost.php b/Flow/tests/phpunit/Mock/MockImportPost.php new file mode 100644 index 00000000..f9e1a4c6 --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportPost.php @@ -0,0 +1,50 @@ +<?php + +namespace Flow\Tests\Mock; + +use ArrayIterator; +use Flow\Import\IImportPost; +use Flow\Import\IObjectRevision; +use User; + +class MockImportPost implements IImportPost { + /** + * @var IObjectRevision[] + */ + protected $revisions; + + /** + * @var IImportPost[] + */ + protected $replies; + + /** + * @param IObjectRevision[] $revisions + * @param IImportPost[] $replies + */ + public function __construct( array $revisions, array $replies ) { + $this->revisions = $revisions; + $this->replies = $replies; + } + + /** + * {@inheritDoc} + */ + public function getRevisions() { + return new ArrayIterator( $this->revisions ); + } + + /** + * {@inheritDoc} + */ + public function getReplies() { + return new ArrayIterator( $this->replies ); + } + + /** + * {@inheritDoc} + */ + public function getObjectKey() { + return 'mock-post:1'; + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportRevision.php b/Flow/tests/phpunit/Mock/MockImportRevision.php new file mode 100644 index 00000000..1a55a29c --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportRevision.php @@ -0,0 +1,52 @@ +<?php + +namespace Flow\Tests\Mock; + +use Flow\Import\IObjectRevision; +use User; + +class MockImportRevision implements IObjectRevision { + /** + * @var array + */ + protected $attribs; + + /** + * @param array $attribs + */ + public function __construct( array $attribs = array() ) { + $this->attribs = $attribs + array( + 'text' => 'dvorak', + 'timestamp' => time(), + 'author' => User::newFromName( '127.0.0.1', false ), + ); + } + + /** + * {@inheritDoc} + */ + public function getText() { + return $this->attribs['text']; + } + + /** + * {@inheritDoc} + */ + public function getTimestamp() { + return $this->attribs['timestamp']; + } + + /** + * {@inheritDoc} + */ + public function getAuthor() { + return $this->attribs['author']; + } + + /** + * {@inheritDoc} + */ + public function getObjectKey() { + return 'mock-revision:1'; + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportSource.php b/Flow/tests/phpunit/Mock/MockImportSource.php new file mode 100644 index 00000000..c9514675 --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportSource.php @@ -0,0 +1,42 @@ +<?php + +namespace Flow\Tests\Mock; + +use ArrayIterator; +use Flow\Import\IImportHeader; +use Flow\Import\IImportSource; + +class MockImportSource implements IImportSource { + /** + * @var IImportTopic[] + */ + protected $topics; + + /** + * @var IImportHeader|null $header + */ + protected $header; + + /** + * @param IImportHeader|null $header + * @param IImportTopic[] + */ + public function __construct( MockImportHeader $header = null, array $topics = array() ) { + $this->topics = $topics; + $this->header = $header; + } + + /** + * {@inheritDoc} + */ + public function getTopics() { + return new ArrayIterator( $this->topics ); + } + + /** + * {@inheritDoc} + */ + public function getHeader() { + return $this->header; + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportSummary.php b/Flow/tests/phpunit/Mock/MockImportSummary.php new file mode 100644 index 00000000..608183d9 --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportSummary.php @@ -0,0 +1,30 @@ +<?php + +namespace Flow\Tests\Mock; + +use ArrayIterator; +use Flow\Import\IObjectRevision; +use Flow\Import\IImportSummary; +use User; + +class MockImportSummary implements IImportSummary { + /** + * @var IObjectRevision[] + */ + protected $revisions; + + /** + * @param IObjectRevision[] $revisions + */ + public function __construct( array $revisions = array() ) { + $this->revisions = $revisions; + } + + public function getRevisions() { + return new ArrayIterator( $this->revisions ); + } + + public function getObjectKey() { + return 'mock-summary:1'; + } +} diff --git a/Flow/tests/phpunit/Mock/MockImportTopic.php b/Flow/tests/phpunit/Mock/MockImportTopic.php new file mode 100644 index 00000000..7ff79a75 --- /dev/null +++ b/Flow/tests/phpunit/Mock/MockImportTopic.php @@ -0,0 +1,52 @@ +<?php + +namespace Flow\Tests\Mock; + +use Flow\Import\IObjectRevision; +use Flow\Import\IImportSummary; +use Flow\Import\IImportTopic; + +class MockImportTopic extends MockImportPost implements IImportTopic { + /** + * @var IImportSummary + */ + protected $summary; + + /** + * @param IImportSummary $summary + * @param IObjectRevision[] $revisions + * @param IImportPost[] $replies + */ + public function __construct( IImportSummary $summary = null, array $revisions, array $replies ) { + parent::__construct( $revisions, $replies ); + $this->summary = $summary; + } + + /** + * {@inheritDoc} + */ + public function getTopicSummary() { + return $this->summary; + } + + /** + * {@inheritDoc} + */ + public function getLogType() { + "mock-flow-topic-import"; + } + + /** + * {@inheritDoc} + */ + public function getLogParameters() { + return array(); + } + + /** + * {@inheritDoc} + */ + public function getObjectKey() { + return 'mock-topic:1'; + } +} diff --git a/Flow/tests/phpunit/Model/PostRevisionTest.php b/Flow/tests/phpunit/Model/PostRevisionTest.php new file mode 100644 index 00000000..ce8849bc --- /dev/null +++ b/Flow/tests/phpunit/Model/PostRevisionTest.php @@ -0,0 +1,53 @@ +<?php + +namespace Flow\Tests\Model; + +use Flow\Model\PostRevision; +use Flow\Model\UUID; +use Flow\Model\Workflow; +use Flow\Tests\PostRevisionTestCase; +use User; +use Title; + +/** + * @group Flow + */ +class PostRevisionTest extends PostRevisionTestCase { + /** + * Tests that a PostRevision::fromStorageRow & ::toStorageRow roundtrip + * returns the same DB data. + */ + public function testRoundtrip() { + $row = $this->generateRow(); + $object = PostRevision::fromStorageRow( $row ); + + // toStorageRow will add a bogus column 'rev_content_url' - that's ok. + // It'll be caught in code to distinguish between external content and + // content to be saved in rev_content, and, before inserting into DB, + // it'll be unset. We'll ignore this column here. + $roundtripRow = PostRevision::toStorageRow( $object ); + unset( $roundtripRow['rev_content_url'] ); + + // Due to our desire to store alphadecimal values in cache and binary values on + // disk we need to perform uuid conversion before comparing + $roundtripRow = UUID::convertUUIDs( $roundtripRow, 'binary' ); + $this->assertEquals( $row, $roundtripRow ); + } + + public function testContentLength() { + $content = 'This is a topic title'; + $nextContent = 'Changed my mind'; + + $title = Title::newMainPage(); + $user = User::newFromName( '127.0.0.1', false ); + $workflow = Workflow::create( 'topic', $title ); + + $topic = PostRevision::create( $workflow, $user, $content, 'wikitext' ); + $this->assertEquals( 0, $topic->getPreviousContentLength() ); + $this->assertEquals( mb_strlen( $content ), $topic->getContentLength() ); + + $next = $topic->newNextRevision( $user, $nextContent, 'wikitext', 'edit-title', $title ); + $this->assertEquals( mb_strlen( $content ), $next->getPreviousContentLength() ); + $this->assertEquals( mb_strlen( $nextContent ), $next->getContentLength() ); + } +} diff --git a/Flow/tests/phpunit/Model/UUIDTest.php b/Flow/tests/phpunit/Model/UUIDTest.php new file mode 100644 index 00000000..d6ad604c --- /dev/null +++ b/Flow/tests/phpunit/Model/UUIDTest.php @@ -0,0 +1,161 @@ +<?php + +namespace Flow\Tests\Model; + +use Flow\Model\UUID; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + */ +class UUIDTest extends FlowTestCase { + + public function testFixesCapitalizedDataWhenUnserializing() { + $uuid = UUID::create(); + $serialized = serialize( $uuid ); + // We are targeting this portion of the serialized string: + // s:16:"s3xyjucl93jtq2ci" + $broken = preg_replace_callback( + '/(s:16:")([a-z0-9])/', + function( $matches ) { + return $matches[1] . strtoupper( $matches[2] ); + }, + $serialized + ); + $this->assertNotEquals( $broken, $serialized, 'Failed to create a broken uuid to test unserializing' ); + $fixed = unserialize( $broken ); + $this->assertTrue( $uuid->equals( $fixed ) ); + $this->assertEquals( $uuid->getAlphadecimal(), $fixed->getAlphadecimal() ); + } + + public function invalidInputProvider() { + $valid = UUID::create()->getAlphadecimal(); + + return array( + array( '' ), + array( strtoupper( $valid ) ), + array( strtoupper( UUID::alnum2hex( $valid ) ) ), + array( ucfirst( $valid ) ), + ); + } + + /** + * @dataProvider invalidInputProvider + * @expectedException Flow\Exception\InvalidInputException + */ + public function testInvalidInputOnCreate( $invalidInput ) { + UUID::create( $invalidInput ); + } + + static public function uuidConversionProvider() { + $dbr = wfGetDB( DB_SLAVE ); + + // sample uuid from UIDGenerator::newTimestampedUID128() + $numeric_128 = '6709199728898751234959525538795913762'; + $hex_128 = wfBaseConvert( $numeric_128, 10, 16, 32 ); + $bin_128 = $dbr->encodeBlob( pack( 'H*', $hex_128 ) ); + $pretty_128 = wfBaseConvert( $numeric_128, 10, 36 ); + + // Conversion from 128 bit to 88 bit takes the left + // most 88 bits. + $bits_88 = substr( wfBaseConvert( $numeric_128, 10, 2, 128 ), 0, 88 ); + $numeric_88 = wfBaseConvert( $bits_88, 2, 10 ); + $hex_88 = wfBaseConvert( $numeric_88, 10, 16, 22 ); + $bin_88 = $dbr->encodeBlob( pack( 'H*', $hex_88 ) ); + $pretty_88 = wfBaseConvert( $numeric_88, 10, 36 ); + + return array( + array( + '128 bit hex input must be truncated to 88bit output', + // input + $hex_128, + // binary + $bin_88, + // hex + $hex_88, + // base36 output + $pretty_88, + ), + + array( + '88 bit binary input', + // input + $bin_88, + // binary + $bin_88, + // hex + $hex_88, + // pretty + $pretty_88, + ), + + array( + '88 bit numeric input', + // input + $numeric_88, + // binary + $bin_88, + // hex + $hex_88, + // pretty + $pretty_88, + ), + + array( + '88 bit hex input', + // input + $hex_88, + // binary + $bin_88, + // hex + $hex_88, + // pretty + $pretty_88, + ), + + array( + '88 bit pretty input', + // input + $pretty_88, + // binary + $bin_88, + // hex + $hex_88, + // pretty + $pretty_88, + ), + + ); + } + + /** + * @dataProvider uuidConversionProvider + */ + public function testUUIDConversion( $msg, $input, $binary, $hex, $pretty ) { + $uuid = UUID::create( $input ); + + $this->assertEquals( $binary, $uuid->getBinary(), "Compare binary: $msg" ); + //$this->assertEquals( $hex, $uuid->getHex(), "Compare hex: $msg" ); + $this->assertEquals( $pretty, $uuid->getAlphadecimal(), "Compare pretty: $msg" ); + } + + static public function prettyProvider() { + return array( + // maximal base 36 value ( 2^88 ) + array( '12vwzoefjlykjgcnwf' ), + // current unpadded values from uidgenerator + array( 'rlnn1941hqtdtn8a' ), + ); + } + + /** + * @dataProvider prettyProvider + */ + public function testUnpaddedPrettyUuid( $uuid ) { + $this->assertEquals( $uuid, UUID::create( $uuid )->getAlphadecimal() ); + } + + public function testConversionToTimestamp() { + $this->assertEquals( '20150303221220', UUID::create( 'scv3pvbt40kcyy4g' )->getTimestamp() ); + } +} diff --git a/Flow/tests/phpunit/Model/UserTupleTest.php b/Flow/tests/phpunit/Model/UserTupleTest.php new file mode 100644 index 00000000..fc14453b --- /dev/null +++ b/Flow/tests/phpunit/Model/UserTupleTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Flow\Tests\Model; + +use Flow\Model\UserTuple; + +/** + * @group Flow + */ +class UserTupleTest extends \MediaWikiTestCase { + + public function invalidInputProvider() { + return array( + array( 'foo', 0, ''), + array( 'foo', 1234, '127.0.0.1' ), + array( '', 0, '127.0.0.1' ), + array( 'foo', -25, '' ), + array( 'foo', null, '127.0.0.1' ), + array( null, 55, '' ), + array( 'foo', 0, null ), + ); + } + + /** + * @dataProvider invalidInputProvider + * @expectedException Flow\Exception\InvalidDataException + */ + public function testInvalidInput( $wiki, $id, $ip ) { + new UserTuple( $wiki, $id, $ip ); + } + + public function validInputProvider() { + return array( + array( 'foo', 42, null ), + array( 'foo', 42, '' ), + array( 'foo', 0, '127.0.0.1' ), + array( 'foo', '0', '10.1.2.3' ), + ); + } + + /** + * @dataProvider validInputProvider + */ + public function testValidInput( $wiki, $id, $ip ) { + new UserTuple( $wiki, $id, $ip ); + // no error thrown from constructor + $this->assertTrue( true ); + } +} diff --git a/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php b/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php new file mode 100644 index 00000000..6ec70826 --- /dev/null +++ b/Flow/tests/phpunit/Notifications/NotifiedUsersTest.php @@ -0,0 +1,142 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Flow\NotificationController; +use EchoNotificationController; +use User; +use WatchedItem; + +/** + * @group Flow + */ +class NotifiedUsersTest extends PostRevisionTestCase { + public function setUp() { + parent::setUp(); + + if ( !class_exists( 'EchoEvent' ) ) { + $this->markTestSkipped(); + return; + } + } + public function testWatchingTopic() { + $data = $this->getTestData(); + if ( !$data ) { + $this->markTestSkipped(); + return; + } + + WatchedItem::fromUserTitle( $data['user'], $data['topicWorkflow']->getArticleTitle() )->addWatch(); + + $events = $data['notificationController']->notifyPostChange( 'flow-post-reply', + array( + 'topic-workflow' => $data['topicWorkflow'], + 'title' => $data['boardWorkflow']->getOwnerTitle(), + 'user' => $data['agent'], + 'reply-to' => $data['topic'], + 'topic-title' => $data['topic'], + 'revision' => $data['post'], + ) ); + + $this->assertNotifiedUser( $events, $data['user'], $data['agent'] ); + } + + public function testWatchingBoard() { + $data = $this->getTestData(); + if ( !$data ) { + $this->markTestSkipped(); + return; + } + + WatchedItem::fromUserTitle( $data['user'], $data['boardWorkflow']->getArticleTitle() )->addWatch(); + + $events = $data['notificationController']->notifyNewTopic( array( + 'board-workflow' => $data['boardWorkflow'], + 'topic-workflow' => $data['topicWorkflow'], + 'topic-title' => $data['topic'], + 'first-post' => $data['post'], + 'user' => $data['agent'], + ) ); + + $this->assertNotifiedUser( $events, $data['user'], $data['agent'] ); + } + + protected function assertNotifiedUser( array $events, User $notifiedUser, User $notNotifiedUser ) { + $users = array(); + foreach( $events as $event ) { + $iterator = EchoNotificationController::getUsersToNotifyForEvent( $event ); + foreach( $iterator as $user ) { + $users[] = $user; + } + } + + // convert user objects back into user ids to simplify assertion + $users = array_map( function( $user ) { return $user->getId(); }, $users ); + + $this->assertContains( $notifiedUser->getId(), $users ); + $this->assertNotContains( $notNotifiedUser->getId(), $users ); + } + + /** + * @return bool|array + * { + * False on failure, or array with these keys: + * + * @type Workflow $boardWorkflow + * @type Workflow $topicWorkflow + * @type PostRevision $post + * @type PostRevision $topic + * @type User $user + * @type User $agent + * @type NotificationController $notificationController + * } + */ + protected function getTestData() { + $this->generateWorkflowForPost(); + $topicWorkflow = $this->workflow; + $post = $this->generateObject( array(), array(), 1 ); + $topic = $this->generateObject( array(), array( $post ) ); + $user = User::newFromName( 'Flow Test User' ); + $user->addToDatabase(); + $agent = User::newFromName( 'Flow Test Agent' ); + $agent->addToDatabase(); + + $notificationController = Container::get( 'controller.notification' ); + + // The data of this global varaible is loaded into occupationListener + // even before the test starts, so modifying this global in setUP() + // won't have any effect on the occupationListener. The trick is to + // fake the workflow to have a title in the global varaible + global $wgFlowOccupyPages; + + $page = reset( $wgFlowOccupyPages ); + if ( !$page ) { + return false; + } + $title = \Title::newFromText( $page ); + if ( !$title ) { + return false; + } + $object = new \ReflectionObject( $topicWorkflow ); + $ownerTitle = $object->getProperty( 'ownerTitle' ); + $ownerTitle->setAccessible( true ); + $ownerTitle->setValue( $topicWorkflow, $title ); + + $boardWorkflow = Container::get( 'factory.loader.workflow' ) + ->createWorkflowLoader( $topicWorkflow->getOwnerTitle() ) + ->getWorkflow(); + + return array( + 'boardWorkflow' => $boardWorkflow, + 'topicWorkflow' => $topicWorkflow, + 'post' => $post, + 'topic' => $topic, + 'user' => $user, + 'agent' => $agent, + 'notificationController' => $notificationController, + ); + } +} diff --git a/Flow/tests/phpunit/PagerTest.php b/Flow/tests/phpunit/PagerTest.php new file mode 100644 index 00000000..b4a46298 --- /dev/null +++ b/Flow/tests/phpunit/PagerTest.php @@ -0,0 +1,105 @@ +<?php + +namespace Flow\Tests; + +use Flow\Data\Pager\PagerPage; +use Flow\Data\Pager\Pager; +use Flow\Model\UUID; + +/** + * @group Flow + */ +class PagerTest extends FlowTestCase { + + public function provideDataMakePagingLink() { + return array ( + array( + $this->mockStorage( + array( + $this->mockTopicListEntry(), + $this->mockTopicListEntry(), + $this->mockTopicListEntry() + ), + UUID::create(), + array( 'topic_id' ) + ), + array( 'topic_list_id' => '123456' ), + array( 'pager-limit' => 2, 'order' => 'desc', 'sort' => 'topic_id' ), + 'offset-id' + ), + array( + $this->mockStorage( + array( + $this->mockTopicListEntry(), + $this->mockTopicListEntry() + ), + UUID::create(), + array( 'workflow_last_update_timestamp' ) + ), + array( 'topic_list_id' => '123456' ), + array( 'pager-limit' => 1, 'order' => 'desc', 'sort' => 'workflow_last_update_timestamp', 'sortby' => 'updated' ), + 'offset' + ) + ); + } + + /** + * @dataProvider provideDataMakePagingLink + */ + public function testMakePagingLink( $storage, $query, $options, $offsetKey ) { + $pager = new Pager( $storage, $query, $options ); + $page = $pager->getPage(); + $pagingOption = $page->getPagingLinksOptions(); + foreach ( $pagingOption as $option ) { + $this->assertArrayHasKey( $offsetKey, $option ); + $this->assertArrayHasKey( 'offset-dir', $option ); + $this->assertArrayHasKey( 'limit', $option ); + if ( isset( $options['sortby'] ) ) { + $this->assertArrayHasKey( 'sortby', $option ); + } + } + } + + /** + * Mock the storage + */ + protected function mockStorage( $return, $offset, $sort ) { + $storage = $this->getMockBuilder( 'Flow\Data\ObjectManager' ) + ->disableOriginalConstructor() + ->getMock(); + $storage->expects( $this->any() ) + ->method( 'find' ) + ->will( $this->returnValue( $return ) ); + $storage->expects( $this->any() ) + ->method( 'serializeOffset' ) + ->will( $this->returnValue( $offset ) ); + $storage->expects( $this->any() ) + ->method( 'getIndexFor' ) + ->will( $this->returnValue( $this->mockIndex( $sort ) ) ); + return $storage; + } + + /** + * Mock TopicListEntry + */ + protected function mockTopicListEntry() { + $entry = $this->getMockBuilder( 'Flow\Model\TopicListEntry' ) + ->disableOriginalConstructor() + ->getMock(); + return $entry; + } + + /** + * Mock TopKIndex + */ + protected function mockIndex( $sort ) { + $index = $this->getMockBuilder( 'Flow\Data\Index\TopKIndex' ) + ->disableOriginalConstructor() + ->getMock(); + $index->expects( $this->any() ) + ->method( 'getSort' ) + ->will( $this->returnValue( $sort ) ); + return $index; + } + +} diff --git a/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php b/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php new file mode 100644 index 00000000..e9781ea4 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php @@ -0,0 +1,61 @@ +<?php + +namespace Flow\Tests\Parsoid; + +use Flow\Parsoid\Fixer\BadImageRemover; +use Flow\Parsoid\ContentFixer; +use Flow\Parsoid\Utils; +use Title; + +/** + * @group Flow + */ +class BadImageRemoverTest extends \MediaWikiTestCase { + + /** + * Note that this must return html rather than roundtripping wikitext + * through parsoid because that is not current available from the jenkins + * test runner/ + */ + public static function imageRemovalProvider() { + return array( + array( + 'Passes through allowed good images', + // expected html after filtering + '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>', + // input html + '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>', + // accept/decline callback + function() { return false; } + ), + + array( + 'Keeps unknown images', + // expected html after filtering + '<meta typeof="mw:Placeholder" data-parsoid="...">', + // input html + '<meta typeof="mw:Placeholder" data-parsoid="...">', + // accept/decline callback + function() { return true; } + ), + + array( + 'Strips declined images', + // expected html after filtering + '<p> and other stuff</p>', + // input html + '<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Image.jpg"><img resource="./File:Image.jpg" src="//upload.wikimedia.org/wikipedia/commons/7/78/Image.jpg" height="500" width="500"></a></span> and other stuff</p>', + // accept/decline callback + function() { return true; } + ), + ); + } + /** + * @dataProvider imageRemovalProvider + */ + public function testImageRemoval( $message, $expect, $content, $badImageFilter ) { + $fixer = new ContentFixer( new BadImageRemover( $badImageFilter ) ); + $result = $fixer->apply( $content, Title::newMainPage() ); + $this->assertEquals( $expect, $result, $message ); + } +} diff --git a/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php b/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php new file mode 100644 index 00000000..1f3119a1 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php @@ -0,0 +1,36 @@ +<?php + +namespace Flow\Tests\Parsoid; + +use Flow\Parsoid\Fixer\BaseHrefFixer; +use Flow\Parsoid\ContentFixer; +use Title; + +/** + * @group Flow + */ +class BaseHrefFixerTest extends \MediaWikiTestCase { + public function setUp() { + parent::setUp(); + $this->setMwGlobals( 'wgServer', 'http://mywiki' ); + } + + public static function baseHrefProvider() { + return array( + array( + 'Rewrites href of link surrounding image', + '<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid=\'{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"[[test]] caption"}],"dsr":[0,43,2,2]}\'><a href="http://mywiki/wiki/./File:Example.jpg" data-parsoid=\'{"a":{"href":"./File:Example.jpg"},"sa":{},"dsr":[2,null,null,null]}\'><img resource="./File:Example.jpg" src="//upload.wikimedia.org/wikipedia/mediawiki/thumb/a/a9/Example.jpg/220px-Example.jpg" data-parsoid=\'{"a":{"resource":"./File:Example.jpg","height":"147","width":"220"},"sa":{"resource":"File:example.jpg"}}\' height="147" width="220"></a><figcaption data-parsoid=\'{"dsr":[null,41,null,null]}\'> caption</figcaption></figure>', + '<figure class="mw-default-size" typeof="mw:Image/Thumb" data-parsoid=\'{"optList":[{"ck":"thumbnail","ak":"thumb"},{"ck":"caption","ak":"[[test]] caption"}],"dsr":[0,43,2,2]}\'><a href="./File:Example.jpg" data-parsoid=\'{"a":{"href":"./File:Example.jpg"},"sa":{},"dsr":[2,null,null,null]}\'><img resource="./File:Example.jpg" src="//upload.wikimedia.org/wikipedia/mediawiki/thumb/a/a9/Example.jpg/220px-Example.jpg" data-parsoid=\'{"a":{"resource":"./File:Example.jpg","height":"147","width":"220"},"sa":{"resource":"File:example.jpg"}}\' height="147" width="220"></a><figcaption data-parsoid=\'{"dsr":[null,41,null,null]}\'> caption</figcaption></figure>', + ), + ); + } + + /** + * @dataProvider baseHrefProvider + */ + public function testBaseHrefFixer( $message, $expectedAfter, $before ) { + $fixer = new ContentFixer( new BaseHrefFixer( '/wiki/$1' ) ); + $result = $fixer->apply( $before, Title::newMainPage() ); + $this->assertEquals( $expectedAfter, $result, $message ); + } +} diff --git a/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php b/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php new file mode 100644 index 00000000..308d8b23 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php @@ -0,0 +1,93 @@ +<?php + +namespace Flow\Tests\Parsoid\Fixer; + +use Flow\Model\UUID; +use Flow\Parsoid\ContentFixer; +use Flow\Parsoid\Fixer\WikiLinkFixer; +use Flow\Parsoid\Utils; +use Flow\Tests\PostRevisionTestCase; +use Html; +use Title; + +/** + * @group Flow + */ +class WikiLinkFixerTest extends PostRevisionTestCase { + + static public function redLinkProvider() { + return array( + array( + 'Basic redlink application', + // html from parsoid for: [[Talk:Flow/Bugs]] + '<a rel="mw:WikiLink" href="./Talk:Flow/Bugs" data-parsoid=\'{"stx":"simple","a":{"href":"./Talk:Flow/Bugs"},"sa":{"href":"Talk:Flow/Bugs"},"dsr":[0,18,2,2]}\'>Talk:Flow/Bugs</a>', + // expect string + // @fixme easily breakable, depends on url order + htmlentities( 'Talk:Flow/Bugs&action=edit&redlink=1' ), + ), + + array( + 'Subpage redlink application', + // html from parsoid for: [[/SubPage]] + '<a rel="mw:WikiLink" href=".//SubPage" data-parsoid=\'{"stx":"simple","a":{"href":".//SubPage"},"sa":{"href":"/SubPage"},"dsr":[0,12,2,2]}\'>/SubPage</a>', + // expect string + htmlentities( 'Main_Page/SubPage&action=edit&redlink=1' ), + ), + + array( + 'Link containing html entities should be properly handled', + // html from parsoid for: [[Foo&Bar]] + '<a rel="mw:WikiLink" href="./Foo&Bar" data-parsoid=\'{"stx":"simple","a":{"href":"./Foo&Bar"},"sa":{"href":"Foo&Bar"},"dsr":[0,11,2,2]}\'>Foo&Bar</a>', + // expect string + '>Foo&Bar</a>', + ), + + array( + 'Link containing UTF-8 anchor content passes through as UTF-8', + // html from parsoid for: [[Foo|test – test]] + '<a rel="mw:WikiLink" href="./Foo" data-parsoid=\'{"stx":"piped","a":{"href":"./Foo"},"sa":{"href":"Foo"},"dsr":[0,19,6,2]}\'>test – test</a>', + // title text from parsoid + // expect string + 'test – test', + ), + + array( + 'Link containing urlencoded UTF-8 href works', + // html from parsoid for: [[Viquipèdia:La taverna/Tecnicismes/Arxius_2]] + '<a rel="mw:WikiLink" href="./Viquip%C3%A8dia:La_taverna/Tecnicismes/Arxius_2" title="Viquipdia:La taverna/Tecnicismes/Arxius 2" data-parsoid=\'{"stx":"simple","a":{"href":"./Viquipdia:La_taverna/Tecnicismes/Arxius_2"},"sa":{"href":"Viquipdia:La taverna/Tecnicismes/Arxius 2"},"dsr":[59,105,2,2]}\'>Viquipdia:La taverna/Tecnicismes/Arxius 2</a>', + // anchor should be transformed to /wiki/Viquip... + // annoyingly we don't control Title::exists() so just assume redlink + // with index.php + 'index.php?title=Viquip%C3%A8dia:La_taverna/Tecnicismes/Arxius_2' + ), + ); + } + + /** + * @dataProvider redLinkProvider + */ + public function testAppliesRedLinks( $message, $anchor, $expect ) { + $fixer = new ContentFixer( new WikiLinkFixer( $this->getMock( 'LinkBatch' ) ) ); + $result = $fixer->apply( $anchor, Title::newMainPage() ); + $this->assertContains( $expect, $result, $message ); + } +} + +class MethodReturnsConstraint extends \PHPUnit_Framework_Constraint { + public function __construct( $method, \PHPUnit_Framework_Constraint $constraint ) { + $this->method = $method; + $this->constraint = $constraint; + } + + protected function matches( $other ) { + return $this->constraint->matches( call_user_func( array( $other, $this->method ) ) ); + } + + public function toString() { + return $this->constraint->toString(); + } + + protected function failureDescription( $other ) { + return $this->constraint->failureDescription( $other ) . " from {$this->method} method"; + } +} diff --git a/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php b/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php new file mode 100644 index 00000000..b7af6509 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php @@ -0,0 +1,180 @@ +<?php + +namespace Flow\Tests\Parsoid; + +use Flow\Container; +use Flow\Exception\WikitextException; +use Flow\Model\UUID; +use Flow\Parsoid\ReferenceFactory; +use Flow\Parsoid\Utils; +use Flow\Tests\FlowTestCase; +use ReflectionMethod; +use Title; + +/** + * @group Database + * @group Flow + */ +class ReferenceExtractorTestCase extends FlowTestCase { + public function setUp() { + parent::setUp(); + + // Check for Parsoid + try { + Utils::convert( 'html', 'wikitext', 'Foo', Title::newFromText( 'UTPage' ) ); + } catch ( WikitextException $excep ) { + $this->markTestSkipped( 'Parsoid not enabled' ); + } + } + + public static function referenceExtractorProvider() { + return array( + array( + 'Normal link', + // source wiki text + '[[My page]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'link', + // expected target + 'title:My_page', + ), + array( + 'Link with URL encoding issues', + // source wiki text + '[[User talk:Werdna?]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'link', + // expected target + 'title:User_talk:Werdna?', + ), + array( + 'Subpage link', + // source wiki text + '[[/Subpage]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'link', + // expected target + 'title:Talk:UTPage/Subpage', + // ??? + 'Talk:UTPage', + ), + array( + 'External link', + // source wiki text + '[http://www.google.com Google]', + // expected factory method + 'Flow\Model\UrlReference', + // expected type + 'link', + // expected target + 'url:http://www.google.com', + ), + array( + 'File', + // source wiki text + '[[File:Image.png]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'file', + // expected target + 'title:File:Image.png', + ), + array( + 'File with parameters', + // source wiki text + '[[File:Image.png|25px]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'file', + // expected target + 'title:File:Image.png', + ), + array( + 'File with encoding issues', + // source wiki text + '[[File:Image?.png]]', + // expected class + 'Flow\Model\WikiReference', + // expected type + 'file', + // expected target + 'title:File:Image?.png', + ), + array( + 'Template', + // source wiki text + '{{Foo}}', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'template', + // expected target + 'title:Template:Foo', + ), + + array( + 'Non-existent File', + // source wiki text + '[[File:Some/Files/Really/Should_Not_Ex/ist.png]]', + // expected factory method + 'Flow\Model\WikiReference', + // expected type + 'file', + // expected target + 'title:File:Some/Files/Really/Should_Not_Ex/ist.png', + ) + ); + } + + /** + * @dataProvider referenceExtractorProvider + */ + public function testReferenceExtractor( + $description, + $wikitext, + $expectedClass, + $expectedType, + $expectedTarget, + $page = 'UTPage' + ) { + $referenceExtractor = Container::get( 'reference.extractor' ); + + $workflow = $this->getMock( 'Flow\Model\Workflow' ); + $workflow->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( UUID::create() ) ); + $workflow->expects( $this->any() ) + ->method( 'getArticleTitle' ) + ->will( $this->returnValue( Title::newMainPage() ) ); + $factory = new ReferenceFactory( $workflow, 'foo', UUID::create() ); + + $reflMethod = new ReflectionMethod( $referenceExtractor, 'extractReferences' ); + $reflMethod->setAccessible( true ); + + $reflProperty = new \ReflectionProperty( $referenceExtractor, 'extractors' ); + $reflProperty->setAccessible( true ); + $extractors = $reflProperty->getValue( $referenceExtractor ); + + $html = Utils::convert( 'wt', 'html', $wikitext, Title::newFromText( $page ) ); + $result = $reflMethod->invoke( + $referenceExtractor, + $factory, + $extractors['post'], + $html + ); + $this->assertCount( 1, $result, $html ); + + $result = reset( $result ); + $this->assertInstanceOf( $expectedClass, $result, $description ); + $this->assertEquals( $expectedType, $result->getType(), $description ); + $this->assertEquals( $expectedTarget, $result->getTargetIdentifier(), $description ); + } +} diff --git a/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php b/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php new file mode 100644 index 00000000..a47b8977 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php @@ -0,0 +1,32 @@ +<?php + +namespace Flow\Tests\Parsoid; + +use Flow\Model\UUID; +use Flow\Parsoid\ReferenceFactory; +use Title; + +/** + * @group Flow + */ +class ReferenceFactoryTest extends \MediaWikiTestCase { + public function testAcceptsParsoidHrefs() { + $workflow = $this->getMock( 'Flow\Model\Workflow' ); + $workflow->expects( $this->any() ) + ->method( 'getId' ) + ->will( $this->returnValue( UUID::create() ) ); + $workflow->expects( $this->any() ) + ->method( 'getArticleTitle' ) + ->will( $this->returnValue( Title::newMainPage() ) ); + + $factory = new ReferenceFactory( + $workflow, + 'foo', + UUID::create() + ); + + $ref = $factory->createWikiReference( 'file', './File:Foo.jpg' ); + $this->assertInstanceOf( 'Flow\\Model\\WikiReference', $ref ); + $this->assertEquals( 'title:File:Foo.jpg', $ref->getTargetIdentifier() ); + } +} diff --git a/Flow/tests/phpunit/Parsoid/UtilsTest.php b/Flow/tests/phpunit/Parsoid/UtilsTest.php new file mode 100644 index 00000000..fc2165b0 --- /dev/null +++ b/Flow/tests/phpunit/Parsoid/UtilsTest.php @@ -0,0 +1,113 @@ +<?php + +namespace Flow\Tests\Parsoid; + +use Flow\Exception\WikitextException; +use Flow\Parsoid\Utils; +use Flow\Tests\FlowTestCase; +use Title; + +/** + * @group Flow + */ +class ParsoidUtilsTest extends FlowTestCase { + + static public function createDomProvider() { + return array( + array( + 'A document with multiple matching ids is valid parser output', + '<body><a id="foo">foo</a><a id="foo">bar</a></body>' + ), + array( + 'HTML5 tags, such as figcaption, are valid html', + '<body><figcaption /></body>' + ), + ); + } + + /** + * @dataProvider createDomProvider + */ + public function testCreateDomErrorModes( $message, $content ) { + $this->assertInstanceOf( 'DOMDocument', Utils::createDOM( $content ), $message ); + } + + static public function createRelativeTitleProvider() { + return array( + array( + 'strips leading ./ and treats as non-relative', + // expect + Title::newFromText( 'File:Foo.jpg' ), + // input text + './File:Foo.jpg', + // relative to title + Title::newMainPage() + ), + + array( + 'two level upwards traversal', + // expect + Title::newFromText( 'File:Bar.jpg' ), + // input text + '../../File:Bar.jpg', + // relative to title + Title::newFromText( 'Main_Page/And/Subpage' ), + ), + ); + } + + /** + * @dataProvider createRelativeTitleProvider + */ + public function testResolveSubpageTraversal( $message, $expect, $text, Title $title ) { + $result = Utils::createRelativeTitle( $text, $title ); + + if ( $expect === null ) { + $this->assertNull( $expect, $message ); + } elseif ( $expect instanceof Title ) { + $this->assertInstanceOf( 'Title', $result, $message ); + $this->assertEquals( $expect->getPrefixedText(), $result->getPrefixedText(), $message ); + } else { + $this->assertEquals( $expect, $result, $message ); + } + } + + static public function wikitextRoundtripProvider() { + return array( + array( + 'italic text', + // text & expect + "''italic text''", + // title + Title::newMainPage(), + ), + array( + 'bold text', + // text & expect + "'''bold text'''", + // title + Title::newMainPage(), + ), + ); + } + + /** + * Test full roundtrip (wikitext -> html -> wikitext) + * + * It doesn't make sense to test only a specific path, since Parsoid's HTML + * may change beyond our control & it doesn't really matter to us what + * exactly the HTML looks like, as long as Parsoid is able to understand it. + * + * @dataProvider wikitextRoundtripProvider + */ + public function testwikitextRoundtrip( $message, $expect, Title $title ) { + // Check for Parsoid + try { + $html = Utils::convert( 'wikitext', 'html', $expect, $title ); + $wikitext = Utils::convert( 'html', 'wikitext', $html, $title ); + $this->assertEquals( $expect, trim( $wikitext ), $message ); + } catch ( WikitextException $excep ) { + $this->markTestSkipped( 'Parsoid not enabled' ); + } + } +} diff --git a/Flow/tests/phpunit/PermissionsTest.php b/Flow/tests/phpunit/PermissionsTest.php new file mode 100644 index 00000000..cfe7f478 --- /dev/null +++ b/Flow/tests/phpunit/PermissionsTest.php @@ -0,0 +1,373 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; +use Flow\FlowActions; +use Flow\Model\AbstractRevision; +use Flow\Model\PostRevision; +use Flow\RevisionActionPermissions; +use Block; +use User; + +/** + * @group Database + * @group Flow + */ +class PermissionsTest extends PostRevisionTestCase { + /** + * @var array + */ + protected $tablesUsed = array( 'user', 'user_groups' ); + + /** + * @var FlowActions + */ + protected $actions; + + /** + * @var PostRevision + */ + protected + $topic, + $hiddenTopic, + $deletedTopic, + $suppressedTopic, + $post, + $hiddenPost, + $deletedPost, + $suppressedPost; + + /** + * @var User + */ + protected + $anonUser, + $unconfirmedUser, + $confirmedUser, + $sysopUser, + $oversightUser; + + protected function setUp() { + parent::setUp(); + + // We don't want local config getting in the way of testing whether or + // not our permissions implementation works well. + // This will load default $wgGroupPermissions + Flow settings, so we can + // test if permissions work well, regardless of any custom config. + global $IP, $wgFlowGroupPermissions; + $wgGroupPermissions = array(); + require "$IP/includes/DefaultSettings.php"; + $wgGroupPermissions = array_merge_recursive( $wgGroupPermissions, $wgFlowGroupPermissions ); + $this->setMwGlobals( 'wgGroupPermissions', $wgGroupPermissions ); + + // load actions object + $this->actions = Container::get( 'flow_actions' ); + } + + protected function tearDown() { + parent::tearDown(); + } + + /** + * Provides User, PostRevision (or null) & action to testPermissions, as + * well as the expected result: whether or not a certain user should be + * allowed to perform a certain action on a certain revision. + * + * I'm calling functions to fetch users & revisions. This is done because + * setUp is called only after dataProvider is executed, so it's impossible + * to create all these objects in setUp. + * + * "All data providers are executed before both the call to the + * setUpBeforeClass static method and the first call to the setUp method. + * Because of that you can't access any variables you create there from + * within a data provider. This is required in order for PHPUnit to be able + * to compute the total number of tests." + * + * @see http://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html + * + * @return array + */ + public function permissionsProvider() { + return array( + // anon users can submit content, but not moderate + array( $this->anonUser(), null, 'create-header', true ), +// array( $this->anonUser(), $this->header(), 'edit-header', true ), + array( $this->anonUser(), $this->topic(), 'edit-title', true ), + array( $this->anonUser(), null, 'new-post', true ), + array( $this->anonUser(), $this->post(), 'edit-post', false ), + array( $this->anonUser(), $this->post(), 'hide-post', true ), + array( $this->anonUser(), $this->topic(), 'hide-topic', true ), + array( $this->anonUser(), $this->topic(), 'lock-topic', false ), + array( $this->anonUser(), $this->post(), 'delete-post', false ), + array( $this->anonUser(), $this->topic(), 'delete-topic', false ), + array( $this->anonUser(), $this->post(), 'suppress-post', false ), + array( $this->anonUser(), $this->topic(), 'suppress-topic', false ), + array( $this->anonUser(), $this->post(), 'restore-post', false ), + array( $this->anonUser(), $this->topic(), 'restore-topic', false ), + array( $this->anonUser(), $this->post(), 'history', true ), + array( $this->anonUser(), $this->post(), 'view', true ), + array( $this->anonUser(), $this->post(), 'reply', true ), + + // unconfirmed users can also hide posts... + array( $this->unconfirmedUser(), null, 'create-header', true ), +// array( $this->unconfirmedUser(), $this->header(), 'edit-header', true ), + array( $this->unconfirmedUser(), $this->topic(), 'edit-title', true ), + array( $this->unconfirmedUser(), null, 'new-post', true ), + array( $this->unconfirmedUser(), $this->post(), 'edit-post', true ), // can edit own post + array( $this->unconfirmedUser(), $this->post(), 'hide-post', true ), + array( $this->unconfirmedUser(), $this->topic(), 'hide-topic', true ), + array( $this->unconfirmedUser(), $this->topic(), 'lock-topic', true ), + array( $this->unconfirmedUser(), $this->post(), 'delete-post', false ), + array( $this->unconfirmedUser(), $this->topic(), 'delete-topic', false ), + array( $this->unconfirmedUser(), $this->post(), 'suppress-post', false ), + array( $this->unconfirmedUser(), $this->topic(), 'suppress-topic', false ), + array( $this->unconfirmedUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden + array( $this->unconfirmedUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden + array( $this->unconfirmedUser(), $this->post(), 'history', true ), + array( $this->unconfirmedUser(), $this->post(), 'view', true ), + array( $this->unconfirmedUser(), $this->post(), 'reply', true ), + + // ... as well as restore hidden posts + array( $this->unconfirmedUser(), $this->hiddenPost(), 'restore-post', true ), + array( $this->unconfirmedUser(), $this->hiddenTopic(), 'restore-topic', true ), + + // ... but not restore deleted/suppressed posts + array( $this->unconfirmedUser(), $this->deletedPost(), 'restore-post', false ), + array( $this->unconfirmedUser(), $this->deletedTopic(), 'restore-topic', false ), + array( $this->unconfirmedUser(), $this->suppressedPost(), 'restore-post', false ), + array( $this->unconfirmedUser(), $this->suppressedTopic(), 'restore-topic', false ), + + // confirmed users are the same as unconfirmed users, in terms of permissions + array( $this->confirmedUser(), null, 'create-header', true ), +// array( $this->confirmedUser(), $this->header(), 'edit-header', true ), + array( $this->confirmedUser(), $this->topic(), 'edit-title', true ), + array( $this->confirmedUser(), null, 'new-post', true ), + array( $this->confirmedUser(), $this->post(), 'edit-post', false ), + array( $this->confirmedUser(), $this->post(), 'hide-post', true ), + array( $this->confirmedUser(), $this->topic(), 'hide-topic', true ), + array( $this->confirmedUser(), $this->post(), 'delete-post', false ), + array( $this->confirmedUser(), $this->topic(), 'delete-topic', false ), + array( $this->confirmedUser(), $this->topic(), 'lock-topic', true ), + array( $this->confirmedUser(), $this->post(), 'suppress-post', false ), + array( $this->confirmedUser(), $this->topic(), 'suppress-topic', false ), + array( $this->confirmedUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden + array( $this->confirmedUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden + array( $this->confirmedUser(), $this->post(), 'history', true ), + array( $this->confirmedUser(), $this->post(), 'view', true ), + array( $this->confirmedUser(), $this->post(), 'reply', true ), + array( $this->confirmedUser(), $this->hiddenPost(), 'restore-post', true ), + array( $this->confirmedUser(), $this->hiddenTopic(), 'restore-topic', true ), + array( $this->confirmedUser(), $this->deletedPost(), 'restore-post', false ), + array( $this->confirmedUser(), $this->deletedTopic(), 'restore-topic', false ), + array( $this->confirmedUser(), $this->suppressedPost(), 'restore-post', false ), + array( $this->confirmedUser(), $this->suppressedTopic(), 'restore-topic', false ), + + // sysops can do all (incl. editing posts) but suppressing + array( $this->sysopUser(), null, 'create-header', true ), +// array( $this->sysopUser(), $this->header(), 'edit-header', true ), + array( $this->sysopUser(), $this->topic(), 'edit-title', true ), + array( $this->sysopUser(), null, 'new-post', true ), + array( $this->sysopUser(), $this->post(), 'edit-post', true ), + array( $this->sysopUser(), $this->post(), 'hide-post', true ), + array( $this->sysopUser(), $this->topic(), 'hide-topic', true ), + array( $this->sysopUser(), $this->topic(), 'lock-topic', true ), + array( $this->sysopUser(), $this->post(), 'delete-post', true ), + array( $this->sysopUser(), $this->topic(), 'delete-topic', true ), + array( $this->sysopUser(), $this->post(), 'suppress-post', false ), + array( $this->sysopUser(), $this->topic(), 'suppress-topic', false ), + array( $this->sysopUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden + array( $this->sysopUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden + array( $this->sysopUser(), $this->topic(), 'history', true ), + array( $this->sysopUser(), $this->post(), 'view', true ), + array( $this->sysopUser(), $this->post(), 'reply', true ), + array( $this->sysopUser(), $this->hiddenPost(), 'restore-post', true ), + array( $this->sysopUser(), $this->hiddenTopic(), 'restore-topic', true ), + array( $this->sysopUser(), $this->deletedPost(), 'restore-post', true ), + array( $this->sysopUser(), $this->deletedTopic(), 'restore-topic', true ), + array( $this->sysopUser(), $this->suppressedPost(), 'restore-post', false ), + array( $this->sysopUser(), $this->suppressedTopic(), 'restore-topic', false ), + + // oversighters can do everything + suppress (but not edit!) + array( $this->oversightUser(), null, 'create-header', true ), +// array( $this->oversightUser(), $this->header(), 'edit-header', true ), + array( $this->oversightUser(), $this->topic(), 'edit-title', true ), + array( $this->oversightUser(), null, 'new-post', true ), + array( $this->oversightUser(), $this->post(), 'edit-post', false ), + array( $this->oversightUser(), $this->post(), 'hide-post', true ), + array( $this->oversightUser(), $this->topic(), 'hide-topic', true ), + array( $this->oversightUser(), $this->topic(), 'lock-topic', true ), + array( $this->oversightUser(), $this->post(), 'delete-post', true ), + array( $this->oversightUser(), $this->topic(), 'delete-topic', true ), + array( $this->oversightUser(), $this->post(), 'suppress-post', true ), + array( $this->oversightUser(), $this->topic(), 'suppress-topic', true ), + array( $this->oversightUser(), $this->post(), 'restore-post', false ), // $this->post is not hidden + array( $this->oversightUser(), $this->topic(), 'restore-topic', false ), // $this->topic is not hidden + array( $this->oversightUser(), $this->post(), 'history', true ), + array( $this->oversightUser(), $this->post(), 'view', true ), + array( $this->oversightUser(), $this->post(), 'reply', true ), + array( $this->oversightUser(), $this->hiddenPost(), 'restore-post', true ), + array( $this->oversightUser(), $this->hiddenTopic(), 'restore-topic', true ), + array( $this->oversightUser(), $this->deletedPost(), 'restore-post', true ), + array( $this->oversightUser(), $this->deletedTopic(), 'restore-topic', true ), + array( $this->oversightUser(), $this->suppressedPost(), 'restore-post', true ), + array( $this->oversightUser(), $this->suppressedTopic(), 'restore-topic', true ), + ); + } + + /** + * @dataProvider permissionsProvider + */ + public function testPermissions( User $user, PostRevision $revision = null, $action, $expected ) { + $permissions = new RevisionActionPermissions( $this->actions, $user ); + $this->assertEquals( $expected, $permissions->isAllowed( $revision, $action ) ); + } + + protected function anonUser() { + if ( !$this->anonUser ) { + $this->anonUser = new User; + } + + return $this->anonUser; + } + + protected function unconfirmedUser() { + if ( !$this->unconfirmedUser ) { + $this->unconfirmedUser = User::newFromName( 'UTFlowUnconfirmed' ); + $this->unconfirmedUser->addToDatabase(); + $this->unconfirmedUser->addGroup( 'user' ); + } + + return $this->unconfirmedUser; + } + + protected function confirmedUser() { + if ( !$this->confirmedUser ) { + $this->confirmedUser = User::newFromName( 'UTFlowConfirmed' ); + $this->confirmedUser->addToDatabase(); + $this->confirmedUser->addGroup( 'autoconfirmed' ); + } + + return $this->confirmedUser; + } + + protected function sysopUser() { + if ( !$this->sysopUser ) { + $this->sysopUser = User::newFromName( 'UTFlowSysop' ); + $this->sysopUser->addToDatabase(); + $this->sysopUser->addGroup( 'sysop' ); + } + + return $this->sysopUser; + } + + protected function oversightUser() { + if ( !$this->oversightUser ) { + $this->oversightUser = User::newFromName( 'UTFlowOversight' ); + $this->oversightUser->addToDatabase(); + $this->oversightUser->addGroup( 'oversight' ); + } + + return $this->oversightUser; + } + + protected function topic() { + if ( !$this->topic ) { + $this->topic = $this->generateObject(); + } + + return $this->topic; + } + + protected function hiddenTopic() { + if ( !$this->hiddenTopic ) { + $this->hiddenTopic = $this->generateObject( array( + 'rev_change_type' => 'hide-topic', + 'rev_mod_state' => AbstractRevision::MODERATED_HIDDEN + ) ); + } + + return $this->hiddenTopic; + } + + protected function deletedTopic() { + if ( !$this->deletedTopic ) { + $this->deletedTopic = $this->generateObject( array( + 'rev_change_type' => 'delete-topic', + 'rev_mod_state' => AbstractRevision::MODERATED_DELETED + ) ); + } + + return $this->deletedTopic; + } + + protected function suppressedTopic() { + if ( !$this->suppressedTopic ) { + $this->suppressedTopic = $this->generateObject( array( + 'rev_change_type' => 'suppress-topic', + 'rev_mod_state' => AbstractRevision::MODERATED_SUPPRESSED + ) ); + } + + return $this->suppressedTopic; + } + + protected function post() { + if ( !$this->post ) { + $this->post = $this->generateObject( array( + 'tree_orig_user_id' => $this->unconfirmedUser()->getId(), + 'tree_orig_user_ip' => '', + 'tree_parent_id' => $this->topic()->getPostId()->getBinary() + ), array(), 1 ); + $this->post->setRootPost( $this->generateObject( array( + 'tree_orig_user_id' => $this->unconfirmedUser()->getId(), + 'tree_orig_user_ip' => '', + 'tree_parent_id' => $this->topic()->getPostId()->getBinary() + ), array(), 1 ) ); + } + + return $this->post; + } + + protected function hiddenPost() { + if ( !$this->hiddenPost ) { + $this->hiddenPost = $this->generateObject( array( + 'tree_orig_user_id' => $this->unconfirmedUser()->getId(), + 'tree_orig_user_ip' => '', + 'tree_parent_id' => $this->topic()->getPostId()->getBinary(), + 'rev_change_type' => 'hide-post', + 'rev_mod_state' => AbstractRevision::MODERATED_HIDDEN + ), array(), 1 ); + } + + return $this->hiddenPost; + } + + protected function deletedPost() { + if ( !$this->deletedPost ) { + $this->deletedPost = $this->generateObject( array( + 'tree_orig_user_id' => $this->unconfirmedUser()->getId(), + 'tree_orig_user_ip' => '', + 'tree_parent_id' => $this->topic()->getPostId()->getBinary(), + 'rev_change_type' => 'delete-post', + 'rev_mod_state' => AbstractRevision::MODERATED_DELETED + ), array(), 1 ); + } + + return $this->deletedPost; + } + + protected function suppressedPost() { + if ( !$this->suppressedPost ) { + $this->suppressedPost = $this->generateObject( array( + 'tree_orig_user_id' => $this->unconfirmedUser()->getId(), + 'tree_orig_user_ip' => '', + 'tree_parent_id' => $this->topic()->getPostId()->getBinary(), + 'rev_change_type' => 'suppress-post', + 'rev_mod_state' => AbstractRevision::MODERATED_SUPPRESSED + ), array(), 1 ); + } + + return $this->suppressedPost; + } +} diff --git a/Flow/tests/phpunit/PostRevisionTestCase.php b/Flow/tests/phpunit/PostRevisionTestCase.php new file mode 100644 index 00000000..8950dc91 --- /dev/null +++ b/Flow/tests/phpunit/PostRevisionTestCase.php @@ -0,0 +1,234 @@ +<?php + +namespace Flow\Tests; + +use DeferredUpdates; +use Flow\Container; +use Flow\Data\Index\BoardHistoryIndex; +use Flow\Data\Listener\NotificationListener; +use Flow\Data\Listener\RecentChangesListener; +use Flow\Data\ObjectManager; +use Flow\Model\AbstractRevision; +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Flow\Model\UserTuple; +use Flow\Model\UUID; +use SplQueue; +use User; + +/** + * @group Flow + * @group Database + */ +class PostRevisionTestCase extends FlowTestCase { + /** + * @var PostRevision + */ + protected $revision; + + /** + * @var PostRevision[] + */ + protected $revisions = array(); + + /** + * @var Workflow + */ + protected $workflow; + + protected function setUp() { + parent::setUp(); + $this->generateWorkflowForPost(); + $this->revision = $this->generateObject(); + // Revisions must be blanked here otherwise phpunit run with --repeat will remember + // ths revision list between multiple invocations of the test causing issues. + $this->revisions = array(); + } + + /** + * Reset the container and with it any state + */ + protected function tearDown() { + parent::tearDown(); + + foreach ( $this->revisions as $revision ) { + try { + $this->getStorage()->remove( $revision ); + } catch ( \MWException $e ) { + // ignore - lifecyclehandlers may cause issues with tests, where + // not all related stuff is loaded + } + } + + // Needed because not all cases do the reset in setUp yet + Container::reset(); + } + + /** + * @return ObjectManager + */ + protected function getStorage() { + return Container::get( 'storage.post' ); + } + + /** + * Returns an array, representing flow_revision & flow_tree_revision db + * columns. + * + * You can pass in arguments to override default data. + * With no arguments tossed in, default data (resembling a newly-created + * topic title) will be returned. + * + * @param array[optional] $row DB row data (only specify override columns) + * @return array + */ + protected function generateRow( array $row = array() ) { + $this->generateWorkflowForPost(); + $uuidRevision = UUID::create(); + + $user = User::newFromName( 'UTSysop' ); + $tuple = UserTuple::newFromUser( $user ); + + return $row + array( + // flow_revision + 'rev_id' => $uuidRevision->getBinary(), + 'rev_type' => 'post', + 'rev_type_id' => $this->workflow->getId()->getBinary(), + 'rev_user_wiki' => $tuple->wiki, + 'rev_user_id' => $tuple->id, + 'rev_user_ip' => $tuple->ip, + 'rev_parent_id' => null, + 'rev_flags' => 'html', + 'rev_content' => 'test content', + 'rev_change_type' => 'new-post', + 'rev_mod_state' => AbstractRevision::MODERATED_NONE, + 'rev_mod_user_wiki' => null, + 'rev_mod_user_id' => null, + 'rev_mod_user_ip' => null, + 'rev_mod_timestamp' => null, + 'rev_mod_reason' => null, + 'rev_last_edit_id' => null, + 'rev_edit_user_wiki' => null, + 'rev_edit_user_id' => null, + 'rev_edit_user_ip' => null, + 'rev_content_length' => 0, + 'rev_previous_content_length' => 0, + + // flow_tree_revision + 'tree_rev_descendant_id' => $this->workflow->getId()->getBinary(), + 'tree_rev_id' => $uuidRevision->getBinary(), + 'tree_orig_user_wiki' => $tuple->wiki, + 'tree_orig_user_id' => $tuple->id, + 'tree_orig_user_ip' => $tuple->ip, + 'tree_parent_id' => null, + ); + } + + /** + * Populate a fake workflow in the unittest database + * + * @return Workflow + */ + protected function generateWorkflowForPost() { + if ( $this->workflow ) { + return $this->workflow; + } + + $row = array( + 'workflow_id' => UUID::create()->getBinary(), + 'workflow_type' => 'topic', + 'workflow_wiki' => wfWikiId(), + // The test workflow has no real associated page, this is + // just a random page number + 'workflow_page_id' => 1, + 'workflow_namespace' => NS_USER_TALK, + 'workflow_title_text' => 'Test', + 'workflow_lock_state' => 0, + 'workflow_last_update_timestamp' => wfTimestampNow(), + ); + $this->workflow = Workflow::fromStorageRow( $row ); + + return $this->workflow; + } + + /** + * Returns a PostRevision object. + * + * You can pass in arguments to override default data. + * With no arguments tossed in, a default revision (resembling a newly- + * created topic title) will be returned. + * + * @param array[optional] $row DB row data (only specify override columns) + * @param array[optional] $children Array of child PostRevision objects + * @param int[optional] $depth Depth of the PostRevision object + * @return PostRevision + */ + protected function generateObject( array $row = array(), $children = array(), $depth = 0 ) { + $row = $this->generateRow( $row ); + + $revision = PostRevision::fromStorageRow( $row ); + $revision->setChildren( $children ); + $revision->setDepth( $depth ); + + return $revision; + } + + /** + * Saves a PostRevision to storage. + * Be sure to add the required tables to $tablesUsed and add @group Database + * to the class' phpDoc. + * + * @param PostRevision $revision + */ + protected function store( PostRevision $revision ) { + $this->getStorage()->put( + $revision, + array( + 'workflow' => $this->generateWorkflowForPost(), + // @todo: Topic.php also adds 'topic-title' + ) + ); + + /** @var SplQueue $deferredQueue */ + $deferredQueue = Container::get( 'deferred_queue' ); + while( !$deferredQueue->isEmpty() ) { + try { + DeferredUpdates::addCallableUpdate( $deferredQueue->dequeue() ); + + // doing updates 1 by 1 so an exception doesn't break others in + // the queue + DeferredUpdates::doUpdates(); + } catch ( \MWException $e ) { + // ignoring exceptions for now, not all are phpunit-proof yet + } + } + + // save for removal at end of tests + $this->revisions[] = $revision; + } + + protected function clearExtraLifecycleHandlers() { + $c = Container::getContainer(); + foreach( array_unique( $c['storage.manager_list'] ) as $key ) { + if ( !isset( $c["$key.listeners"] ) ) { + continue; + } + $c->extend( "$key.listeners", function( $listeners ) use ( $key ) { + return array_filter( + $listeners, + function( $handler ) { + // Recent changes logging is outside the scope of this test, and + // causes interaction issues + return !$handler instanceof RecentChangesListener + // putting together the right metadata for a commit is beyond the + // scope of these tests + && !$handler instanceof NotificationListener + // BoardHistory requires we also wire together TopicListEntry objects for + // each revision, but that's also beyond our scope. + && !$handler instanceof BoardHistoryIndex; + } + ); + } ); + } + } +} diff --git a/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php b/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php new file mode 100644 index 00000000..a439b8a9 --- /dev/null +++ b/Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php @@ -0,0 +1,83 @@ +<?php + +namespace Flow\Tests\Repository; + +use Flow\Container; +use Flow\Data\BagOStuff\BufferedBagOStuff; +use Flow\Data\BufferedCache; +use Flow\Model\UUID; +use Flow\Repository\TreeRepository; +use Flow\Tests\FlowTestCase; + +/** + * @group Flow + * @group Database + */ +class TreeRepositorydbTest extends FlowTestCase { + protected $tablesUsed = array( 'flow_tree_node' ); + + public function testSomething() { + // meaningless set of ids used for repeatability + $ids = array_map( array( 'Flow\Model\UUID', 'create' ), array( + "s3z44zhp93j5vvc8", "s3z44zhqt7yt8220", "s46w00pmmw0otc0q", + "s3qvc7cnor86wvb4", "s3qvc7bbcxr3f340", + "s3gre9r27pobtg0n", "s3cdl3dfqf8brx18", "s3cdl3dhajnz43r0", + ) ); + + // Use 2 repos with 2 caches, the one you insert with reads from cache + // the other reads from db due to different cache + $cache[] = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), 600 ); + $cache[] = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), 600 ); + $dbf = Container::get( 'db.factory' ); + $repo[] = new TreeRepository( $dbf, $cache[0] ); + $repo[] = new TreeRepository( $dbf, $cache[1] ); + + // id0 as new root + wfDebugLog( 'Flow', "\n\n************** id0 as new root ************" ); + $repo[0]->insert( $ids[0] ); + $this->assertEquals( + array( $ids[0] ), + $repo[0]->findRootPath( $ids[0] ) + ); + $this->assertEquals( + array( $ids[0] ), + $repo[1]->findRootPath( $ids[0] ) + ); + + // id1 as child of id0 + wfDebugLog( 'Flow', "\n\n************** id1 as child of id0 ************" ); + $repo[0]->insert( $ids[1], $ids[0] ); + $this->assertEquals( + array( $ids[0], $ids[1] ), + $repo[0]->findRootPath( $ids[1] ) + ); + $this->assertEquals( + array( $ids[0], $ids[1] ), + $repo[1]->findRootPath( $ids[1] ) + ); + + // id2 as child of id0 + wfDebugLog( 'Flow', "\n\n************** id2 as child of id0 ************" ); + $repo[0]->insert( $ids[2], $ids[0] ); + $this->assertEquals( + array( $ids[0], $ids[2] ), + $repo[0]->findRootPath( $ids[2] ) + ); + $this->assertEquals( + array( $ids[0], $ids[2] ), + $repo[1]->findRootPath( $ids[2] ) + ); + + // id3 as child of id1 + wfDebugLog( 'Flow', "\n\n************** id3 as child of id1 ************" ); + $repo[0]->insert( $ids[3], $ids[1] ); + $this->assertEquals( + array( $ids[0], $ids[1], $ids[3] ), + $repo[0]->findRootPath( $ids[3] ) + ); + $this->assertEquals( + array( $ids[0], $ids[1], $ids[3] ), + $repo[1]->findRootPath( $ids[3] ) + ); + } +} diff --git a/Flow/tests/phpunit/Repository/TreeRepositoryTest.php b/Flow/tests/phpunit/Repository/TreeRepositoryTest.php new file mode 100644 index 00000000..6c25d014 --- /dev/null +++ b/Flow/tests/phpunit/Repository/TreeRepositoryTest.php @@ -0,0 +1,90 @@ +<?php + +namespace Flow\Tests\Repository; + +use Flow\Data\BagOStuff\BufferedBagOStuff; +use Flow\Data\BufferedCache; +use Flow\Model\UUID; +use Flow\Repository\TreeRepository; +use Flow\Tests\FlowTestCase; +use ReflectionClass; + +/** + * @group Flow + */ +class TreeRepositoryTest extends FlowTestCase { + + protected $ancestor; + protected $descendant; + + public function setUp() { + parent::setUp(); + $this->ancestor = UUID::create( false ); + $this->descendant = UUID::create( false ); + } + + public function testSuccessfulInsert() { + global $wgFlowCacheTime; + $cache = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), $wgFlowCacheTime ); + $treeRepository = new TreeRepository( $this->mockDbFactory( true ), $cache ); + $this->assertTrue( $treeRepository->insert( $this->descendant, $this->ancestor ) ); + + $reflection = new ReflectionClass( '\Flow\Repository\TreeRepository' ); + $method = $reflection->getMethod( 'cacheKey' ); + $method->setAccessible( true ); + + $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'subtree', $this->descendant ) ), false ); + $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'rootpath', $this->descendant ) ), false ); + $this->assertNotSame( $cache->get( $method->invoke( $treeRepository, 'parent', $this->descendant ) ), false ); + } + + /** + * @expectedException \Flow\Exception\DataModelException + */ + public function testFailingInsert() { + global $wgFlowCacheTime; + // Catch the exception and test the cache result then re-throw the exception, + // otherwise the exception would skip the cache result test + $cache = new BufferedCache( new BufferedBagOStuff( new \HashBagOStuff() ), $wgFlowCacheTime ); + try { + $treeRepository = new TreeRepository( $this->mockDbFactory( false ), $cache ); + $this->assertNull( $treeRepository->insert( $this->descendant, $this->ancestor ) ); + } catch ( \Exception $e ) { + $reflection = new ReflectionClass( '\Flow\Repository\TreeRepository' ); + $method = $reflection->getMethod( 'cacheKey' ); + $method->setAccessible( true ); + + $this->assertSame( $cache->get( $method->invoke( $treeRepository, 'rootpath', $this->descendant ) ), false ); + $this->assertSame( $cache->get( $method->invoke( $treeRepository, 'parent', $this->descendant ) ), false ); + + throw $e; + } + } + + protected function mockDbFactory( $dbResult ) { + $dbFactory = $this->getMockBuilder( '\Flow\DbFactory' ) + ->disableOriginalConstructor() + ->getMock(); + $dbFactory->expects( $this->any() ) + ->method( 'getDB' ) + ->will( $this->returnValue( $this->mockDb( $dbResult) ) ); + return $dbFactory; + } + + protected function mockDb( $dbResult ) { + $db = $this->getMockBuilder( '\DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + $db->expects( $this->any() ) + ->method( 'insert' ) + ->will( $this->returnValue( $dbResult ) ); + $db->expects( $this->any() ) + ->method( 'insertSelect' ) + ->will( $this->returnValue( $dbResult ) ); + $db->expects( $this->any() ) + ->method( 'addQuotes' ) + ->will( $this->returnValue( '' ) ); + return $db; + } + +} diff --git a/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php b/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php new file mode 100644 index 00000000..c6a6a9e6 --- /dev/null +++ b/Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php @@ -0,0 +1,140 @@ +<?php + +namespace Flow\Tests\SpamFilter; + +use Flow\Model\PostRevision; +use Flow\SpamFilter\AbuseFilter; +use Flow\Tests\PostRevisionTestCase; +use Title; +use User; + +/** + * @group Database + * @group Flow + */ +class AbuseFilterTest extends PostRevisionTestCase { + /** + * @var AbuseFilter + */ + protected $spamFilter; + + /** + * @var array + */ + protected $tablesUsed = array( 'abuse_filter', 'abuse_filter_action', 'abuse_filter_history', 'abuse_filter_log' ); + + protected $filters = array( + // no CSS screen hijack + '(new_wikitext rlike "position\s*:\s*(fixed|absolute)|style\s*=\s*\"[a-z0-9:;\s]*&|z-index\s*:\s*\d|\|([4-9]\d{3}|\d{5,})px")' => 'disallow', + ); + + public function spamProvider() { + return array( + array( + // default new topic title revision - no spam + $this->generateObject(), + null, + true + ), + array( + // revision with spam + // https://www.mediawiki.org/w/index.php?title=Talk:Sandbox&workflow=050bbdd07b64a1c028b2782bcb087b42#flow-post-050bbdd07b70a1c028b2782bcb087b42 + $this->generateObject( array( 'rev_content' => '<div style="background: yellow; position: fixed; top: 0; left: 0; width: 3000px; height: 3000px; z-index: 1111;">test</div>', 'rev_flags' => 'html' ) ), + null, + false + ), + ); + } + + /** + * @dataProvider spamProvider + */ + public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) { + $title = Title::newFromText( 'UTPage' ); + + $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title ); + $this->assertEquals( $expected, $status->isOK() ); + } + + protected function setUp() { + parent::setUp(); + + global $wgFlowAbuseFilterGroup, + $wgFlowAbuseFilterEmergencyDisableThreshold, + $wgFlowAbuseFilterEmergencyDisableCount, + $wgFlowAbuseFilterEmergencyDisableAge; + + // Needed because abuse filter tries to read the title out and then + // set it back. If we never provide one it tries to set a null title + // and bails. + \RequestContext::getMain()->setTitle( Title::newMainPage() ); + + $user = User::newFromName( 'UTSysop' ); + + $this->spamFilter = new AbuseFilter( $user, $wgFlowAbuseFilterGroup ); + if ( !$this->spamFilter->enabled() ) { + $this->markTestSkipped( 'AbuseFilter not enabled' ); + } + + $this->spamFilter->setup( array( + 'threshold' => $wgFlowAbuseFilterEmergencyDisableThreshold, + 'count' => $wgFlowAbuseFilterEmergencyDisableCount, + 'age' => $wgFlowAbuseFilterEmergencyDisableAge, + ) ); + + foreach ( $this->filters as $pattern => $action ) { + $this->createFilter( $pattern, $action ); + } + } + + protected function tearDown() { + parent::tearDown(); + foreach ( $this->tablesUsed as $table ) { + $this->db->delete( $table, '*', __METHOD__ ); + } + } + + /** + * Inserts a filter into stub database. + * + * @param string $pattern + * @param string[optional] $action + */ + protected function createFilter( $pattern, $action = 'disallow' ) { + global $wgFlowAbuseFilterGroup; + $user = User::newFromName( 'UTSysop' ); + + $this->db->replace( + 'abuse_filter', + array( 'af_id' ), + array( +// 'af_id', + 'af_pattern' => $pattern, + 'af_user' => $user->getId(), + 'af_user_text' => $user->getName(), + 'af_timestamp' => wfTimestampNow(), + 'af_enabled' => 1, + 'af_comments' => null, + 'af_public_comments' => 'Test filter', + 'af_hidden' => 0, + 'af_hit_count' => 0, + 'af_throttled' => 0, + 'af_deleted' => 0, + 'af_actions' => $action, + 'af_group' => $wgFlowAbuseFilterGroup, + ), + __METHOD__ + ); + + $this->db->replace( + 'abuse_filter_action', + array( 'afa_filter' ), + array( + 'afa_filter' => $this->db->insertId(), + 'afa_consequence' => $action, + 'afa_parameters' => '', + ), + __METHOD__ + ); + } +} diff --git a/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php b/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php new file mode 100644 index 00000000..548d4291 --- /dev/null +++ b/Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php @@ -0,0 +1,36 @@ +<?php + +namespace Flow\Tests\SpamFilter; + + +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Flow\SpamFilter\ConfirmEdit; +use Title; +use User; + +class ConfirmEditTest extends \MediaWikiTestCase { + + public function testValidateDoesntBlowUp() { + $filter = new ConfirmEdit; + if ( !$filter->enabled() ) { + $this->markTestSkipped( 'ConfirmEdit is not enabled' ); + } + + $user = User::newFromName( '127.0.0.1', false ); + $title = Title::newMainPage(); + $workflow = Workflow::create( 'topic', $title ); + + $oldRevision = PostRevision::create( $workflow, $user, 'foo', 'wikitext' ); + $newRevision = $oldRevision->newNextRevision( $user, 'bar', 'wikitext', 'change-type', $title ); + + $context = $this->getMock( 'IContextSource' ); + $context->expects( $this->any() ) + ->method( 'getUser' ) + ->will( $this->returnValue( $user ) ); + + $status = $filter->validate( $context, $newRevision, $oldRevision, $title ); + $this->assertInstanceOf( 'Status', $status ); + $this->assertTrue( $status->isGood() ); + } +} diff --git a/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php b/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php new file mode 100644 index 00000000..bfa548cc --- /dev/null +++ b/Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php @@ -0,0 +1,58 @@ +<?php + +namespace Flow\Tests\SpamFilter; + +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Flow\SpamFilter\ContentLengthFilter; +use User; +use Title; + +/** + * @group Flow + */ +class ContentLengthFilterTest extends \MediaWikiTestCase { + /** + * @var SpamRegex + */ + protected $spamFilter; + + public function spamProvider() { + return array( + array( + 'With content shorter than max length allow through filter', + // expect + true, + // content + 'blah', + // max length + 100 + ), + + array( + 'With content longer than max length dissalow through filter', + // expect + false, + // content + 'blah', + // max length + 2 + ), + ); + } + + /** + * @dataProvider spamProvider + */ + public function testSpam( $message, $expect, $content, $maxLength ) { + $title = Title::newFromText( 'UTPage' ); + $user = User::newFromName( '127.0.0.1', false ); + $workflow = Workflow::create( 'topic', $title ); + $topic = PostRevision::create( $workflow, $user, 'title content', 'wikitext' ); + $reply = $topic->reply( $workflow, $user, $content, 'wikitext' ); + + $spamFilter = new ContentLengthFilter( $maxLength ); + $status = $spamFilter->validate( $this->getMock( 'IContextSource' ), $reply, null, $title ); + $this->assertEquals( $expect, $status->isOK() ); + } +} diff --git a/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php b/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php new file mode 100644 index 00000000..584d78be --- /dev/null +++ b/Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php @@ -0,0 +1,99 @@ +<?php + +namespace Flow\Tests\SpamFilter; + +use BaseBlacklist; +use Flow\Model\PostRevision; +use Flow\SpamFilter\SpamBlacklist; +use Flow\Tests\PostRevisionTestCase; +use Title; + +/** + * @group Flow + */ +class SpamBlacklistTest extends PostRevisionTestCase { + /** + * @var SpamBlacklist + */ + protected $spamFilter; + + /** + * Spam blacklist & whitelist regexes. Examples taken from: + * + * @see http://meta.wikimedia.org/wiki/Spam_blacklist + * @see http://en.wikipedia.org/wiki/MediaWiki:Spam-blacklist + * @see http://en.wikipedia.org/wiki/MediaWiki:Spam-whitelist + * + * @var array + */ + protected + $blacklist = array( '\b01bags\.com\b', 'sytes\.net' ), + $whitelist = array( 'a5b\.sytes\.net' ); + + public function spamProvider() { + return array( + array( + // default new topic title revision - no spam + $this->generateObject(), + null, + true + ), + array( + // revision with spam + $this->generateObject( array( 'rev_content' => 'http://01bags.com', 'rev_flags' => 'html' ) ), + null, + false + ), + array( + // revision with domain blacklisted as spam, but subdomain whitelisted + $this->generateObject( array( 'rev_content' => 'http://a5b.sytes.net', 'rev_flags' => 'html' ) ), + null, + true + ), + ); + } + + /** + * @dataProvider spamProvider + */ + public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) { + $title = Title::newFromText( 'UTPage' ); + + $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title ); + $this->assertEquals( $expected, $status->isOK() ); + } + + protected function setUp() { + parent::setUp(); + + // create spam filter + $this->spamFilter = new SpamBlacklist; + if ( !$this->spamFilter->enabled() ) { + $this->markTestSkipped( 'SpamBlacklist not enabled' ); + } + + $this->setMwGlobals( 'wgBlacklistSettings', array( + 'files' => array(), + ) ); + + // local spam lists are read from spam-blacklist & spam-whitelist + // messages, so change them for this test + $msgCache = \MessageCache::singleton(); + $msgCache->enable(); + $msgCache->replace( 'Spam-blacklist', implode( "\n", $this->blacklist ) ); + $msgCache->replace( 'Spam-whitelist', implode( "\n", $this->whitelist ) ); + // That only works if the spam blacklist is really reset + $instance = BaseBlacklist::getInstance( 'spam' ); + $reflProp = new \ReflectionProperty( $instance, 'regexes' ); + $reflProp->setAccessible( true ); + $reflProp->setValue( $instance, false ); + } + + protected function tearDown() { + // we don't have to restore the original messages, disable() will make + // sure they're ignored + $msgCache = \MessageCache::singleton(); + $msgCache->disable(); + parent::tearDown(); + } +} diff --git a/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php b/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php new file mode 100644 index 00000000..4537b08a --- /dev/null +++ b/Flow/tests/phpunit/SpamFilter/SpamRegexTest.php @@ -0,0 +1,58 @@ +<?php + +namespace Flow\Tests\SpamFilter; + +use Flow\Model\PostRevision; +use Flow\SpamFilter\SpamRegex; +use Flow\Tests\PostRevisionTestCase; +use Title; + +/** + * @group Flow + */ +class SpamRegexTest extends PostRevisionTestCase { + /** + * @var SpamRegex + */ + protected $spamFilter; + + public function spamProvider() { + return array( + array( + // default new topic title revision - no spam + $this->generateObject(), + null, + true + ), + array( + // revision with spam + $this->generateObject( array( 'rev_content' => 'http://spam', 'rev_flags' => 'html' ) ), + null, + false + ), + ); + } + + /** + * @dataProvider spamProvider + */ + public function testSpam( PostRevision $newRevision, PostRevision $oldRevision = null, $expected ) { + $title = Title::newFromText( 'UTPage' ); + + $status = $this->spamFilter->validate( $this->getMock( 'IContextSource' ), $newRevision, $oldRevision, $title ); + $this->assertEquals( $expected, $status->isOK() ); + } + + protected function setUp() { + parent::setUp(); + + // create a dummy filter + $this->setMwGlobals( 'wgSpamRegex', array( '/http:\/\/spam/' ) ); + + // create spam filter + $this->spamFilter = new SpamRegex; + if ( !$this->spamFilter->enabled() ) { + $this->markTestSkipped( 'SpamRegex not enabled' ); + } + } +} diff --git a/Flow/tests/phpunit/TemplateHelperTest.php b/Flow/tests/phpunit/TemplateHelperTest.php new file mode 100644 index 00000000..4e6adfe6 --- /dev/null +++ b/Flow/tests/phpunit/TemplateHelperTest.php @@ -0,0 +1,48 @@ +<?php + +namespace Flow\Tests; + +use Lightncandy; +use Flow\TemplateHelper; + +/** + * @group Flow + */ +class TemplateHelperTest extends \MediaWikiTestCase { + + public function provideTraversalAttackFilenames() { + return array_map( function( $x ) { return array( $x ); }, array( + '.', + '..', + './foo', + '../foo', + 'foo/./bar', + 'foo/../bar', + 'foo/bar/.', + 'foo/bar/..', + ) ); + } + + /** + * @dataProvider provideTraversalAttackFilenames + * @expectedException \Flow\Exception\FlowException + */ + public function testGetTemplateFilenamesTraversalAttack( $templateName ) { + $helper = new TemplateHelper( '/does/not/exist' ); + $helper->getTemplateFilenames( $templateName ); + } + + public function testIfCond() { + $code = TemplateHelper::compile( "{{#ifCond foo \"or\" bar}}Works{{/ifCond}}", '' ); + $renderer = Lightncandy::prepare( $code ); + + $this->assertEquals( 'Works', $renderer( array( 'foo' => true, 'bar' => false ) ) ); + $this->assertEquals( '', $renderer( array( 'foo' => false, 'bar' => false ) ) ); + /* + FIXME: Why won't this work!? + $code2 = TemplateHelper::compile( "{{#ifCond foo \"===\" bar}}Works{{/ifCond}}", '' ); + $renderer2 = Lightncandy::prepare( $code2 ); + $this->assertEquals( 'Works', $renderer2( array( 'foo' => 1, 'bar' => 1 ) ) ); + $this->assertEquals( '', $renderer2( array( 'foo' => 2, 'bar' => 3 ) ) );*/ + } +} diff --git a/Flow/tests/phpunit/TemplatingTest.php b/Flow/tests/phpunit/TemplatingTest.php new file mode 100644 index 00000000..31167ea7 --- /dev/null +++ b/Flow/tests/phpunit/TemplatingTest.php @@ -0,0 +1,73 @@ +<?php + +namespace Flow\Tests; + +use Flow\Model\PostRevision; +use Flow\Model\Workflow; +use Flow\Repository\UserNameBatch; +use Flow\Templating; +use Title; +use User; + +/** + * @group Flow + */ +class TemplatingTest extends \MediaWikiTestCase { + + protected function mockTemplating() { + $query = $this->getMock( 'Flow\Repository\UserName\UserNameQuery' ); + $usernames = new UserNameBatch( $query ); + $urlGenerator = $this->getMockBuilder( 'Flow\UrlGenerator' ) + ->disableOriginalConstructor() + ->getMock(); + $output = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $fixer = $this->getMockBuilder( 'Flow\Parsoid\ContentFixer' ) + ->disableOriginalConstructor() + ->getMock(); + $permissions = $this->getMockBuilder( 'Flow\RevisionActionPermissions' ) + ->disableOriginalConstructor() + ->getMock(); + + return new Templating( $usernames, $urlGenerator, $output, $fixer, $permissions ); + } + + /** + * There was a bug where all anonymous users got the same + * user links output, this checks that they are distinct. + */ + public function testNonRepeatingUserLinksForAnonymousUsers() { + $templating = $this->mockTemplating(); + + $user = User::newFromName( '127.0.0.1', false ); + $title = Title::newMainPage(); + $workflow = Workflow::create( 'topic', $title ); + $topicTitle = PostRevision::create( $workflow, $user, 'some content', 'wikitext' ); + + $hidden = $topicTitle->moderate( + $user, + $topicTitle::MODERATED_HIDDEN, + 'hide-topic', + 'hide and go seek' + ); + + $this->assertContains( + 'Special:Contributions/127.0.0.1', + $templating->getUserLinks( $hidden ), + 'User links should include anonymous contributions' + ); + + $hidden = $topicTitle->moderate( + User::newFromName( '10.0.0.2', false ), + $topicTitle::MODERATED_HIDDEN, + 'hide-topic', + 'hide and go seek' + ); + $this->assertContains( + 'Special:Contributions/10.0.0.2', + $templating->getUserLinks( $hidden ), + 'An alternate user should have the correct anonymous contributions' + ); + } +} diff --git a/Flow/tests/phpunit/UrlGeneratorTest.php b/Flow/tests/phpunit/UrlGeneratorTest.php new file mode 100644 index 00000000..341d1491 --- /dev/null +++ b/Flow/tests/phpunit/UrlGeneratorTest.php @@ -0,0 +1,114 @@ +<?php + +namespace Flow\Tests; + +use Flow\Container; +use Flow\Model\UUID; +use Title; + +/** + * @group Flow + */ +class UrlGeneratorTest extends FlowTestCase { + + protected $urlGenerator; + + protected function setUp() { + parent::setUp(); + $this->urlGenerator = Container::get( 'url_generator' ); + } + + public function provideDataBoardLink() { + return array ( + array( + Title::makeTitle( NS_MAIN, 'Test' ), + 'updated', + true + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + 'updated', + false + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + 'created', + true + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + 'created', + false + ) + ); + } + + /** + * @dataProvider provideDataBoardLink + */ + public function testBoardLink( Title $title, $sortBy = null, $saveSortBy = false ) { + $anchor = $this->urlGenerator->boardLink( $title, $sortBy, $saveSortBy ); + $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor ); + + $link = $anchor->getFullURL(); + $option = parse_url( $link ); + $this->assertArrayHasKey( 'query', $option ); + parse_str( $option['query'], $query ); + + if ( $sortBy !== null ) { + $this->assertEquals( $sortBy, $query['topiclist_sortby'] ); + if ( $saveSortBy ) { + $this->assertEquals( '1', $query['topiclist_savesortby'] ); + } + } + } + + public function provideDataWatchTopicLink() { + return array ( + array( + Title::makeTitle( NS_MAIN, 'Test' ), + UUID::create() + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + UUID::create() + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + UUID::create() + ), + array( + Title::makeTitle( NS_MAIN, 'Test' ), + UUID::create() + ) + ); + } + + /** + * @dataProvider provideDataWatchTopicLink + */ + public function testWatchTopicLink( Title $title, $workflowId ) { + $anchor = $this->urlGenerator->watchTopicLink( $title, $workflowId ); + $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor ); + + $link = $anchor->getFullURL(); + $option = parse_url( $link ); + $this->assertArrayHasKey( 'query', $option ); + parse_str( $option['query'], $query ); + $this->assertEquals( 'watch', $query['action'] ); + } + + /** + * @dataProvider provideDataWatchTopicLink + */ + public function testUnwatchTopicLink( Title $title, $workflowId ) { + $anchor = $this->urlGenerator->unwatchTopicLink( $title, $workflowId ); + $this->assertInstanceOf( '\Flow\Model\Anchor', $anchor ); + + $link = $anchor->getFullURL(); + $option = parse_url( $link ); + $this->assertArrayHasKey( 'query', $option ); + parse_str( $option['query'], $query ); + $this->assertEquals( 'unwatch', $query['action'] ); + } +} diff --git a/Flow/tests/phpunit/WatchedTopicItemsTest.php b/Flow/tests/phpunit/WatchedTopicItemsTest.php new file mode 100644 index 00000000..f6b5a62e --- /dev/null +++ b/Flow/tests/phpunit/WatchedTopicItemsTest.php @@ -0,0 +1,83 @@ +<?php + +namespace Flow\Tests; + +use Flow\Model\UUID; +use Flow\WatchedTopicItems; +use User; + +/** + * @group Flow + */ +class WatchedTopicItemTest extends FlowTestCase { + + public function provideDataGetWatchStatus() { + // number of test cases + $testCount = 10; + $tests = array(); + while ( $testCount > 0 ) { + $testCount--; + // number of uuid per test case + $uuidCount = 10; + $uuids = $dbResult = $result = array(); + while( $uuidCount > 0 ) { + $uuidCount--; + $uuid = UUID::create()->getAlphadecimal(); + $rand = rand( 0, 1 ); + // put in the query result + if ( $rand ) { + $dbResult[] = ( object )array( 'wl_title' => $uuid ); + $result[$uuid] = true; + } else { + $result[$uuid] = false; + } + $uuids[] = $uuid; + } + $dbResult = new \ArrayObject( $dbResult ); + $tests[] = array( $uuids, $dbResult->getIterator(), $result ); + } + + // attach empty uuids array to query + $uuids = $dbResult = $result = array(); + $emptyCount = 10; + while ( $emptyCount > 0 ) { + $emptyCount--; + $uuid = UUID::create()->getAlphadecimal(); + $dbResult[] = ( object )array( 'wl_title' => $uuid ); + } + $dbResult = new \ArrayObject( $dbResult ); + $tests[] = array( $uuids, $dbResult->getIterator(), $result ); + return $tests; + } + + /** + * @dataProvider provideDataGetWatchStatus + * + */ + public function testGetWatchStatus( $uuids, $dbResult, $result ) { + // give it a fake user id + $watchedTopicItems = new WatchedTopicItems( User::newFromId( 1 ), $this->mockDb( $dbResult ) ); + $res = $watchedTopicItems->getWatchStatus( $uuids ); + $this->assertEquals( count( $res ), count( $result ) ); + foreach ( $res as $key => $value ) { + $this->assertArrayHasKey( $key, $result ); + $this->assertEquals( $value, $result[$key] ); + } + + // false values for all uuids for anon users + $watchedTopicItems = new WatchedTopicItems( User::newFromId( 0 ), $this->mockDb( $dbResult ) ); + foreach ( $watchedTopicItems->getWatchStatus( $uuids ) as $value ) { + $this->assertFalse( $value ); + } + } + + protected function mockDb( $dbResult ) { + $db = $this->getMockBuilder( '\DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + $db->expects( $this->any() ) + ->method( 'select' ) + ->will( $this->returnValue( $dbResult ) ); + return $db; + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php b/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php new file mode 100644 index 00000000..a45e8c89 --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowEditHeaderTest extends ApiTestCase { + public function testEditHeader() { + $data = $this->doApiRequest( array( + 'page' => "Talk:Flow_QA", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'edit-header', + 'ehprev_revision' => '', + 'ehcontent' => '(._.)' + ) ); + + $result = $data[0]['flow']['edit-header']['result']['header']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $result ); + + $this->assertArrayHasKey( 'revision', $result, $debug ); + $revision = $result['revision']; + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'create-header', $revision['changeType'], $debug ); + $this->assertEquals( + '(._.)', + trim( strip_tags( $revision['content']['content'] ) ), + $debug + ); + $this->assertEquals( 'html', $revision['content']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowEditPostTest.php b/Flow/tests/phpunit/api/ApiFlowEditPostTest.php new file mode 100644 index 00000000..d4558ee7 --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowEditPostTest.php @@ -0,0 +1,49 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowEditPostTest extends ApiTestCase { + public function testEditPost() { + $result = $this->createTopic( 'result' ); + $workflowId = $result['roots'][0]; + $topicRevisionId = $result['posts'][$workflowId][0]; + $topic = $result['revisions'][$topicRevisionId]; + + $replyPostId = $topic['replies'][0]; + $replyRevisionId = $result['posts'][$replyPostId][0]; + + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'edit-post', + 'eppostId' => $replyPostId, + 'epprev_revision' => $replyRevisionId, + 'epcontent' => '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞' + ) ); + + $result = $data[0]['flow']['edit-post']['result']['topic']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $result ); + + $newRevisionId = $result['posts'][$replyPostId][0]; + $revision = $result['revisions'][$newRevisionId]; + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'edit-post', $revision['changeType'], $debug ); + $this->assertEquals( + '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞', + trim( strip_tags( $revision['content']['content'] ) ), + $debug + ); + $this->assertEquals( 'html', $revision['content']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php b/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php new file mode 100644 index 00000000..17b9a0fe --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowEditTitleTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowEditTitleTest extends ApiTestCase { + public function testEditTitle() { + $result = $this->createTopic( 'result' ); + $workflowId = $result['roots'][0]; + $revisionId = $result['posts'][$workflowId][0]; + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'edit-title', + 'etprev_revision' => $revisionId, + 'etcontent' => '(ノ◕ヮ◕)ノ*:・ ゚ ゚ ゚ ゚ ゚ ゚ ゚ ゚✧' + ) ); + + $result = $data[0]['flow']['edit-title']['result']['topic']; + + $this->assertArrayHasKey( 'errors', $result ); + $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) ); + + $revisionId = $result['posts'][$workflowId][0]; + $revision = $result['revisions'][$revisionId]; + $debug = json_encode( $revision ); + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'edit-title', $revision['changeType'], $debug ); + $this->assertEquals( '(ノ◕ヮ◕)ノ*:・ ゚ ゚ ゚ ゚ ゚ ゚ ゚ ゚✧', $revision['content']['content'], $debug ); + $this->assertEquals( 'plaintext', $revision['content']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php b/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php new file mode 100644 index 00000000..73a899ed --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php @@ -0,0 +1,40 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowEditTopicSummaryTest extends ApiTestCase { + public function testEditTopicSummary() { + $workflowId = $this->createTopic(); + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'edit-topic-summary', + 'etsprev_revision' => '', + 'etssummary' => '( ●_●)-((⌼===((() ≍≍≍≍≍ ♒ ✺ ♒ ZAP!' + ) ); + + $result = $data[0]['flow']['edit-topic-summary']['result']['topicsummary']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $result ); + + $revision = $result['revision']; + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'create-topic-summary', $revision['changeType'], $debug ); + $this->assertEquals( + '( ●_●)-((⌼===((() ≍≍≍≍≍ ♒ ✺ ♒ ZAP!', + trim( strip_tags( $revision['content']['content'] ) ), + $debug + ); + $this->assertEquals( 'html', $revision['content']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php b/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php new file mode 100644 index 00000000..7019a9da --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowLockTopicTest.php @@ -0,0 +1,78 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowLockTopicTest extends ApiTestCase { + public function testLockTopic() { + $workflowId = $this->createTopic(); + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'lock-topic', + 'cotmoderationState' => 'lock', + 'cotreason' => 'fiddle faddle', + 'cotprev_revision' => null, + ) ); + + $result = $data[0]['flow']['lock-topic']['result']['topic']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $debug ); + $this->assertArrayHasKey( 'workflowId', $result, $debug ); + $this->assertEquals( $workflowId, $result['workflowId'], $debug ); + $this->assertArrayHasKey( 'changeType', $result, $debug ); + $this->assertEquals( 'lock-topic', $result['changeType'], $debug ); + $this->assertArrayHasKey( 'isModerated', $result, $debug ); + $this->assertTrue( $result['isModerated'], $debug ); + $this->assertArrayHasKey( 'actions', $result, $debug ); + $this->assertArrayHasKey( 'unlock', $result['actions'], $debug ); + $this->assertArrayHasKey( 'moderateReason', $result, $debug ); + $this->assertEquals( 'fiddle faddle', $result['moderateReason']['content'], $debug ); + $this->assertEquals( 'plaintext', $result['moderateReason']['format'], $debug ); + } + + public function testUnlockTopic() { + $workflowId = $this->createTopic(); + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'lock-topic', + 'cotmoderationState' => 'lock', + 'cotreason' => 'fiddle faddle', + ) ); + $result = $data[0]['flow']['lock-topic']['result']['topic']; + $this->assertCount( 0, $result['errors'] ); + + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'lock-topic', + 'cotmoderationState' => 'unlock', + 'cotreason' => 'Ether', + ) ); + + $result = $data[0]['flow']['lock-topic']['result']['topic']; + $this->assertArrayHasKey( 'errors', $result ); + $this->assertCount( 0, $result['errors'] ); + $this->assertArrayHasKey( 'changeType', $result ); + $this->assertEquals( 'restore-topic', $result['changeType'] ); + $this->assertArrayHasKey( 'isModerated', $result ); + $this->assertFalse( $result['isModerated'] ); + $this->assertArrayHasKey( 'actions', $result ); + $this->assertArrayHasKey( 'lock', $result['actions'] ); + // Is this intentional? We don't display it by default + // but perhaps it should still be in the api output. + $this->assertArrayNotHasKey( 'moderateReason', $result ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php b/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php new file mode 100644 index 00000000..4ac36efb --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowModeratePostTest.php @@ -0,0 +1,54 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use Flow\Model\AbstractRevision; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowModeratePostTest extends ApiTestCase { + public function testModeratePost() { + $result = $this->createTopic( 'result' ); + $workflowId = $result['roots'][0]; + $topicRevisionId = $result['posts'][$workflowId][0]; + $topic = $result['revisions'][$topicRevisionId]; + $replyPostId = $topic['replies'][0]; + + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'moderate-post', + 'mpmoderationState' => AbstractRevision::MODERATED_HIDDEN, + 'mppostId' => $replyPostId, + 'mpreason' => '<>&{};' + ) ); + + $result = $data[0]['flow']['moderate-post']['result']['topic']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result ); + $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) ); + + $newRevisionId = $result['posts'][$replyPostId][0]; + $revision = $result['revisions'][$newRevisionId]; + $debug = json_encode( $revision ); + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'hide-post', $revision['changeType'], $debug ); + $this->assertArrayHasKey( 'isModerated', $revision, $debug ); + $this->assertTrue( $revision['isModerated'], $debug ); + $this->assertArrayHasKey( 'actions', $revision, $debug ); + $this->assertArrayHasKey( 'unhide', $revision['actions'], $debug ); + $this->assertArrayHasKey( 'moderateState', $revision, $debug ); + $this->assertEquals( AbstractRevision::MODERATED_HIDDEN, $revision['moderateState'], $debug ); + $this->assertArrayHasKey( 'moderateReason', $revision, $debug ); + $this->assertArrayHasKey( 'content', $revision['moderateReason'], $debug ); + $this->assertEquals( '<>&{};', $revision['moderateReason']['content'], $debug ); + $this->assertArrayHasKey( 'format', $revision['moderateReason'], $debug ); + $this->assertEquals( 'plaintext', $revision['moderateReason']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php b/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php new file mode 100644 index 00000000..4fd36913 --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php @@ -0,0 +1,71 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use Flow\Model\AbstractRevision; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowModerateTopicTest extends ApiTestCase { + protected $tablesUsed = array( + 'flow_ext_ref', + 'flow_revision', + 'flow_subscription', + 'flow_topic_list', + 'flow_tree_node', + 'flow_tree_revision', + 'flow_wiki_ref', + 'flow_workflow', + 'logging', + ); + + public function testModerateTopic() { + $workflowId = $this->createTopic(); + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'moderate-topic', + 'mtmoderationState' => AbstractRevision::MODERATED_DELETED, + 'mtreason' => '<>&{};' + ) ); + + $result = $data[0]['flow']['moderate-topic']['result']['topic']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result ); + $this->assertCount( 0, $result['errors'], json_encode( $result['errors'] ) ); + + $newRevisionId = $result['posts'][$workflowId][0]; + $revision = $result['revisions'][$newRevisionId]; + $debug = json_encode( $revision ); + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'delete-topic', $revision['changeType'], $debug ); + $this->assertArrayHasKey( 'isModerated', $revision, $debug ); + $this->assertTrue( $revision['isModerated'], $debug ); + $this->assertArrayHasKey( 'actions', $revision, $debug ); + $this->assertArrayHasKey( 'undelete', $revision['actions'], $debug ); + $this->assertArrayHasKey( 'moderateState', $revision, $debug ); + $this->assertEquals( AbstractRevision::MODERATED_DELETED, $revision['moderateState'], $debug ); + $this->assertArrayHasKey( 'moderateReason', $revision, $debug ); + $this->assertArrayHasKey( 'content', $revision['moderateReason'], $debug ); + $this->assertEquals( '<>&{};', $revision['moderateReason']['content'], $debug ); + $this->assertArrayHasKey( 'format', $revision['moderateReason'], $debug ); + $this->assertEquals( 'plaintext', $revision['moderateReason']['format'], $debug ); + + // make sure our moderated topic made it into Special:Log + $data = $this->doApiRequest( array( + 'action' => 'query', + 'list' => 'logevents', + ) ); + $debug = json_encode( $data ); + $logEntry = $data[0]['query']['logevents'][0]; + $logParams = isset( $logEntry['params'] ) ? $logEntry['params'] : $logEntry; + $this->assertArrayHasKey( 'topicId', $logParams, $debug ); + $this->assertEquals( $workflowId, $logParams['topicId'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowReplyTest.php b/Flow/tests/phpunit/api/ApiFlowReplyTest.php new file mode 100644 index 00000000..f69d4f4e --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowReplyTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowReplyTest extends ApiTestCase { + public function testTopLevelReply() { + $result = $this->createTopic( 'result' ); + $workflowId = $result['roots'][0]; + $topicRevId = $result['posts'][$workflowId][0]; + + $data = $this->doApiRequest( array( + 'page' => "Topic:$workflowId", + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'reply', + 'repreplyTo' => $workflowId, + 'repcontent' => '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞' + ) ); + + $result = $data[0]['flow']['reply']['result']['topic']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $result ); + + $newPostId = end( $result['revisions'][$topicRevId]['replies'] ); + $newRevisionId = $result['posts'][$newPostId][0]; + $revision = $result['revisions'][$newRevisionId]; + $this->assertArrayHasKey( 'changeType', $revision, $debug ); + $this->assertEquals( 'reply', $revision['changeType'], $debug ); + $this->assertEquals( + '⎛ ゚∩゚⎞⎛ ⍜⌒⍜⎞⎛ ゚⌒゚⎞', + trim( strip_tags( $revision['content']['content'] ) ), + $debug + ); + $this->assertEquals( 'html', $revision['content']['format'], $debug ); + } +} diff --git a/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php b/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php new file mode 100644 index 00000000..4f8c59aa --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php @@ -0,0 +1,79 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Container; +use FlowHooks; +use User; + +/** + * @group Flow + * @group medium + */ +class ApiFlowViewHeaderTest extends ApiTestCase { + public function testViewEmptyHeader() { + $data = $this->doApiRequest( array( + 'page' => "Talk:Flow_QA", + 'action' => 'flow', + 'submodule' => 'view-header', + ) ); + + $result = $data[0]['flow']['view-header']['result']['header']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $debug ); + + // a revision key should exist with only an action link + $this->assertArrayHasKey( 'revision', $result, $debug ); + $revision = $result['revision']; + $this->assertEmpty( $revision['links'], $debug ); + $this->assertEquals( array( 'edit' ), array_keys( $revision['actions'] ), $debug ); + $this->assertArrayNotHasKey( 'content', $revision ); + } + + public function testViewHeader() { + $data = $this->doApiRequest( array( + 'page' => 'Talk:Flow_QA', + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'edit-header', + 'ehprev_revision' => '', + 'ehcontent' => 'swimmingly', + ) ); + $result = $data[0]['flow']['edit-header']['result']['header']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $debug ); + + $data = $this->doApiRequest( array( + 'page' => "Talk:Flow_QA", + 'action' => 'flow', + 'submodule' => 'view-header', + 'vhcontentFormat' => 'html', + ) ); + $result = $data[0]['flow']['view-header']['result']['header']; + $debug = json_encode( $result ); + $this->assertArrayHasKey( 'errors', $result, $debug ); + $this->assertCount( 0, $result['errors'], $debug ); + $this->assertArrayHasKey( 'revision', $result ); + + $revision = $result['revision']; + $this->assertArrayHasKey( 'revisionId', $revision, $debug ); + $this->assertArrayHasKey( 'content', $revision, $debug ); + $this->assertArrayHasKey( 'content', $revision['content'], $debug ); + $this->assertEquals( + 'swimmingly', + trim( strip_tags( $revision['content']['content'] ) ), + $debug + ); + $this->assertArrayHasKey( 'format', $revision['content'], $debug ); + $this->assertEquals( 'html', $revision['content']['format'], $debug ); + } + + /** + * @todo + * + public function testViewHistorical() { + } + */ +} diff --git a/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php b/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php new file mode 100644 index 00000000..b2f70d7b --- /dev/null +++ b/Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php @@ -0,0 +1,255 @@ +<?php + +namespace Flow\Tests\Api; + +use Flow\Model\UUID; +use Title; + +/** + * @group Flow + * @group medium + */ +class ApiFlowViewTopicListTest extends ApiTestCase { + const TITLE_PREFIX = 'VTL Test '; + + public function testTocOnly() { + $topicData = array(); + for ( $i = 0; $i < 3; $i++ ) { + $title = self::TITLE_PREFIX . $i; + $topicData[$i]['response'] = $this->createTopic( 'result', $title ); + $topicData[$i]['id'] = $topicData[$i]['response']['roots'][0]; + $topicData[$i]['revisionId'] = $topicData[$i]['response']['posts'][$topicData[$i]['id']][0]; + $actualRevision = $topicData[$i]['response']['revisions'][$topicData[$i]['revisionId']]; + $topicData[$i]['expectedRevision'] = array( + 'content' => array( + 'content' => $title, + 'format' => 'plaintext' + ), + // This last_updated is used for the 'newest' test, then later changed for 'updated' test. + 'last_updated' => $actualRevision['last_updated'], + ); + } + + $flowQaTitle = Title::newFromText( 'Talk:Flow_QA' ); + + $expectedCommonResponse = array( + 'flow' => array( + 'view-topiclist' => array( + 'result' => array( + 'topiclist' => array( + 'submitted' => array( + 'savesortby' => false, + 'offset-dir' => 'fwd', + 'offset-id' => null, + 'offset' => null, + 'limit' => 2, + 'render' => false, + 'toconly' => true, + 'include-offset' => false, + ), + 'errors' => array(), + 'type' => 'topiclist', + ), + ), + 'status' => 'ok', + ), + ), + ); + + $expectedEmptyPageResponse = array_merge_recursive( array( + 'flow' => array( + 'view-topiclist' => array( + 'result' => array( + 'topiclist' => array( + 'submitted' => array( + 'sortby' => 'user', + ), + 'sortby' => 'newest', + 'roots' => array(), + 'posts' => array(), + 'revisions' => array(), + 'links' => array( + 'pagination' => array(), + ), + ), + ), + ), + ), + ), $expectedCommonResponse ); + + $actualEmptyPageResponse = $this->doApiRequest( + array( + 'action' => 'flow', + 'page' => 'Talk:Intentionally blank', + 'submodule' => 'view-topiclist', + 'vtllimit' => 2, + 'vtltoconly' => true, + ) + ); + $actualEmptyPageResponse = $actualEmptyPageResponse[0]; + + $this->assertEquals( + $expectedEmptyPageResponse, + $actualEmptyPageResponse, + 'TOC-only output for an empty, but occupied, Flow board' + ); + + $expectedNewestResponse = array_merge_recursive( array( + 'flow' => array( + 'view-topiclist' => array( + 'result' => array( + 'topiclist' => array( + 'submitted' => array( + 'sortby' => 'newest', + ), + 'sortby' => 'newest', + 'roots' => array( + $topicData[2]['id'], + $topicData[1]['id'], + ), + 'posts' => array( + $topicData[2]['id'] => $topicData[2]['response']['posts'][$topicData[2]['id']], + $topicData[1]['id'] => $topicData[1]['response']['posts'][$topicData[1]['id']], + ), + 'revisions' => array( + $topicData[2]['revisionId'] => $topicData[2]['expectedRevision'], + $topicData[1]['revisionId'] => $topicData[1]['expectedRevision'], + ), + 'links' => array( + 'pagination' => array( + 'fwd' => array( + 'url' => $flowQaTitle->getLinkURL( array( + 'topiclist_offset-dir' => 'fwd', + 'topiclist_limit' => '2', + 'topiclist_offset-id' => $topicData[1]['id'], + 'topiclist_sortby' => 'newest', + ) ), + 'title' => 'fwd', + 'text' => 'fwd', + ), + ), + ), + ), + ) + ) + ) + ), $expectedCommonResponse ); + + $actualNewestResponse = $this->doApiRequest( + array( + 'action' => 'flow', + 'page' => 'Talk:Flow QA', + 'submodule' => 'view-topiclist', + 'vtllimit' => 2, + 'vtlsortby' => 'newest', + 'vtltoconly' => true, + ) + ); + $actualNewestResponse = $actualNewestResponse[0]; + + $this->assertEquals( + $expectedNewestResponse, + $actualNewestResponse, + 'TOC-only output for "newest" order' + ); + + // Make it so update order is chronologically (1, 0, 2) + // We then expect it to be returned reverse chronologically (2, 0) + + $updateList = array( 1, 0, 2); + + foreach ( $updateList as $updateListInd => $topicDataInd ) { + $replyResponse = $this->doApiRequest( + array( + 'action' => 'flow', + 'page' => Title::makeTitle( NS_TOPIC, $topicData[$topicDataInd]['id'] )->getPrefixedText(), + 'submodule' => 'reply', + 'token' => $this->getEditToken(), + 'repreplyTo' => $topicData[$topicDataInd]['id'], + 'repcontent' => "Reply to topic $topicDataInd", + ) + ); + + // This is because we use timestamps with second granularity. + // Without this, the timestamp can be exactly the same + // for two topics, which means the ordering is undefined (and thus + // untestable). This was causing failures on Jenkins. + // + // Possible improvement: Make a simple class for getting the current + // time that normally calls wfTimestampNow. Have an alternative + // implementation for tests that can be controlled by an API like + // http://sinonjs.org/ (which we use on the client side). + // Pimple can be in charge of which is used. + if ( $updateListInd !== ( count( $updateList ) - 1 ) ) { + sleep( 1 ); + } + + $replyResponse = $replyResponse[0]; + + $responseTopic = $replyResponse['flow']['reply']['result']['topic']; + $topicRevisionId = $topicData[$topicDataInd]['revisionId']; + $newPostId = end( $responseTopic['revisions'][$topicRevisionId]['replies'] ); + $topicData[$topicDataInd]['updateTimestamp'] = UUID::create( $newPostId )->getTimestamp(); + $topicData[$topicDataInd]['expectedRevision']['last_updated'] = wfTimestamp( TS_UNIX, $topicData[$topicDataInd]['updateTimestamp'] ) * 1000; + } + + $expectedUpdatedResponse = array_merge_recursive( array( + 'flow' => array( + 'view-topiclist' => array( + 'result' => array( + 'topiclist' => array( + 'submitted' => array( + 'sortby' => 'updated', + ), + 'sortby' => 'updated', + 'roots' => array( + $topicData[2]['id'], + $topicData[0]['id'], + ), + 'posts' => array( + $topicData[2]['id'] => $topicData[2]['response']['posts'][$topicData[2]['id']], + $topicData[0]['id'] => $topicData[0]['response']['posts'][$topicData[0]['id']], + ), + 'revisions' => array( + $topicData[2]['revisionId'] => $topicData[2]['expectedRevision'], + $topicData[0]['revisionId'] => $topicData[0]['expectedRevision'], + ), + 'links' => array( + 'pagination' => array( + 'fwd' => array( + 'url' => $flowQaTitle->getLinkURL( array( + 'topiclist_offset-dir' => 'fwd', + 'topiclist_limit' => '2', + 'topiclist_offset' => $topicData[0]['updateTimestamp'], + 'topiclist_sortby' => 'updated', + ) ), + 'title' => 'fwd', + 'text' => 'fwd', + ), + ), + ), + ), + ) + ) + ) + ), $expectedCommonResponse ); + + $actualUpdatedResponse = $this->doApiRequest( + array( + 'action' => 'flow', + 'page' => 'Talk:Flow QA', + 'submodule' => 'view-topiclist', + 'vtllimit' => 2, + 'vtlsortby' => 'updated', + 'vtltoconly' => true, + ) + ); + $actualUpdatedResponse = $actualUpdatedResponse[0]; + + $this->assertEquals( + $expectedUpdatedResponse, + $actualUpdatedResponse, + 'TOC-only output for "updated" order' + ); + } +} diff --git a/Flow/tests/phpunit/api/ApiTestCase.php b/Flow/tests/phpunit/api/ApiTestCase.php new file mode 100644 index 00000000..402fe19c --- /dev/null +++ b/Flow/tests/phpunit/api/ApiTestCase.php @@ -0,0 +1,95 @@ +<?php + +namespace Flow\Tests\Api; + +use ApiTestCase as BaseApiTestCase; +use Flow\Container; +use FlowHooks; +use TestUser; +use User; + +/** + * @group Flow + * @group medium + */ +abstract class ApiTestCase extends BaseApiTestCase { + protected $tablesUsed = array( + 'flow_ext_ref', + 'flow_revision', + 'flow_subscription', + 'flow_topic_list', + 'flow_tree_node', + 'flow_tree_revision', + 'flow_wiki_ref', + 'flow_workflow', + ); + + protected function setUp() { + $this->setMwGlobals( 'wgFlowOccupyPages', array( + // For testing use; shared with browser tests + 'Talk:Flow QA', + + // Don't do any write operations on this. It's intentionally left + // blank for testing read operations on unused (but occupied) pages. + 'Talk:Intentionally blank', + ) ); + + Container::reset(); + parent::setUp(); + } + + protected function getEditToken( $user = null, $token = 'edittoken' ) { + $tokens = $this->getTokenList( $user ?: self::$users['sysop'] ); + return $tokens[$token]; + } + + /** + * Ensures Flow is reset before passing control on + * to parent::doApiRequest. Defaults all requests to + * the sysop user if not specified. + */ + protected function doApiRequest( + array $params, + array $session = null, + $appendModule = false, + User $user = null + ) { + if ( $user === null ) { + $user = self::$users['sysop']->user; + } + + // reset flow state before each request + FlowHooks::resetFlowExtension(); + Container::reset(); + $container = Container::getContainer(); + $container['user'] = $user; + return parent::doApiRequest( $params, $session, $appendModule, $user ); + } + + /** + * Create a topic on a board using the default user + */ + protected function createTopic( $return = '', $topicTitle = 'Hi there!' ) { + $data = $this->doApiRequest( array( + 'page' => 'Talk:Flow QA', + 'token' => $this->getEditToken(), + 'action' => 'flow', + 'submodule' => 'new-topic', + 'nttopic' => $topicTitle, + 'ntcontent' => '...', + ) ); + $this->assertTrue( + // @todo we should return the new id much more directly than this + isset( $data[0]['flow']['new-topic']['result']['topiclist']['roots'][0] ), + 'Api response must contain new topic id' + ); + + if ( $return === 'all' ) { + return $data; + } elseif ( $return === 'result' ) { + return $data[0]['flow']['new-topic']['result']['topiclist']; + } else { + return $data[0]['flow']['new-topic']['result']['topiclist']['roots'][0]; + } + } +} diff --git a/Flow/tests/phpunit/api/ApiWatchTopicTest.php b/Flow/tests/phpunit/api/ApiWatchTopicTest.php new file mode 100644 index 00000000..f4320aa0 --- /dev/null +++ b/Flow/tests/phpunit/api/ApiWatchTopicTest.php @@ -0,0 +1,54 @@ +<?php + +namespace Flow\Tests\Api; + +use Title; +use WatchedItem; + +/** + * @group Flow + * @group medium + */ +class ApiWatchTopicTest extends ApiTestCase { + + public function watchTopicProvider() { + return array( + array( + 'Watch a topic', + // expected key in api result + 'watched', + // initialization + function( WatchedItem $item ) { $item->removeWatch(); }, + // extra request parameters + array(), + ), + array( + 'Unwatch a topic', + // expected key in api result + 'unwatched', + // initialization + function( WatchedItem $item ) { $item->addWatch(); }, + // extra request parameters + array( 'unwatch' => 1 ), + ), + ); + } + + /** + * @dataProvider watchTopicProvider + */ + public function testWatchTopic( $message, $expect, $init, array $request ) { + $topicWorkflowId = $this->createTopic(); + $title = Title::newFromText( 'Topic:' . $topicWorkflowId ); + $init( WatchedItem::fromUserTitle( self::$users['sysop']->user, $title, false ) ); + + // issue a watch api request + $data = $this->doApiRequest( $request + array( + 'action' => 'watch', + 'format' => 'json', + 'titles' => "Topic:$topicWorkflowId", + 'token' => $this->getEditToken( null, 'watchtoken' ), + ) ); + $this->assertArrayHasKey( $expect, $data[0]['watch'][0], $message ); + } +} diff --git a/Flow/tests/phpunit/bootstrap.php b/Flow/tests/phpunit/bootstrap.php new file mode 100644 index 00000000..cddd5d5a --- /dev/null +++ b/Flow/tests/phpunit/bootstrap.php @@ -0,0 +1,19 @@ +<?php +/** + * Find the correct path to /tests/phpunit/bootstrap.php in core + * + * Takes MW_INSTALL_PATH environment variable into account. This is used by the + * test suite defined in mfe.suite.xml for MobileFrontend phpunit testing. + */ + +$IP = getenv( 'MW_INSTALL_PATH' ); +if ( $IP === false ) { + if ( realpath( '../..' ) ) { + $IP = realpath( '../..' ); + } else { + $IP = dirname( dirname( dirname( __DIR__ ) ) ); + } +} + +require_once( $IP . "/tests/phpunit/bootstrap.php" ); + diff --git a/Flow/tests/phpunit/flow.suite.xml b/Flow/tests/phpunit/flow.suite.xml new file mode 100644 index 00000000..a7bc5e64 --- /dev/null +++ b/Flow/tests/phpunit/flow.suite.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!-- colors don't work on Windows! --> +<phpunit bootstrap="bootstrap.php" + colors="true" + backupGlobals="false" + convertErrorsToExceptions="true" + convertNoticesToExceptions="true" + convertWarningsToExceptions="true" + stopOnFailure="false" + timeoutForSmallTests="2" + timeoutForMediumTests="10" + timeoutForLargeTests="60" + strict="true" + verbose="true"> + <testsuites> + <testsuite name="extensions"> + <file>../../../../tests/phpunit/suites/ExtensionsTestSuite.php</file> + </testsuite> + </testsuites> + <groups> + <exclude> + <group>Utility</group> + <group>Broken</group> + <group>ParserFuzz</group> + <group>Stub</group> + </exclude> + </groups> +</phpunit> diff --git a/Flow/tests/qunit/engine/components/board/test_flow-board.js b/Flow/tests/qunit/engine/components/board/test_flow-board.js new file mode 100644 index 00000000..eb2a8e32 --- /dev/null +++ b/Flow/tests/qunit/engine/components/board/test_flow-board.js @@ -0,0 +1,178 @@ +( function ( $ ) { +QUnit.module( 'ext.flow: Flow board' ); + +QUnit.test( 'Check Flow is running', 1, function() { + strictEqual( 1, 1, 'Test to see if Flow has a qunit test.' ); +} ); + +QUnit.module( 'ext.flow: FlowBoardComponent', { + setup: function() { + var stub, events; + + this.$el = $( '<div class="flow-component" data-flow-component="board">' ); + this.component = mw.flow.initComponent( this.$el ); + stub = this.sandbox.stub( this.component.Api, 'apiCall' ); + + stub.withArgs( { + action: 'flow', + submodule: 'view-topic', + workflow: 's18cjkj1bs3rkt13', + page: 'Topic:S18cjkj1bs3rkt13' + } ).returns( + $.Deferred().resolve( { + flow: { + 'view-topic': { + result: { + topic: { + roots: [ 's18cjkj1bs3rkt13' ], + posts: { + s18cjkj1bs3rkt13: '4' + }, + revisions: { + '4': { + content: { + format: 'html', + content: 'Hi' + }, + changeType: "lock-topic", + isModerated: false + } + } + } + } + } + } + } ) + ); + stub.withArgs( { + action: 'flow', + submodule: 'view-topic', + workflow: 't18cjkj1bs3rkt13', + page: 'Topic:T18cjkj1bs3rkt13' + } ).returns( + $.Deferred().resolve( { + flow: { + 'view-topic': { + result: { + topic: { + roots: [ 't18cjkj1bs3rkt13' ], + posts: { + t18cjkj1bs3rkt13: '4' + }, + revisions: { + '4': { + changeType: "restore-topic", + content: { + format: 'html', + content: 'Hi' + }, + isModerated: true, + moderateState: 'lock' + } + } + } + } + } + } + } ) + ); + + events = this.component.UI.events; + // This method is used to directly trigger callback methods + // It's needed, because the element we are triggering it from doesn't necessarily + // have the required data- attributes to cause the correct workflow + // @todo Correct these tests to test with real elements and their data attribs + this.triggerEvent = function ( handlerType, callbackName, context, args ) { + var returns = []; + args = Array.prototype.slice.call( arguments, 3 ); + + $.each( events[ handlerType ][ callbackName ], function ( i, callbackFn ) { + returns.push( callbackFn.apply( context, args ) ); + } ); + + return returns; + }; + } +} ); + +QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.lockTopic - perform unlock', 2, function( assert ) { + var + $topic = $( '<div class="flow-topic" data-flow-id="s18cjkj1bs3rkt13">' ). + addClass( 'flow-topic-moderatestate-lock flow-topic-moderated' ). + appendTo( this.$el ), + $titleBar = $( '<div class="flow-topic-titlebar">' ).appendTo( $topic ), + info = { status: 'done', $target: $topic }; + + this.triggerEvent( 'apiHandlers', 'lockTopic', $titleBar, info ); + $topic = this.$el.children( '.flow-topic' ); + assert.strictEqual( $topic.hasClass( 'flow-topic-moderated' ), false, 'No longer has the moderated state.' ); + assert.strictEqual( $topic.hasClass( 'flow-topic-moderatestate-lock' ), false, 'No longer has the moderated lock state.' ); +} ); + +QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.lockTopic - perform lock', 2, function( assert ) { + var + $topic = $( '<div class="flow-topic" data-flow-id="t18cjkj1bs3rkt13">' ). + appendTo( this.$el ), + $titleBar = $( '<div class="flow-topic-titlebar">' ).appendTo( $topic ), + info = { status: 'done', $target: $topic }; + + this.triggerEvent( 'apiHandlers', 'lockTopic', $titleBar, info ); + $topic = this.$el.children( '.flow-topic' ); + assert.strictEqual( $topic.hasClass( 'flow-topic-moderated' ), true, 'Has the moderated state.' ); + assert.strictEqual( $topic.hasClass( 'flow-topic-moderatestate-lock' ), true, 'Has the moderated lock state.' ); +} ); + +QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.preview', 3, function( assert ) { + var $container = this.$el, + $form = $( '<form>' ).appendTo( $container ), + $input = $( '<input value="HEADING">' ).appendTo( $form ), + $textarea = $( '<textarea data-flow-preview-template="flow_post">text</textarea>' ).appendTo( $form ), + $btn = $( '<button name="preview">' ). + appendTo( $form ), + info = { + $target: $textarea, + status: 'done' + }, + data = { + 'flow-parsoid-utils': { + format: 'html', + content: 'hello' + } + }; + + this.triggerEvent( 'apiHandlers', 'preview', $btn, info, data ); + + // check all is well. + assert.strictEqual( $container.find( '.flow-preview-warning' ).length, 1, 'There is a preview warning.' ); + assert.strictEqual( $textarea.hasClass( 'flow-preview-target-hidden' ), true, 'Textarea is hidden.' ); + assert.strictEqual( $input.hasClass( 'flow-preview-target-hidden' ), true, 'Input is hidden.' ); +} ); + +QUnit.test( 'FlowBoardComponent.UI.events.apiHandlers.preview (summary)', 3, function( assert ) { + var $container = this.$el, + $form = $( '<form>' ).appendTo( $container ), + $textarea = $( '<textarea data-flow-preview-template="flow_topic_titlebar_summary.partial" data-flow-preview-node="summary">text</textarea>' ).appendTo( $form ), + $btn = $( '<button name="preview">' ). + appendTo( $form ), + info = { + $target: $textarea, + status: 'done' + }, + data = { + 'flow-parsoid-utils': { + format: 'html', + content: 'hello' + } + }; + + this.triggerEvent( 'apiHandlers', 'preview', $btn, info, data ); + + // check all is well. + assert.strictEqual( $container.find( '.flow-preview-warning' ).length, 1, + 'There is a preview warning.' ); + assert.strictEqual( $container.find( '.flow-topic-summary' ).length, 1, 'Summary visible.' ); + assert.strictEqual( $.trim( $container.find( '.flow-topic-summary' ).text() ), + 'hello', 'Check content of summary.' ); +} ); + +} ( jQuery ) ); diff --git a/Flow/tests/qunit/engine/misc/test_flow-handlebars.js b/Flow/tests/qunit/engine/misc/test_flow-handlebars.js new file mode 100644 index 00000000..cc0f70f2 --- /dev/null +++ b/Flow/tests/qunit/engine/misc/test_flow-handlebars.js @@ -0,0 +1,153 @@ +( function ( $ ) { +QUnit.module( 'ext.flow: Handlebars helpers', { + setup: function() { + var stub = this.sandbox.stub( mw.template, 'get' ), + stubUser; + + stub.withArgs( 'ext.flow.templating', 'foo.handlebars' ).returns ( { + render: function( data ) { + return data && data.val ? '<div>Magic.</div>' : 'Stubbed.'; + } + } ); + this.handlebarsProto = mw.flow.FlowHandlebars.prototype; + this.handlebarsProto._qunit_helper_test = function( a, b ) { + return a + b; + }; + + // Stub user + stubUser = this.sandbox.stub( mw.user, 'isAnon' ); + stubUser.onCall( 0 ).returns( true ); + stubUser.onCall( 1 ).returns( false ); + this.opts = { + fn: function() { + return 'ok'; + }, + inverse: function() { + return 'nope'; + } + }; + } +} ); + +QUnit.test( 'Handlebars.prototype.processTemplate', 1, function( assert ) { + assert.strictEqual( this.handlebarsProto.processTemplate( 'foo', { val: 'Hello' } ), + '<div>Magic.</div>', 'Getting a template works.' ); +} ); + +QUnit.test( 'Handlebars.prototype.processTemplateGetFragment', 1, function( assert ) { + assert.strictEqual( this.handlebarsProto.processTemplateGetFragment( 'foo', { val: 'Hello' } ).childNodes.length, + 1, 'Return a fragment with the div child node' ); +} ); + +QUnit.test( 'Handlebars.prototype.getTemplate', 2, function( assert ) { + assert.strictEqual( this.handlebarsProto.getTemplate( 'foo' )(), 'Stubbed.', 'Getting a template works.' ); + assert.strictEqual( this.handlebarsProto.getTemplate( 'foo' )(), 'Stubbed.', 'Getting a template from cache works.' ); +} ); + +// Helpers +QUnit.test( 'Handlebars.prototype.callHelper', 1, function( assert ) { + assert.strictEqual( this.handlebarsProto.callHelper( '_qunit_helper_test', 1, 2 ), + 3, 'Check the helper was called.' ); +} ); + +QUnit.test( 'Handlebars.prototype.eachPost', 3, function( assert ) { + var ctx = { + posts: { + 1: [ 300 ], + // Purposely points to a missing revision to deal with edge case + 2: [ 500 ] + }, + revisions: { + 300: { content: 'a' } + } + }; + + assert.deepEqual( this.handlebarsProto.eachPost( ctx, 1, {} ), { content: 'a' }, 'Matches given id.' ); + assert.deepEqual( this.handlebarsProto.eachPost( ctx, 1, this.opts ), 'ok', 'Runs fn when given.' ); + assert.deepEqual( this.handlebarsProto.eachPost( ctx, 2, {} ), { content: null }, 'Missing revision id.' ); +} ); + +QUnit.test( 'Handlebars.prototype.ifCond', 8, function( assert ) { + assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '===', 'bar', this.opts ), 'nope', 'not equal' ); + assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '===', 'foo', this.opts ), 'ok', 'equal' ); + assert.strictEqual( this.handlebarsProto.ifCond( true, 'or', false, this.opts ), 'ok', 'true || false' ); + assert.strictEqual( this.handlebarsProto.ifCond( true, 'or', true, this.opts ), 'ok', 'true || true' ); + assert.strictEqual( this.handlebarsProto.ifCond( false, 'or', false, this.opts ), 'nope', 'false || false' ); + assert.strictEqual( this.handlebarsProto.ifCond( false, 'monkeypunch', this.opts ), '', 'Unknown operator' ); + assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '!==', 'foo', this.opts ), 'nope' ); + assert.strictEqual( this.handlebarsProto.ifCond( 'foo', '!==', 'bar', this.opts ), 'ok' ); +} ); + +QUnit.test( 'Handlebars.prototype.ifAnonymous', 2, function() { + strictEqual( this.handlebarsProto.ifAnonymous( this.opts ), 'ok', 'User should be anonymous first time.' ); + strictEqual( this.handlebarsProto.ifAnonymous( this.opts ), 'nope', 'User should be logged in on second call.' ); +} ); + +QUnit.test( 'Handlebars.prototype.concat', 2, function() { + strictEqual( this.handlebarsProto.concat( 'a', 'b', 'c', this.opts ), 'abc', 'Check concat working fine.' ); + strictEqual( this.handlebarsProto.concat( this.opts ), '', 'Without arguments.' ); +} ); + +QUnit.test( 'Handlebars.prototype.progressiveEnhancement', 5, function() { + var opts = $.extend( { hash: { type: 'insert', target: 'abc', id: 'def' } }, this.opts ), + $div = $( document.createElement( 'div' ) ); + + // Render script tag + strictEqual( + this.handlebarsProto.progressiveEnhancement( opts ).string, + '<scr' + 'ipt' + + ' type="text/x-handlebars-template-progressive-enhancement"' + + ' data-type="' + opts.hash.type + '"' + + ' data-target="' + opts.hash.target +'"' + + ' id="' + opts.hash.id + '">' + + 'ok' + + '</scr' + 'ipt>', + 'Should output exact replica of script tag.' + ); + + // Replace itself: no target (default to self), no type (default to insert) + $div.empty().append( this.handlebarsProto.processTemplateGetFragment( + Handlebars.compile( "{{#progressiveEnhancement}}hello{{/progressiveEnhancement}}" ) + ) ); + strictEqual( + $div.html(), + 'hello', + 'progressiveEnhancement should be processed in template string.' + ); + + // Replace a target entirely: target + type=replace + $div.empty().append( this.handlebarsProto.processTemplateGetFragment( + Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="replace"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' ) + ) ); + strictEqual( + $div.html(), + 'hello', + 'progressiveEnhancement should replace target node.' + ); + + // Insert before a target: target + type=insert + $div.empty().append( + this.handlebarsProto.processTemplateGetFragment( + Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="insert"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' ) + ) + ); + strictEqual( + $div.html(), + 'hello<div class="pgetest">foo</div>', + 'progressiveEnhancement should insert before target.' + ); + + // Replace target's content: target + type=content + $div.empty().append( + this.handlebarsProto.processTemplateGetFragment( + Handlebars.compile( '{{#progressiveEnhancement target="~ .pgetest" type="content"}}hello{{/progressiveEnhancement}}<div class="pgetest">foo</div>' ) + ) + ); + strictEqual( + $div.html(), + '<div class="pgetest">hello</div>', + 'progressiveEnhancement should replace target content.' + ); +} ); + +} ( jQuery ) ); diff --git a/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js b/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js new file mode 100644 index 00000000..bb784e8e --- /dev/null +++ b/Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js @@ -0,0 +1,128 @@ +( function ( $ ) { + QUnit.module( 'ext.flow: mediawiki.ui.enhance' ); + + QUnit.test( 'Forms with required fields have certain buttons disabled by default', 6, function( assert ) { + var $forms = [ + $( '<form><input class="mw-ui-input" required><button data-role="action" class="mw-ui-button">go</button></form>' ), + $( '<form><input class="mw-ui-input" required><button data-role="submit" class="mw-ui-button">go</button></form>' ), + $( '<form><textarea class="mw-ui-input"></textarea><input class="mw-ui-input"><button data-role="submit" class="mw-ui-button">go</button></form>' ), + $( '<form><textarea class="mw-ui-input" required></textarea><button data-role="submit" class="mw-ui-button">go</button></form>' ), + $( '<form><textarea class="mw-ui-input" required>foo</textarea><button data-role="submit" class="mw-ui-button">go</button></form>' ), + $( '<form><textarea class="mw-ui-input" required>foo</textarea><input class="mw-ui-input" required><button data-role="submit" class="mw-ui-button">go</button></form>' ) + ]; + + $.each( $forms, function() { + this.appendTo( '#qunit-fixture' ); + this.find( '.mw-ui-input' ).trigger( 'keyup' ); + } ); + + assert.strictEqual( $forms[0].find( 'button' ).is( ':disabled' ), true, + 'Buttons with data-role=action are disabled when required fields are empty.' ); + assert.strictEqual( $forms[1].find( 'button' ).is( ':disabled' ), true, + 'Buttons with data-role=action are disabled when required fields are empty.' ); + assert.strictEqual( $forms[2].find( 'button' ).is( ':disabled' ), false, + 'Buttons with are enabled when no required fields in form.' ); + assert.strictEqual( $forms[3].find( 'button' ).is( ':disabled' ), true, + 'Buttons are disabled when textarea is required but empty.' ); + assert.strictEqual( $forms[4].find( 'button' ).is( ':disabled' ), false, + 'Buttons are enabled when required textarea has text.' ); + assert.strictEqual( $forms[5].find( 'button' ).is( ':disabled' ), true, + 'Buttons are disabled when required textarea but required input does not.' ); + } ); + + QUnit.test( 'mw-ui-tooltip', 4, function( assert ) { + assert.ok( mw.tooltip, 'mw.tooltip exists' ); + + // Create a tooltip using body + $( 'body' ).attr( 'title', 'test' ); + assert.ok( mw.tooltip.show( $( 'body' ) ), 'mw.ui.tooltip.show returned something' ); + assert.strictEqual( $('.flow-ui-tooltip-content' ).filter(':contains("test"):visible').length, 1, + 'Tooltip with text "test" is visible' ); + mw.tooltip.hide( $( 'body' ) ); + assert.strictEqual( $('.flow-ui-tooltip-content' ).filter(':contains("test")').length, 0, + 'Tooltip with text "test" is removed' ); + $( 'body' ).attr( 'title', '' ); + } ); + + QUnit.test( 'mw-ui-modal', 15, function( assert ) { + var modal, $node; + + assert.ok( mw.tooltip, 'mw.Modal exists' ); + + // Instantiation + modal = mw.Modal(); + assert.strictEqual( modal.constructor, mw.Modal, + 'mw.Modal() returns mw.Modal instance' ); + + modal = new mw.Modal(); + assert.strictEqual( modal.constructor, mw.Modal, + 'new mw.Modal() returns mw.Modal instance' ); + + modal = mw.Modal( 'namefoo' ); + assert.strictEqual( modal.getName(), 'namefoo', + 'Modal sets name to "namefoo"' ); + + // Title + assert.strictEqual( modal.getNode().find( modal.headingSelector ).css( 'display' ), 'none', + 'Modal heading should be hidden with no title' ); + + modal = mw.Modal( { title: 'titlefoo' } ); + assert.strictEqual( modal.getNode().find( modal.headingSelector ).text().indexOf( 'titlefoo' ) > -1, true, + 'Modal instantiation sets title to "titlefoo"' ); + + modal.setTitle( 'titlebaz' ); + assert.strictEqual( modal.getNode().find( modal.headingSelector ).text().indexOf( 'titlebaz' ) > -1, true, + 'Modal setTitle to "titlebaz"' ); + + // Content at instantiation + modal = mw.Modal( { open: 'contentfoo' } ); + assert.strictEqual( modal.getContentNode().text(), 'contentfoo', + 'Modal instantiation sets content to "contentfoo"' ); + $node = modal.getNode(); + assert.strictEqual( $node.closest( 'body' ).length, 1, + 'Modal instantiation adds modal to body' ); + + // Close + modal.close(); + assert.strictEqual( $node.closest( 'body' ).length, 0, + 'Modal close removes it from page' ); + $node = null; + + // Content after instantiation + modal = mw.Modal(); + + modal.open( 'contentfoo' ); + assert.strictEqual( modal.getContentNode().html(), 'contentfoo', + 'Modal open string' ); + + modal.open( '<h1>contentfoo</h1>' ); + assert.strictEqual( modal.getContentNode().html(), '<h1>contentfoo</h1>', + 'Modal open html string' ); + + modal.open( $( '<h2>contentfoo</h2>' ) ); + assert.strictEqual( modal.getContentNode().html(), '<h2>contentfoo</h2>', + 'Modal open jQuery' ); + + // @todo content Array + // @todo content Object + + // Get nodes + assert.strictEqual( modal.getNode().length, 1, + 'getNode has length' ); + assert.strictEqual( modal.getContentNode().length, 1, + 'getContentNode has length' ); + + modal.close(); // kill the test modal + + // @todo setInteractiveHandler + // @todo addSteps + // @todo setStep + // @todo getSteps + // @todo prevOrClose + // @todo nextOrSubmit + // @todo prev + // @todo next + // @todo go + } ); + +} ( jQuery ) ); |