summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'Flow/tests')
-rw-r--r--Flow/tests/browser/README.md1
-rw-r--r--Flow/tests/browser/features/action_menu_permalink.feature31
-rw-r--r--Flow/tests/browser/features/anon_interface.feature9
-rw-r--r--Flow/tests/browser/features/edit_existing.feature22
-rw-r--r--Flow/tests/browser/features/flow_in_recent_changes.feature18
-rw-r--r--Flow/tests/browser/features/flow_logged_in.feature33
-rw-r--r--Flow/tests/browser/features/flow_no_javascript.feature21
-rw-r--r--Flow/tests/browser/features/lock_unlock_topics.feature48
-rw-r--r--Flow/tests/browser/features/moderation.feature44
-rw-r--r--Flow/tests/browser/features/new_topic.feature15
-rw-r--r--Flow/tests/browser/features/post_links.feature12
-rw-r--r--Flow/tests/browser/features/reply.feature30
-rw-r--r--Flow/tests/browser/features/reply_moderation.feature17
-rw-r--r--Flow/tests/browser/features/sorting_topics.feature19
-rw-r--r--Flow/tests/browser/features/step_definitions/action_menu_permalink_steps.rb41
-rw-r--r--Flow/tests/browser/features/step_definitions/edit_existing_steps.rb47
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_in_recent_changes_steps.rb11
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_no_javascript_steps.rb65
-rw-r--r--Flow/tests/browser/features/step_definitions/flow_steps.rb167
-rw-r--r--Flow/tests/browser/features/step_definitions/lock_unlock_topics_steps.rb75
-rw-r--r--Flow/tests/browser/features/step_definitions/moderation_steps.rb47
-rw-r--r--Flow/tests/browser/features/step_definitions/reply_moderation_steps.rb24
-rw-r--r--Flow/tests/browser/features/step_definitions/reply_steps.rb65
-rw-r--r--Flow/tests/browser/features/step_definitions/sorting_topics_steps.rb34
-rw-r--r--Flow/tests/browser/features/step_definitions/thank_steps.rb37
-rw-r--r--Flow/tests/browser/features/step_definitions/watch_steps.rb64
-rw-r--r--Flow/tests/browser/features/support/env.rb10
-rw-r--r--Flow/tests/browser/features/support/hooks.rb3
-rw-r--r--Flow/tests/browser/features/support/pages/flow_old_permalink_page.rb7
-rw-r--r--Flow/tests/browser/features/support/pages/flow_page.rb239
-rw-r--r--Flow/tests/browser/features/support/pages/new_flow_page.rb7
-rw-r--r--Flow/tests/browser/features/support/pages/recent_changes_page.rb8
-rw-r--r--Flow/tests/browser/features/support/pages/user_page.rb9
-rw-r--r--Flow/tests/browser/features/thank.feature23
-rw-r--r--Flow/tests/browser/features/watch.feature36
-rw-r--r--Flow/tests/externals/phantomjs-qunit-runner.js127
-rw-r--r--Flow/tests/phpunit/Block/TopicListTest.php58
-rw-r--r--Flow/tests/phpunit/BlockFactoryTest.php69
-rw-r--r--Flow/tests/phpunit/Collection/PostCollectionTest.php125
-rw-r--r--Flow/tests/phpunit/Collection/RevisionCollectionPermissionsTest.php290
-rw-r--r--Flow/tests/phpunit/ContainerTest.php44
-rw-r--r--Flow/tests/phpunit/Data/BagOStuff/BufferedBagOStuffTest.php273
-rw-r--r--Flow/tests/phpunit/Data/BagOStuff/LocalBufferedBagOStuffTest.php31
-rw-r--r--Flow/tests/phpunit/Data/BufferedCacheTest.php33
-rw-r--r--Flow/tests/phpunit/Data/CachingObjectMapperTest.php35
-rw-r--r--Flow/tests/phpunit/Data/Index/FeatureIndexTest.php104
-rw-r--r--Flow/tests/phpunit/Data/IndexTest.php111
-rw-r--r--Flow/tests/phpunit/Data/Listener/RecentChangesListenerTest.php78
-rw-r--r--Flow/tests/phpunit/Data/ManagerGroupTest.php66
-rw-r--r--Flow/tests/phpunit/Data/NothingTest.php66
-rw-r--r--Flow/tests/phpunit/Data/ObjectLocatorTest.php24
-rw-r--r--Flow/tests/phpunit/Data/Pager/PagerTest.php513
-rw-r--r--Flow/tests/phpunit/Data/RevisionStorageTest.php138
-rw-r--r--Flow/tests/phpunit/Data/Storage/RevisionStorageTest.php56
-rw-r--r--Flow/tests/phpunit/Data/UserNameBatchTest.php89
-rw-r--r--Flow/tests/phpunit/Data/UserNameListenerTest.php51
-rw-r--r--Flow/tests/phpunit/FlowActionsTest.php22
-rw-r--r--Flow/tests/phpunit/FlowTestCase.php29
-rw-r--r--Flow/tests/phpunit/Formatter/FormatterTest.php150
-rw-r--r--Flow/tests/phpunit/Formatter/RevisionFormatterTest.php165
-rw-r--r--Flow/tests/phpunit/Handlebars/FlowPostMetaActionsTest.php121
-rw-r--r--Flow/tests/phpunit/HookTest.php206
-rw-r--r--Flow/tests/phpunit/Import/ConverterTest.php101
-rw-r--r--Flow/tests/phpunit/Import/HistoricalUIDGeneratorTest.php33
-rw-r--r--Flow/tests/phpunit/Import/LiquidThreadsApi/ConversionStrategyTest.php157
-rw-r--r--Flow/tests/phpunit/Import/PageImportStateTest.php116
-rw-r--r--Flow/tests/phpunit/Import/TalkpageImportOperationTest.php164
-rw-r--r--Flow/tests/phpunit/Import/Wikitext/ConversionStrategyTest.php96
-rw-r--r--Flow/tests/phpunit/Import/Wikitext/ImportSourceTest.php50
-rw-r--r--Flow/tests/phpunit/LinksTableTest.php473
-rw-r--r--Flow/tests/phpunit/Mock/MockImportHeader.php34
-rw-r--r--Flow/tests/phpunit/Mock/MockImportPost.php50
-rw-r--r--Flow/tests/phpunit/Mock/MockImportRevision.php52
-rw-r--r--Flow/tests/phpunit/Mock/MockImportSource.php42
-rw-r--r--Flow/tests/phpunit/Mock/MockImportSummary.php30
-rw-r--r--Flow/tests/phpunit/Mock/MockImportTopic.php52
-rw-r--r--Flow/tests/phpunit/Model/PostRevisionTest.php53
-rw-r--r--Flow/tests/phpunit/Model/UUIDTest.php161
-rw-r--r--Flow/tests/phpunit/Model/UserTupleTest.php49
-rw-r--r--Flow/tests/phpunit/Notifications/NotifiedUsersTest.php142
-rw-r--r--Flow/tests/phpunit/PagerTest.php105
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/BadImageRemoverTest.php61
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/BaseHrefFixerTest.php36
-rw-r--r--Flow/tests/phpunit/Parsoid/Fixer/WikiLinkFixerTest.php93
-rw-r--r--Flow/tests/phpunit/Parsoid/ReferenceExtractorTest.php180
-rw-r--r--Flow/tests/phpunit/Parsoid/ReferenceFactoryTest.php32
-rw-r--r--Flow/tests/phpunit/Parsoid/UtilsTest.php113
-rw-r--r--Flow/tests/phpunit/PermissionsTest.php373
-rw-r--r--Flow/tests/phpunit/PostRevisionTestCase.php234
-rw-r--r--Flow/tests/phpunit/Repository/TreeRepositoryDbTest.php83
-rw-r--r--Flow/tests/phpunit/Repository/TreeRepositoryTest.php90
-rw-r--r--Flow/tests/phpunit/SpamFilter/AbuseFilterTest.php140
-rw-r--r--Flow/tests/phpunit/SpamFilter/ConfirmEditTest.php36
-rw-r--r--Flow/tests/phpunit/SpamFilter/ContentLengthFilterTest.php58
-rw-r--r--Flow/tests/phpunit/SpamFilter/SpamBlacklistTest.php99
-rw-r--r--Flow/tests/phpunit/SpamFilter/SpamRegexTest.php58
-rw-r--r--Flow/tests/phpunit/TemplateHelperTest.php48
-rw-r--r--Flow/tests/phpunit/TemplatingTest.php73
-rw-r--r--Flow/tests/phpunit/UrlGeneratorTest.php114
-rw-r--r--Flow/tests/phpunit/WatchedTopicItemsTest.php83
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditHeaderTest.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditPostTest.php49
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditTitleTest.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowEditTopicSummary.php40
-rw-r--r--Flow/tests/phpunit/api/ApiFlowLockTopicTest.php78
-rw-r--r--Flow/tests/phpunit/api/ApiFlowModeratePostTest.php54
-rw-r--r--Flow/tests/phpunit/api/ApiFlowModerateTopicTest.php71
-rw-r--r--Flow/tests/phpunit/api/ApiFlowReplyTest.php45
-rw-r--r--Flow/tests/phpunit/api/ApiFlowViewHeaderTest.php79
-rw-r--r--Flow/tests/phpunit/api/ApiFlowViewTopicListTest.php255
-rw-r--r--Flow/tests/phpunit/api/ApiTestCase.php95
-rw-r--r--Flow/tests/phpunit/api/ApiWatchTopicTest.php54
-rw-r--r--Flow/tests/phpunit/bootstrap.php19
-rw-r--r--Flow/tests/phpunit/flow.suite.xml29
-rw-r--r--Flow/tests/qunit/engine/components/board/test_flow-board.js178
-rw-r--r--Flow/tests/qunit/engine/misc/test_flow-handlebars.js153
-rw-r--r--Flow/tests/qunit/engine/misc/test_mw-ui.enhance.js128
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&amp;Bar" data-parsoid=\'{"stx":"simple","a":{"href":"./Foo&amp;Bar"},"sa":{"href":"Foo&amp;Bar"},"dsr":[0,11,2,2]}\'>Foo&amp;Bar</a>',
+ // expect string
+ '>Foo&amp;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 ) );