From 14f74d141ab5580688bfd46d2f74c026e43ed967 Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Wed, 1 Apr 2015 06:11:44 +0200 Subject: Update to MediaWiki 1.24.2 --- tests/phpunit/LessFileCompilationTest.php | 60 + tests/phpunit/Makefile | 91 + tests/phpunit/MediaWikiLangTestCase.php | 32 + tests/phpunit/MediaWikiPHPUnitTestListener.php | 129 + tests/phpunit/MediaWikiTestCase.php | 1141 +++++++++ tests/phpunit/README | 53 + tests/phpunit/ResourceLoaderTestCase.php | 95 + tests/phpunit/TODO | 20 + tests/phpunit/bootstrap.php | 36 + .../data/autoloader/TestAutoloadedCamlClass.php | 4 + .../data/autoloader/TestAutoloadedClass.php | 4 + .../data/autoloader/TestAutoloadedLocalClass.php | 4 + .../autoloader/TestAutoloadedSerializedClass.php | 4 + tests/phpunit/data/css/expected.css | 11 + tests/phpunit/data/css/simple-ltr.gif | Bin 0 -> 35 bytes tests/phpunit/data/css/simple-rtl.gif | Bin 0 -> 35 bytes tests/phpunit/data/css/test.css | 11 + tests/phpunit/data/cssmin/green.gif | Bin 0 -> 35 bytes tests/phpunit/data/cssmin/large.png | Bin 0 -> 36462 bytes tests/phpunit/data/cssmin/red.gif | Bin 0 -> 35 bytes tests/phpunit/data/db/mysql/functions.sql | 12 + tests/phpunit/data/db/postgres/functions.sql | 12 + tests/phpunit/data/db/sqlite/tables-1.13.sql | 342 +++ tests/phpunit/data/db/sqlite/tables-1.15.sql | 454 ++++ tests/phpunit/data/db/sqlite/tables-1.16.sql | 478 ++++ tests/phpunit/data/db/sqlite/tables-1.17.sql | 511 ++++ tests/phpunit/data/db/sqlite/tables-1.18.sql | 530 +++++ tests/phpunit/data/filerepo/video.png | Bin 0 -> 116 bytes tests/phpunit/data/filerepo/wiki.png | Bin 0 -> 22589 bytes .../data/gitinfo/info-testValidJsonData.json | 1 + .../data/less/common/test.common.mixins.less | 5 + tests/phpunit/data/less/module/dependency.less | 3 + tests/phpunit/data/less/module/styles.css | 6 + tests/phpunit/data/less/module/styles.less | 6 + tests/phpunit/data/localisationcache/en.json | 5 + tests/phpunit/data/localisationcache/ru.json | 4 + tests/phpunit/data/localisationcache/uk.json | 3 + tests/phpunit/data/media/1bit-png.png | Bin 0 -> 167 bytes .../Animated_PNG_example_bouncing_beach_ball.png | Bin 0 -> 72209 bytes tests/phpunit/data/media/Bishzilla_blink.gif | Bin 0 -> 39057 bytes tests/phpunit/data/media/Gtk-media-play-ltr.svg | 35 + tests/phpunit/data/media/LoremIpsum.djvu | Bin 0 -> 3249 bytes tests/phpunit/data/media/Png-native-test.png | Bin 0 -> 4665 bytes tests/phpunit/data/media/QA_icon.svg | 77 + tests/phpunit/data/media/README | 61 + tests/phpunit/data/media/Soccer_ball_animated.svg | 55 + tests/phpunit/data/media/Speech_bubbles.svg | 14 + tests/phpunit/data/media/Toll_Texas_1.svg | 150 ++ tests/phpunit/data/media/Tux.svg | 902 +++++++ .../media/US_states_by_total_state_tax_revenue.svg | 248 ++ tests/phpunit/data/media/Wikimedia-logo.svg | 14 + .../data/media/Xmp-exif-multilingual_test.jpg | Bin 0 -> 12544 bytes tests/phpunit/data/media/animated-xmp.gif | Bin 0 -> 3864 bytes tests/phpunit/data/media/animated.gif | Bin 0 -> 497 bytes tests/phpunit/data/media/broken_exif_date.jpg | Bin 0 -> 3233 bytes tests/phpunit/data/media/exif-gps.jpg | Bin 0 -> 665 bytes tests/phpunit/data/media/exif-user-comment.jpg | Bin 0 -> 484 bytes tests/phpunit/data/media/greyscale-na-png.png | Bin 0 -> 365 bytes tests/phpunit/data/media/greyscale-png.png | Bin 0 -> 415 bytes tests/phpunit/data/media/iptc-invalid-psir.jpg | Bin 0 -> 9574 bytes tests/phpunit/data/media/iptc-timetest-invalid.jpg | Bin 0 -> 9573 bytes tests/phpunit/data/media/iptc-timetest.jpg | Bin 0 -> 9573 bytes tests/phpunit/data/media/jpeg-comment-binary.jpg | Bin 0 -> 448 bytes .../phpunit/data/media/jpeg-comment-iso8859-1.jpg | Bin 0 -> 447 bytes tests/phpunit/data/media/jpeg-comment-multiple.jpg | Bin 0 -> 431 bytes tests/phpunit/data/media/jpeg-comment-utf.jpg | Bin 0 -> 445 bytes tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg | Bin 0 -> 499 bytes tests/phpunit/data/media/jpeg-iptc-good-hash.jpg | Bin 0 -> 499 bytes tests/phpunit/data/media/jpeg-padding-even.jpg | Bin 0 -> 450 bytes tests/phpunit/data/media/jpeg-padding-odd.jpg | Bin 0 -> 451 bytes tests/phpunit/data/media/jpeg-xmp-alt.jpg | Bin 0 -> 3255 bytes tests/phpunit/data/media/jpeg-xmp-psir.jpg | Bin 0 -> 3308 bytes tests/phpunit/data/media/jpeg-xmp-psir.xmp | 35 + tests/phpunit/data/media/landscape-plain.jpg | Bin 0 -> 38771 bytes tests/phpunit/data/media/nonanimated.gif | Bin 0 -> 200 bytes tests/phpunit/data/media/portrait-rotated.jpg | Bin 0 -> 38577 bytes tests/phpunit/data/media/rgb-na-png.png | Bin 0 -> 593 bytes tests/phpunit/data/media/rgb-png.png | Bin 0 -> 663 bytes tests/phpunit/data/media/say-test.ogg | Bin 0 -> 5132 bytes tests/phpunit/data/media/test.jpg | Bin 0 -> 437 bytes tests/phpunit/data/media/test.tiff | Bin 0 -> 566 bytes tests/phpunit/data/media/xmp.png | Bin 0 -> 582 bytes tests/phpunit/data/parser/LoremIpsum.djvu | Bin 0 -> 3249 bytes tests/phpunit/data/parser/headbg.jpg | Bin 0 -> 7881 bytes tests/phpunit/data/parser/wiki.png | Bin 0 -> 22589 bytes tests/phpunit/data/upload/headbg.jpg | Bin 0 -> 7881 bytes tests/phpunit/data/xmp/1.result.php | 8 + tests/phpunit/data/xmp/1.xmp | 11 + tests/phpunit/data/xmp/2.result.php | 8 + tests/phpunit/data/xmp/2.xmp | 12 + tests/phpunit/data/xmp/3-invalid.result.php | 7 + tests/phpunit/data/xmp/3-invalid.xmp | 31 + tests/phpunit/data/xmp/3.result.php | 8 + tests/phpunit/data/xmp/3.xmp | 29 + tests/phpunit/data/xmp/4.result.php | 7 + tests/phpunit/data/xmp/4.xmp | 22 + tests/phpunit/data/xmp/5.result.php | 7 + tests/phpunit/data/xmp/5.xmp | 16 + tests/phpunit/data/xmp/6.result.php | 8 + tests/phpunit/data/xmp/6.xmp | 18 + tests/phpunit/data/xmp/7.result.php | 52 + tests/phpunit/data/xmp/7.xmp | 67 + tests/phpunit/data/xmp/README | 3 + tests/phpunit/data/xmp/bag-for-seq.result.php | 10 + tests/phpunit/data/xmp/bag-for-seq.xmp | 1 + tests/phpunit/data/xmp/doctype-included.result.php | 3 + tests/phpunit/data/xmp/doctype-included.xmp | 12 + tests/phpunit/data/xmp/doctype-not-included.xmp | 11 + tests/phpunit/data/xmp/flash.result.php | 8 + tests/phpunit/data/xmp/flash.xmp | 11 + tests/phpunit/data/xmp/gps.result.php | 11 + tests/phpunit/data/xmp/gps.xmp | 17 + .../data/xmp/invalid-child-not-struct.result.php | 7 + .../phpunit/data/xmp/invalid-child-not-struct.xmp | 12 + tests/phpunit/data/xmp/no-namespace.result.php | 7 + tests/phpunit/data/xmp/no-namespace.xmp | 11 + .../data/xmp/no-recognized-props.result.php | 2 + tests/phpunit/data/xmp/no-recognized-props.xmp | 8 + tests/phpunit/data/xmp/utf16BE.result.php | 12 + tests/phpunit/data/xmp/utf16BE.xmp | Bin 0 -> 930 bytes tests/phpunit/data/xmp/utf16LE.result.php | 12 + tests/phpunit/data/xmp/utf16LE.xmp | Bin 0 -> 930 bytes tests/phpunit/data/xmp/utf32BE.result.php | 12 + tests/phpunit/data/xmp/utf32BE.xmp | Bin 0 -> 1856 bytes tests/phpunit/data/xmp/utf32LE.result.php | 12 + tests/phpunit/data/xmp/utf32LE.xmp | Bin 0 -> 1856 bytes tests/phpunit/data/xmp/xmpExt.result.php | 8 + tests/phpunit/data/xmp/xmpExt.xmp | 13 + tests/phpunit/data/xmp/xmpExt2.xmp | 8 + tests/phpunit/data/zip/cd-gap.zip | Bin 0 -> 182 bytes tests/phpunit/data/zip/cd-truncated.zip | Bin 0 -> 171 bytes tests/phpunit/data/zip/class-trailing-null.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/class-trailing-slash.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/class.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/empty.zip | Bin 0 -> 22 bytes tests/phpunit/data/zip/looks-like-zip64.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/nosig.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/split.zip | Bin 0 -> 196 bytes tests/phpunit/data/zip/trail.zip | Bin 0 -> 181 bytes tests/phpunit/data/zip/wrong-cd-start-disk.zip | Bin 0 -> 173 bytes tests/phpunit/data/zip/wrong-central-entry-sig.zip | Bin 0 -> 173 bytes tests/phpunit/docs/ExportDemoTest.php | 31 + tests/phpunit/includes/ArrayUtilsTest.php | 311 +++ tests/phpunit/includes/ArticleTablesTest.php | 53 + tests/phpunit/includes/ArticleTest.php | 95 + tests/phpunit/includes/BlockTest.php | 368 +++ tests/phpunit/includes/CollationTest.php | 117 + tests/phpunit/includes/DiffHistoryBlobTest.php | 40 + tests/phpunit/includes/EditPageTest.php | 499 ++++ tests/phpunit/includes/ExternalStoreTest.php | 87 + tests/phpunit/includes/ExtraParserTest.php | 218 ++ tests/phpunit/includes/FallbackTest.php | 72 + tests/phpunit/includes/FauxRequestTest.php | 18 + tests/phpunit/includes/FauxResponseTest.php | 118 + .../includes/FormOptionsInitializationTest.php | 89 + tests/phpunit/includes/FormOptionsTest.php | 103 + tests/phpunit/includes/GitInfoTest.php | 42 + .../includes/GlobalFunctions/GlobalTest.php | 745 ++++++ .../includes/GlobalFunctions/GlobalWithDBTest.php | 32 + tests/phpunit/includes/GlobalFunctions/README | 2 + .../includes/GlobalFunctions/wfAssembleUrlTest.php | 112 + .../includes/GlobalFunctions/wfBCP47Test.php | 121 + .../includes/GlobalFunctions/wfBaseConvertTest.php | 195 ++ .../includes/GlobalFunctions/wfBaseNameTest.php | 40 + .../includes/GlobalFunctions/wfExpandUrlTest.php | 117 + .../includes/GlobalFunctions/wfGetCallerTest.php | 46 + .../includes/GlobalFunctions/wfParseUrlTest.php | 157 ++ .../GlobalFunctions/wfRemoveDotSegmentsTest.php | 93 + .../includes/GlobalFunctions/wfShellExecTest.php | 20 + .../GlobalFunctions/wfShorthandToIntegerTest.php | 31 + .../includes/GlobalFunctions/wfTimestampTest.php | 196 ++ .../includes/GlobalFunctions/wfUrlencodeTest.php | 124 + tests/phpunit/includes/HooksTest.php | 202 ++ tests/phpunit/includes/HtmlFormatterTest.php | 127 + tests/phpunit/includes/HtmlTest.php | 773 ++++++ tests/phpunit/includes/HttpTest.php | 216 ++ tests/phpunit/includes/ImagePage404Test.php | 53 + tests/phpunit/includes/ImagePageTest.php | 90 + tests/phpunit/includes/ImportTest.php | 101 + tests/phpunit/includes/LanguageConverterTest.php | 187 ++ tests/phpunit/includes/LicensesTest.php | 25 + tests/phpunit/includes/LinkFilterTest.php | 274 +++ tests/phpunit/includes/LinkerTest.php | 192 ++ tests/phpunit/includes/LinksUpdateTest.php | 266 +++ tests/phpunit/includes/LocalFileTest.php | 184 ++ tests/phpunit/includes/MWFunctionTest.php | 33 + tests/phpunit/includes/MWNamespaceTest.php | 612 +++++ tests/phpunit/includes/MWTimestampTest.php | 342 +++ .../includes/MediaWikiVersionFetcherTest.php | 21 + tests/phpunit/includes/MessageTest.php | 368 +++ tests/phpunit/includes/MimeMagicTest.php | 49 + tests/phpunit/includes/OutputPageTest.php | 273 +++ tests/phpunit/includes/PasswordTest.php | 33 + tests/phpunit/includes/PathRouterTest.php | 264 +++ tests/phpunit/includes/PreferencesTest.php | 91 + tests/phpunit/includes/RequestContextTest.php | 96 + tests/phpunit/includes/RevisionStorageTest.php | 574 +++++ .../RevisionStorageTestContentHandlerUseDB.php | 89 + tests/phpunit/includes/RevisionTest.php | 506 ++++ tests/phpunit/includes/SampleTest.php | 108 + tests/phpunit/includes/SanitizerTest.php | 349 +++ .../includes/SanitizerValidateEmailTest.php | 103 + tests/phpunit/includes/SiteConfigurationTest.php | 363 +++ tests/phpunit/includes/SpecialPageTest.php | 105 + tests/phpunit/includes/StatusTest.php | 573 +++++ tests/phpunit/includes/TemplateCategoriesTest.php | 96 + tests/phpunit/includes/TestUser.php | 62 + tests/phpunit/includes/TimeAdjustTest.php | 39 + .../phpunit/includes/TitleArrayFromResultTest.php | 119 + tests/phpunit/includes/TitleMethodsTest.php | 300 +++ tests/phpunit/includes/TitlePermissionTest.php | 770 ++++++ tests/phpunit/includes/TitleTest.php | 650 +++++ tests/phpunit/includes/UserArrayFromResultTest.php | 114 + tests/phpunit/includes/UserTest.php | 369 +++ tests/phpunit/includes/WebRequestTest.php | 358 +++ tests/phpunit/includes/WikiPageTest.php | 1301 ++++++++++ .../includes/WikiPageTestContentHandlerUseDB.php | 61 + tests/phpunit/includes/XmlJsTest.php | 24 + tests/phpunit/includes/XmlSelectTest.php | 185 ++ tests/phpunit/includes/XmlTest.php | 411 ++++ tests/phpunit/includes/XmlTypeCheckTest.php | 49 + tests/phpunit/includes/actions/ActionTest.php | 199 ++ tests/phpunit/includes/api/ApiBaseTest.php | 46 + tests/phpunit/includes/api/ApiBlockTest.php | 83 + .../phpunit/includes/api/ApiCreateAccountTest.php | 161 ++ tests/phpunit/includes/api/ApiEditPageTest.php | 496 ++++ tests/phpunit/includes/api/ApiLoginTest.php | 181 ++ tests/phpunit/includes/api/ApiMainTest.php | 72 + .../phpunit/includes/api/ApiModuleManagerTest.php | 318 +++ tests/phpunit/includes/api/ApiOptionsTest.php | 459 ++++ tests/phpunit/includes/api/ApiParseTest.php | 35 + tests/phpunit/includes/api/ApiPurgeTest.php | 45 + .../phpunit/includes/api/ApiQueryAllPagesTest.php | 34 + .../phpunit/includes/api/ApiRevisionDeleteTest.php | 114 + tests/phpunit/includes/api/ApiTestCase.php | 196 ++ tests/phpunit/includes/api/ApiTestCaseUpload.php | 171 ++ tests/phpunit/includes/api/ApiTestContext.php | 21 + tests/phpunit/includes/api/ApiTokensTest.php | 40 + tests/phpunit/includes/api/ApiUnblockTest.php | 31 + tests/phpunit/includes/api/ApiUploadTest.php | 572 +++++ tests/phpunit/includes/api/ApiWatchTest.php | 157 ++ tests/phpunit/includes/api/MockApi.php | 20 + tests/phpunit/includes/api/MockApiQueryBase.php | 11 + .../phpunit/includes/api/PrefixUniquenessTest.php | 30 + .../phpunit/includes/api/RandomImageGenerator.php | 496 ++++ tests/phpunit/includes/api/UserWrapper.php | 25 + .../includes/api/format/ApiFormatJsonTest.php | 22 + .../includes/api/format/ApiFormatNoneTest.php | 16 + .../includes/api/format/ApiFormatPhpTest.php | 17 + .../includes/api/format/ApiFormatTestBase.php | 32 + .../includes/api/format/ApiFormatWddxTest.php | 20 + .../phpunit/includes/api/generateRandomImages.php | 46 + .../includes/api/query/ApiQueryBasicTest.php | 353 +++ .../includes/api/query/ApiQueryContinue2Test.php | 71 + .../includes/api/query/ApiQueryContinueTest.php | 316 +++ .../api/query/ApiQueryContinueTestBase.php | 218 ++ .../includes/api/query/ApiQueryRevisionsTest.php | 40 + tests/phpunit/includes/api/query/ApiQueryTest.php | 130 + .../includes/api/query/ApiQueryTestBase.php | 148 ++ tests/phpunit/includes/api/words.txt | 1000 ++++++++ tests/phpunit/includes/cache/GenderCacheTest.php | 104 + .../includes/cache/LocalisationCacheTest.php | 91 + tests/phpunit/includes/cache/MessageCacheTest.php | 128 + .../phpunit/includes/cache/RedisBloomCacheTest.php | 71 + .../includes/changes/EnhancedChangesListTest.php | 132 ++ .../includes/changes/OldChangesListTest.php | 187 ++ .../includes/changes/RCCacheEntryFactoryTest.php | 331 +++ .../phpunit/includes/changes/RecentChangeTest.php | 286 +++ .../includes/changes/TestRecentChangesHelper.php | 137 ++ .../composer/ComposerVersionNormalizerTest.php | 161 ++ .../phpunit/includes/config/ConfigFactoryTest.php | 70 + .../includes/config/GlobalVarConfigTest.php | 120 + tests/phpunit/includes/config/HashConfigTest.php | 63 + tests/phpunit/includes/config/MultiConfigTest.php | 38 + .../includes/content/ContentHandlerTest.php | 525 +++++ tests/phpunit/includes/content/CssContentTest.php | 87 + .../includes/content/JavaScriptContentTest.php | 293 +++ tests/phpunit/includes/content/JsonContentTest.php | 114 + tests/phpunit/includes/content/TextContentTest.php | 490 ++++ .../content/WikitextContentHandlerTest.php | 241 ++ .../includes/content/WikitextContentTest.php | 433 ++++ .../phpunit/includes/db/DatabaseMysqlBaseTest.php | 247 ++ tests/phpunit/includes/db/DatabaseSQLTest.php | 725 ++++++ tests/phpunit/includes/db/DatabaseSqliteTest.php | 455 ++++ tests/phpunit/includes/db/DatabaseTest.php | 237 ++ tests/phpunit/includes/db/DatabaseTestHelper.php | 170 ++ tests/phpunit/includes/db/LBFactoryTest.php | 61 + tests/phpunit/includes/db/ORMRowTest.php | 226 ++ tests/phpunit/includes/db/ORMTableTest.php | 150 ++ tests/phpunit/includes/db/TestORMRowTest.php | 218 ++ tests/phpunit/includes/debug/MWDebugTest.php | 141 ++ .../includes/deferred/DeferredUpdatesTest.php | 38 + .../includes/diff/ArrayDiffFormatterTest.php | 135 ++ tests/phpunit/includes/diff/DiffOpTest.php | 73 + tests/phpunit/includes/diff/DiffTest.php | 20 + .../phpunit/includes/diff/DifferenceEngineTest.php | 121 + tests/phpunit/includes/diff/FakeDiffOp.php | 11 + .../includes/exception/BadTitleErrorTest.php | 43 + .../includes/exception/ErrorPageErrorTest.php | 67 + .../includes/exception/MWExceptionHandlerTest.php | 74 + .../phpunit/includes/exception/MWExceptionTest.php | 241 ++ .../includes/exception/ReadOnlyErrorTest.php | 16 + .../includes/exception/ThrottledErrorTest.php | 44 + .../includes/exception/UserNotLoggedInTest.php | 16 + .../includes/filebackend/FileBackendTest.php | 2472 ++++++++++++++++++++ tests/phpunit/includes/filerepo/FileRepoTest.php | 55 + tests/phpunit/includes/filerepo/RepoGroupTest.php | 59 + tests/phpunit/includes/filerepo/StoreBatchTest.php | 146 ++ tests/phpunit/includes/filerepo/file/FileTest.php | 386 +++ .../htmlform/HTMLAutoCompleteSelectFieldTest.php | 68 + .../includes/htmlform/HTMLCheckMatrixTest.php | 105 + .../includes/installer/InstallDocFormatterTest.php | 72 + .../includes/installer/OracleInstallerTest.php | 52 + tests/phpunit/includes/jobqueue/JobQueueTest.php | 344 +++ .../jobqueue/RefreshLinksPartitionTest.php | 112 + tests/phpunit/includes/json/FormatJsonTest.php | 279 +++ tests/phpunit/includes/libs/CSSMinTest.php | 401 ++++ .../includes/libs/GenericArrayObjectTest.php | 280 +++ tests/phpunit/includes/libs/HashRingTest.php | 56 + tests/phpunit/includes/libs/IEUrlExtensionTest.php | 173 ++ tests/phpunit/includes/libs/IPSetTest.php | 252 ++ .../includes/libs/JavaScriptMinifierTest.php | 204 ++ tests/phpunit/includes/libs/MWMessagePackTest.php | 75 + .../phpunit/includes/libs/ProcessCacheLRUTest.php | 237 ++ tests/phpunit/includes/libs/RunningStatTest.php | 79 + .../phpunit/includes/logging/LogFormatterTest.php | 242 ++ tests/phpunit/includes/logging/LogTests.i18n.php | 15 + tests/phpunit/includes/mail/MailAddressTest.php | 63 + tests/phpunit/includes/mail/UserMailerTest.php | 14 + .../includes/media/BitmapMetadataHandlerTest.php | 167 ++ tests/phpunit/includes/media/BitmapScalingTest.php | 140 ++ tests/phpunit/includes/media/DjVuTest.php | 69 + tests/phpunit/includes/media/ExifBitmapTest.php | 146 ++ tests/phpunit/includes/media/ExifRotationTest.php | 280 +++ tests/phpunit/includes/media/ExifTest.php | 47 + tests/phpunit/includes/media/FakeDimensionFile.php | 31 + .../phpunit/includes/media/FormatMetadataTest.php | 71 + .../includes/media/GIFMetadataExtractorTest.php | 111 + tests/phpunit/includes/media/GIFTest.php | 142 ++ tests/phpunit/includes/media/IPTCTest.php | 85 + .../includes/media/JpegMetadataExtractorTest.php | 111 + tests/phpunit/includes/media/JpegTest.php | 54 + tests/phpunit/includes/media/MediaHandlerTest.php | 56 + .../includes/media/MediaWikiMediaTestCase.php | 86 + .../includes/media/PNGMetadataExtractorTest.php | 155 ++ tests/phpunit/includes/media/PNGTest.php | 131 ++ .../includes/media/SVGMetadataExtractorTest.php | 160 ++ tests/phpunit/includes/media/SVGTest.php | 41 + tests/phpunit/includes/media/TiffTest.php | 45 + tests/phpunit/includes/media/XCFTest.php | 78 + tests/phpunit/includes/media/XMPTest.php | 223 ++ tests/phpunit/includes/media/XMPValidateTest.php | 50 + tests/phpunit/includes/normal/CleanUpTest.php | 409 ++++ .../phpunit/includes/objectcache/BagOStuffTest.php | 147 ++ .../phpunit/includes/parser/MagicVariableTest.php | 229 ++ .../includes/parser/MediaWikiParserTest.php | 134 ++ tests/phpunit/includes/parser/NewParserTest.php | 1091 +++++++++ .../phpunit/includes/parser/ParserMethodsTest.php | 187 ++ tests/phpunit/includes/parser/ParserOutputTest.php | 87 + .../phpunit/includes/parser/ParserPreloadTest.php | 80 + tests/phpunit/includes/parser/PreprocessorTest.php | 247 ++ tests/phpunit/includes/parser/TagHooksTest.php | 108 + tests/phpunit/includes/parser/TidyTest.php | 64 + .../includes/password/BcryptPasswordTest.php | 40 + .../password/LayeredParameterizedPasswordTest.php | 51 + .../phpunit/includes/password/PasswordTestCase.php | 88 + .../includes/password/Pbkdf2PasswordTest.php | 24 + .../includes/poolcounter/PoolCounterTest.php | 72 + .../resourceloader/ResourceLoaderModuleTest.php | 132 ++ .../ResourceLoaderStartupModuleTest.php | 388 +++ .../includes/resourceloader/ResourceLoaderTest.php | 249 ++ .../ResourceLoaderWikiModuleTest.php | 67 + tests/phpunit/includes/search/SearchEngineTest.php | 187 ++ tests/phpunit/includes/search/SearchUpdateTest.php | 81 + tests/phpunit/includes/site/MediaWikiSiteTest.php | 109 + tests/phpunit/includes/site/SiteListTest.php | 240 ++ tests/phpunit/includes/site/SiteSQLStoreTest.php | 134 ++ tests/phpunit/includes/site/SiteTest.php | 296 +++ tests/phpunit/includes/site/TestSites.php | 115 + tests/phpunit/includes/skins/SkinFactoryTest.php | 70 + tests/phpunit/includes/skins/SkinTemplateTest.php | 43 + .../specialpage/SpecialPageFactoryTest.php | 225 ++ .../includes/specials/ImageListPagerTest.php | 22 + .../includes/specials/QueryAllSpecialPagesTest.php | 74 + .../includes/specials/SpecialMIMESearchTest.php | 48 + .../includes/specials/SpecialMyLanguageTest.php | 65 + .../includes/specials/SpecialPreferencesTest.php | 57 + .../includes/specials/SpecialRecentchangesTest.php | 123 + .../includes/specials/SpecialSearchTest.php | 144 ++ .../title/MediaWikiPageLinkRendererTest.php | 169 ++ .../includes/title/MediaWikiTitleCodecTest.php | 384 +++ tests/phpunit/includes/title/TitleValueTest.php | 100 + tests/phpunit/includes/upload/UploadBaseTest.php | 427 ++++ .../phpunit/includes/upload/UploadFromUrlTest.php | 328 +++ tests/phpunit/includes/upload/UploadStashTest.php | 107 + tests/phpunit/includes/utils/CdbTest.php | 90 + tests/phpunit/includes/utils/IPTest.php | 580 +++++ tests/phpunit/includes/utils/MWCryptHKDFTest.php | 89 + tests/phpunit/includes/utils/StringUtilsTest.php | 149 ++ tests/phpunit/includes/utils/UIDGeneratorTest.php | 129 + .../includes/utils/ZipDirectoryReaderTest.php | 84 + tests/phpunit/install-phpunit.sh | 38 + tests/phpunit/languages/LanguageAmTest.php | 35 + tests/phpunit/languages/LanguageArTest.php | 87 + tests/phpunit/languages/LanguageArqTest.php | 26 + tests/phpunit/languages/LanguageBeTest.php | 42 + tests/phpunit/languages/LanguageBe_taraskTest.php | 97 + tests/phpunit/languages/LanguageBhoTest.php | 35 + tests/phpunit/languages/LanguageBsTest.php | 42 + .../phpunit/languages/LanguageClassesTestCase.php | 74 + tests/phpunit/languages/LanguageCsTest.php | 41 + tests/phpunit/languages/LanguageCuTest.php | 42 + tests/phpunit/languages/LanguageCyTest.php | 43 + tests/phpunit/languages/LanguageDsbTest.php | 41 + tests/phpunit/languages/LanguageFrTest.php | 35 + tests/phpunit/languages/LanguageGaTest.php | 35 + tests/phpunit/languages/LanguageGdTest.php | 53 + tests/phpunit/languages/LanguageGvTest.php | 44 + tests/phpunit/languages/LanguageHeTest.php | 132 ++ tests/phpunit/languages/LanguageHiTest.php | 35 + tests/phpunit/languages/LanguageHrTest.php | 42 + tests/phpunit/languages/LanguageHsbTest.php | 41 + tests/phpunit/languages/LanguageHuTest.php | 35 + tests/phpunit/languages/LanguageHyTest.php | 35 + tests/phpunit/languages/LanguageKshTest.php | 35 + tests/phpunit/languages/LanguageLnTest.php | 35 + tests/phpunit/languages/LanguageLtTest.php | 63 + tests/phpunit/languages/LanguageLvTest.php | 44 + tests/phpunit/languages/LanguageMgTest.php | 36 + tests/phpunit/languages/LanguageMkTest.php | 40 + tests/phpunit/languages/LanguageMlTest.php | 38 + tests/phpunit/languages/LanguageMoTest.php | 45 + tests/phpunit/languages/LanguageMtTest.php | 77 + tests/phpunit/languages/LanguageNlTest.php | 24 + tests/phpunit/languages/LanguageNsoTest.php | 34 + tests/phpunit/languages/LanguagePlTest.php | 77 + tests/phpunit/languages/LanguageRoTest.php | 45 + tests/phpunit/languages/LanguageRuTest.php | 115 + tests/phpunit/languages/LanguageSeTest.php | 53 + tests/phpunit/languages/LanguageSgsTest.php | 71 + tests/phpunit/languages/LanguageShTest.php | 42 + tests/phpunit/languages/LanguageSkTest.php | 42 + tests/phpunit/languages/LanguageSlTest.php | 44 + tests/phpunit/languages/LanguageSmaTest.php | 53 + tests/phpunit/languages/LanguageSrTest.php | 249 ++ tests/phpunit/languages/LanguageTest.php | 1635 +++++++++++++ tests/phpunit/languages/LanguageTiTest.php | 34 + tests/phpunit/languages/LanguageTlTest.php | 34 + tests/phpunit/languages/LanguageTrTest.php | 61 + tests/phpunit/languages/LanguageUkTest.php | 72 + tests/phpunit/languages/LanguageUzTest.php | 124 + tests/phpunit/languages/LanguageWaTest.php | 34 + tests/phpunit/languages/SpecialPageAliasTest.php | 63 + .../utils/CLDRPluralRuleEvaluatorTest.php | 151 ++ tests/phpunit/maintenance/DumpTestCase.php | 386 +++ tests/phpunit/maintenance/MaintenanceTest.php | 830 +++++++ tests/phpunit/maintenance/backupPrefetchTest.php | 277 +++ tests/phpunit/maintenance/backupTextPassTest.php | 584 +++++ tests/phpunit/maintenance/backup_LogTest.php | 225 ++ tests/phpunit/maintenance/backup_PageTest.php | 428 ++++ tests/phpunit/maintenance/fetchTextTest.php | 261 +++ tests/phpunit/mocks/filebackend/MockFSFile.php | 69 + .../phpunit/mocks/filebackend/MockFileBackend.php | 39 + tests/phpunit/mocks/media/MockBitmapHandler.php | 32 + tests/phpunit/mocks/media/MockDjVuHandler.php | 49 + tests/phpunit/mocks/media/MockImageHandler.php | 86 + tests/phpunit/mocks/media/MockSvgHandler.php | 28 + tests/phpunit/phpunit.php | 233 ++ tests/phpunit/run-tests.bat | 1 + tests/phpunit/skins/SideBarTest.php | 219 ++ tests/phpunit/structure/AutoLoaderTest.php | 135 ++ tests/phpunit/structure/ResourcesTest.php | 269 +++ tests/phpunit/structure/StructureTest.php | 67 + tests/phpunit/suite.xml | 64 + tests/phpunit/suites/ExtensionsParserTestSuite.php | 8 + tests/phpunit/suites/ExtensionsTestSuite.php | 47 + tests/phpunit/suites/LessTestSuite.php | 34 + tests/phpunit/suites/UploadFromUrlTestSuite.php | 207 ++ tests/phpunit/tests/MediaWikiTestCaseTest.php | 77 + 479 files changed, 63633 insertions(+) create mode 100644 tests/phpunit/LessFileCompilationTest.php create mode 100644 tests/phpunit/Makefile create mode 100644 tests/phpunit/MediaWikiLangTestCase.php create mode 100644 tests/phpunit/MediaWikiPHPUnitTestListener.php create mode 100644 tests/phpunit/MediaWikiTestCase.php create mode 100644 tests/phpunit/README create mode 100644 tests/phpunit/ResourceLoaderTestCase.php create mode 100644 tests/phpunit/TODO create mode 100644 tests/phpunit/bootstrap.php create mode 100644 tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php create mode 100644 tests/phpunit/data/autoloader/TestAutoloadedClass.php create mode 100644 tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php create mode 100644 tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php create mode 100644 tests/phpunit/data/css/expected.css create mode 100644 tests/phpunit/data/css/simple-ltr.gif create mode 100644 tests/phpunit/data/css/simple-rtl.gif create mode 100644 tests/phpunit/data/css/test.css create mode 100644 tests/phpunit/data/cssmin/green.gif create mode 100644 tests/phpunit/data/cssmin/large.png create mode 100644 tests/phpunit/data/cssmin/red.gif create mode 100644 tests/phpunit/data/db/mysql/functions.sql create mode 100644 tests/phpunit/data/db/postgres/functions.sql create mode 100644 tests/phpunit/data/db/sqlite/tables-1.13.sql create mode 100644 tests/phpunit/data/db/sqlite/tables-1.15.sql create mode 100644 tests/phpunit/data/db/sqlite/tables-1.16.sql create mode 100644 tests/phpunit/data/db/sqlite/tables-1.17.sql create mode 100644 tests/phpunit/data/db/sqlite/tables-1.18.sql create mode 100644 tests/phpunit/data/filerepo/video.png create mode 100644 tests/phpunit/data/filerepo/wiki.png create mode 100644 tests/phpunit/data/gitinfo/info-testValidJsonData.json create mode 100644 tests/phpunit/data/less/common/test.common.mixins.less create mode 100644 tests/phpunit/data/less/module/dependency.less create mode 100644 tests/phpunit/data/less/module/styles.css create mode 100644 tests/phpunit/data/less/module/styles.less create mode 100644 tests/phpunit/data/localisationcache/en.json create mode 100644 tests/phpunit/data/localisationcache/ru.json create mode 100644 tests/phpunit/data/localisationcache/uk.json create mode 100644 tests/phpunit/data/media/1bit-png.png create mode 100644 tests/phpunit/data/media/Animated_PNG_example_bouncing_beach_ball.png create mode 100644 tests/phpunit/data/media/Bishzilla_blink.gif create mode 100644 tests/phpunit/data/media/Gtk-media-play-ltr.svg create mode 100644 tests/phpunit/data/media/LoremIpsum.djvu create mode 100644 tests/phpunit/data/media/Png-native-test.png create mode 100644 tests/phpunit/data/media/QA_icon.svg create mode 100644 tests/phpunit/data/media/README create mode 100644 tests/phpunit/data/media/Soccer_ball_animated.svg create mode 100644 tests/phpunit/data/media/Speech_bubbles.svg create mode 100644 tests/phpunit/data/media/Toll_Texas_1.svg create mode 100644 tests/phpunit/data/media/Tux.svg create mode 100644 tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg create mode 100644 tests/phpunit/data/media/Wikimedia-logo.svg create mode 100644 tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg create mode 100644 tests/phpunit/data/media/animated-xmp.gif create mode 100644 tests/phpunit/data/media/animated.gif create mode 100644 tests/phpunit/data/media/broken_exif_date.jpg create mode 100644 tests/phpunit/data/media/exif-gps.jpg create mode 100644 tests/phpunit/data/media/exif-user-comment.jpg create mode 100644 tests/phpunit/data/media/greyscale-na-png.png create mode 100644 tests/phpunit/data/media/greyscale-png.png create mode 100644 tests/phpunit/data/media/iptc-invalid-psir.jpg create mode 100644 tests/phpunit/data/media/iptc-timetest-invalid.jpg create mode 100644 tests/phpunit/data/media/iptc-timetest.jpg create mode 100644 tests/phpunit/data/media/jpeg-comment-binary.jpg create mode 100644 tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg create mode 100644 tests/phpunit/data/media/jpeg-comment-multiple.jpg create mode 100644 tests/phpunit/data/media/jpeg-comment-utf.jpg create mode 100644 tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg create mode 100644 tests/phpunit/data/media/jpeg-iptc-good-hash.jpg create mode 100644 tests/phpunit/data/media/jpeg-padding-even.jpg create mode 100644 tests/phpunit/data/media/jpeg-padding-odd.jpg create mode 100644 tests/phpunit/data/media/jpeg-xmp-alt.jpg create mode 100644 tests/phpunit/data/media/jpeg-xmp-psir.jpg create mode 100644 tests/phpunit/data/media/jpeg-xmp-psir.xmp create mode 100644 tests/phpunit/data/media/landscape-plain.jpg create mode 100644 tests/phpunit/data/media/nonanimated.gif create mode 100644 tests/phpunit/data/media/portrait-rotated.jpg create mode 100644 tests/phpunit/data/media/rgb-na-png.png create mode 100644 tests/phpunit/data/media/rgb-png.png create mode 100644 tests/phpunit/data/media/say-test.ogg create mode 100644 tests/phpunit/data/media/test.jpg create mode 100644 tests/phpunit/data/media/test.tiff create mode 100644 tests/phpunit/data/media/xmp.png create mode 100644 tests/phpunit/data/parser/LoremIpsum.djvu create mode 100644 tests/phpunit/data/parser/headbg.jpg create mode 100644 tests/phpunit/data/parser/wiki.png create mode 100644 tests/phpunit/data/upload/headbg.jpg create mode 100644 tests/phpunit/data/xmp/1.result.php create mode 100644 tests/phpunit/data/xmp/1.xmp create mode 100644 tests/phpunit/data/xmp/2.result.php create mode 100644 tests/phpunit/data/xmp/2.xmp create mode 100644 tests/phpunit/data/xmp/3-invalid.result.php create mode 100644 tests/phpunit/data/xmp/3-invalid.xmp create mode 100644 tests/phpunit/data/xmp/3.result.php create mode 100644 tests/phpunit/data/xmp/3.xmp create mode 100644 tests/phpunit/data/xmp/4.result.php create mode 100644 tests/phpunit/data/xmp/4.xmp create mode 100644 tests/phpunit/data/xmp/5.result.php create mode 100644 tests/phpunit/data/xmp/5.xmp create mode 100644 tests/phpunit/data/xmp/6.result.php create mode 100644 tests/phpunit/data/xmp/6.xmp create mode 100644 tests/phpunit/data/xmp/7.result.php create mode 100644 tests/phpunit/data/xmp/7.xmp create mode 100644 tests/phpunit/data/xmp/README create mode 100644 tests/phpunit/data/xmp/bag-for-seq.result.php create mode 100644 tests/phpunit/data/xmp/bag-for-seq.xmp create mode 100644 tests/phpunit/data/xmp/doctype-included.result.php create mode 100644 tests/phpunit/data/xmp/doctype-included.xmp create mode 100644 tests/phpunit/data/xmp/doctype-not-included.xmp create mode 100644 tests/phpunit/data/xmp/flash.result.php create mode 100644 tests/phpunit/data/xmp/flash.xmp create mode 100644 tests/phpunit/data/xmp/gps.result.php create mode 100644 tests/phpunit/data/xmp/gps.xmp create mode 100644 tests/phpunit/data/xmp/invalid-child-not-struct.result.php create mode 100644 tests/phpunit/data/xmp/invalid-child-not-struct.xmp create mode 100644 tests/phpunit/data/xmp/no-namespace.result.php create mode 100644 tests/phpunit/data/xmp/no-namespace.xmp create mode 100644 tests/phpunit/data/xmp/no-recognized-props.result.php create mode 100644 tests/phpunit/data/xmp/no-recognized-props.xmp create mode 100644 tests/phpunit/data/xmp/utf16BE.result.php create mode 100644 tests/phpunit/data/xmp/utf16BE.xmp create mode 100644 tests/phpunit/data/xmp/utf16LE.result.php create mode 100644 tests/phpunit/data/xmp/utf16LE.xmp create mode 100644 tests/phpunit/data/xmp/utf32BE.result.php create mode 100644 tests/phpunit/data/xmp/utf32BE.xmp create mode 100644 tests/phpunit/data/xmp/utf32LE.result.php create mode 100644 tests/phpunit/data/xmp/utf32LE.xmp create mode 100644 tests/phpunit/data/xmp/xmpExt.result.php create mode 100644 tests/phpunit/data/xmp/xmpExt.xmp create mode 100644 tests/phpunit/data/xmp/xmpExt2.xmp create mode 100644 tests/phpunit/data/zip/cd-gap.zip create mode 100644 tests/phpunit/data/zip/cd-truncated.zip create mode 100644 tests/phpunit/data/zip/class-trailing-null.zip create mode 100644 tests/phpunit/data/zip/class-trailing-slash.zip create mode 100644 tests/phpunit/data/zip/class.zip create mode 100644 tests/phpunit/data/zip/empty.zip create mode 100644 tests/phpunit/data/zip/looks-like-zip64.zip create mode 100644 tests/phpunit/data/zip/nosig.zip create mode 100644 tests/phpunit/data/zip/split.zip create mode 100644 tests/phpunit/data/zip/trail.zip create mode 100644 tests/phpunit/data/zip/wrong-cd-start-disk.zip create mode 100644 tests/phpunit/data/zip/wrong-central-entry-sig.zip create mode 100644 tests/phpunit/docs/ExportDemoTest.php create mode 100644 tests/phpunit/includes/ArrayUtilsTest.php create mode 100644 tests/phpunit/includes/ArticleTablesTest.php create mode 100644 tests/phpunit/includes/ArticleTest.php create mode 100644 tests/phpunit/includes/BlockTest.php create mode 100644 tests/phpunit/includes/CollationTest.php create mode 100644 tests/phpunit/includes/DiffHistoryBlobTest.php create mode 100644 tests/phpunit/includes/EditPageTest.php create mode 100644 tests/phpunit/includes/ExternalStoreTest.php create mode 100644 tests/phpunit/includes/ExtraParserTest.php create mode 100644 tests/phpunit/includes/FallbackTest.php create mode 100644 tests/phpunit/includes/FauxRequestTest.php create mode 100644 tests/phpunit/includes/FauxResponseTest.php create mode 100644 tests/phpunit/includes/FormOptionsInitializationTest.php create mode 100644 tests/phpunit/includes/FormOptionsTest.php create mode 100644 tests/phpunit/includes/GitInfoTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/GlobalTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/README create mode 100644 tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php create mode 100644 tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php create mode 100644 tests/phpunit/includes/HooksTest.php create mode 100644 tests/phpunit/includes/HtmlFormatterTest.php create mode 100644 tests/phpunit/includes/HtmlTest.php create mode 100644 tests/phpunit/includes/HttpTest.php create mode 100644 tests/phpunit/includes/ImagePage404Test.php create mode 100644 tests/phpunit/includes/ImagePageTest.php create mode 100644 tests/phpunit/includes/ImportTest.php create mode 100644 tests/phpunit/includes/LanguageConverterTest.php create mode 100644 tests/phpunit/includes/LicensesTest.php create mode 100644 tests/phpunit/includes/LinkFilterTest.php create mode 100644 tests/phpunit/includes/LinkerTest.php create mode 100644 tests/phpunit/includes/LinksUpdateTest.php create mode 100644 tests/phpunit/includes/LocalFileTest.php create mode 100644 tests/phpunit/includes/MWFunctionTest.php create mode 100644 tests/phpunit/includes/MWNamespaceTest.php create mode 100644 tests/phpunit/includes/MWTimestampTest.php create mode 100644 tests/phpunit/includes/MediaWikiVersionFetcherTest.php create mode 100644 tests/phpunit/includes/MessageTest.php create mode 100644 tests/phpunit/includes/MimeMagicTest.php create mode 100644 tests/phpunit/includes/OutputPageTest.php create mode 100644 tests/phpunit/includes/PasswordTest.php create mode 100644 tests/phpunit/includes/PathRouterTest.php create mode 100644 tests/phpunit/includes/PreferencesTest.php create mode 100644 tests/phpunit/includes/RequestContextTest.php create mode 100644 tests/phpunit/includes/RevisionStorageTest.php create mode 100644 tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php create mode 100644 tests/phpunit/includes/RevisionTest.php create mode 100644 tests/phpunit/includes/SampleTest.php create mode 100644 tests/phpunit/includes/SanitizerTest.php create mode 100644 tests/phpunit/includes/SanitizerValidateEmailTest.php create mode 100644 tests/phpunit/includes/SiteConfigurationTest.php create mode 100644 tests/phpunit/includes/SpecialPageTest.php create mode 100644 tests/phpunit/includes/StatusTest.php create mode 100644 tests/phpunit/includes/TemplateCategoriesTest.php create mode 100644 tests/phpunit/includes/TestUser.php create mode 100644 tests/phpunit/includes/TimeAdjustTest.php create mode 100644 tests/phpunit/includes/TitleArrayFromResultTest.php create mode 100644 tests/phpunit/includes/TitleMethodsTest.php create mode 100644 tests/phpunit/includes/TitlePermissionTest.php create mode 100644 tests/phpunit/includes/TitleTest.php create mode 100644 tests/phpunit/includes/UserArrayFromResultTest.php create mode 100644 tests/phpunit/includes/UserTest.php create mode 100644 tests/phpunit/includes/WebRequestTest.php create mode 100644 tests/phpunit/includes/WikiPageTest.php create mode 100644 tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php create mode 100644 tests/phpunit/includes/XmlJsTest.php create mode 100644 tests/phpunit/includes/XmlSelectTest.php create mode 100644 tests/phpunit/includes/XmlTest.php create mode 100644 tests/phpunit/includes/XmlTypeCheckTest.php create mode 100644 tests/phpunit/includes/actions/ActionTest.php create mode 100644 tests/phpunit/includes/api/ApiBaseTest.php create mode 100644 tests/phpunit/includes/api/ApiBlockTest.php create mode 100644 tests/phpunit/includes/api/ApiCreateAccountTest.php create mode 100644 tests/phpunit/includes/api/ApiEditPageTest.php create mode 100644 tests/phpunit/includes/api/ApiLoginTest.php create mode 100644 tests/phpunit/includes/api/ApiMainTest.php create mode 100644 tests/phpunit/includes/api/ApiModuleManagerTest.php create mode 100644 tests/phpunit/includes/api/ApiOptionsTest.php create mode 100644 tests/phpunit/includes/api/ApiParseTest.php create mode 100644 tests/phpunit/includes/api/ApiPurgeTest.php create mode 100644 tests/phpunit/includes/api/ApiQueryAllPagesTest.php create mode 100644 tests/phpunit/includes/api/ApiRevisionDeleteTest.php create mode 100644 tests/phpunit/includes/api/ApiTestCase.php create mode 100644 tests/phpunit/includes/api/ApiTestCaseUpload.php create mode 100644 tests/phpunit/includes/api/ApiTestContext.php create mode 100644 tests/phpunit/includes/api/ApiTokensTest.php create mode 100644 tests/phpunit/includes/api/ApiUnblockTest.php create mode 100644 tests/phpunit/includes/api/ApiUploadTest.php create mode 100644 tests/phpunit/includes/api/ApiWatchTest.php create mode 100644 tests/phpunit/includes/api/MockApi.php create mode 100644 tests/phpunit/includes/api/MockApiQueryBase.php create mode 100644 tests/phpunit/includes/api/PrefixUniquenessTest.php create mode 100644 tests/phpunit/includes/api/RandomImageGenerator.php create mode 100644 tests/phpunit/includes/api/UserWrapper.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatJsonTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatNoneTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatPhpTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatTestBase.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatWddxTest.php create mode 100644 tests/phpunit/includes/api/generateRandomImages.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryBasicTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinue2Test.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinueTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryTestBase.php create mode 100644 tests/phpunit/includes/api/words.txt create mode 100644 tests/phpunit/includes/cache/GenderCacheTest.php create mode 100644 tests/phpunit/includes/cache/LocalisationCacheTest.php create mode 100644 tests/phpunit/includes/cache/MessageCacheTest.php create mode 100644 tests/phpunit/includes/cache/RedisBloomCacheTest.php create mode 100644 tests/phpunit/includes/changes/EnhancedChangesListTest.php create mode 100644 tests/phpunit/includes/changes/OldChangesListTest.php create mode 100644 tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php create mode 100644 tests/phpunit/includes/changes/RecentChangeTest.php create mode 100644 tests/phpunit/includes/changes/TestRecentChangesHelper.php create mode 100644 tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php create mode 100644 tests/phpunit/includes/config/ConfigFactoryTest.php create mode 100644 tests/phpunit/includes/config/GlobalVarConfigTest.php create mode 100644 tests/phpunit/includes/config/HashConfigTest.php create mode 100644 tests/phpunit/includes/config/MultiConfigTest.php create mode 100644 tests/phpunit/includes/content/ContentHandlerTest.php create mode 100644 tests/phpunit/includes/content/CssContentTest.php create mode 100644 tests/phpunit/includes/content/JavaScriptContentTest.php create mode 100644 tests/phpunit/includes/content/JsonContentTest.php create mode 100644 tests/phpunit/includes/content/TextContentTest.php create mode 100644 tests/phpunit/includes/content/WikitextContentHandlerTest.php create mode 100644 tests/phpunit/includes/content/WikitextContentTest.php create mode 100644 tests/phpunit/includes/db/DatabaseMysqlBaseTest.php create mode 100644 tests/phpunit/includes/db/DatabaseSQLTest.php create mode 100644 tests/phpunit/includes/db/DatabaseSqliteTest.php create mode 100644 tests/phpunit/includes/db/DatabaseTest.php create mode 100644 tests/phpunit/includes/db/DatabaseTestHelper.php create mode 100644 tests/phpunit/includes/db/LBFactoryTest.php create mode 100644 tests/phpunit/includes/db/ORMRowTest.php create mode 100644 tests/phpunit/includes/db/ORMTableTest.php create mode 100644 tests/phpunit/includes/db/TestORMRowTest.php create mode 100644 tests/phpunit/includes/debug/MWDebugTest.php create mode 100644 tests/phpunit/includes/deferred/DeferredUpdatesTest.php create mode 100644 tests/phpunit/includes/diff/ArrayDiffFormatterTest.php create mode 100644 tests/phpunit/includes/diff/DiffOpTest.php create mode 100644 tests/phpunit/includes/diff/DiffTest.php create mode 100644 tests/phpunit/includes/diff/DifferenceEngineTest.php create mode 100644 tests/phpunit/includes/diff/FakeDiffOp.php create mode 100644 tests/phpunit/includes/exception/BadTitleErrorTest.php create mode 100644 tests/phpunit/includes/exception/ErrorPageErrorTest.php create mode 100644 tests/phpunit/includes/exception/MWExceptionHandlerTest.php create mode 100644 tests/phpunit/includes/exception/MWExceptionTest.php create mode 100644 tests/phpunit/includes/exception/ReadOnlyErrorTest.php create mode 100644 tests/phpunit/includes/exception/ThrottledErrorTest.php create mode 100644 tests/phpunit/includes/exception/UserNotLoggedInTest.php create mode 100644 tests/phpunit/includes/filebackend/FileBackendTest.php create mode 100644 tests/phpunit/includes/filerepo/FileRepoTest.php create mode 100644 tests/phpunit/includes/filerepo/RepoGroupTest.php create mode 100644 tests/phpunit/includes/filerepo/StoreBatchTest.php create mode 100644 tests/phpunit/includes/filerepo/file/FileTest.php create mode 100644 tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php create mode 100644 tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php create mode 100644 tests/phpunit/includes/installer/InstallDocFormatterTest.php create mode 100644 tests/phpunit/includes/installer/OracleInstallerTest.php create mode 100644 tests/phpunit/includes/jobqueue/JobQueueTest.php create mode 100644 tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php create mode 100644 tests/phpunit/includes/json/FormatJsonTest.php create mode 100644 tests/phpunit/includes/libs/CSSMinTest.php create mode 100644 tests/phpunit/includes/libs/GenericArrayObjectTest.php create mode 100644 tests/phpunit/includes/libs/HashRingTest.php create mode 100644 tests/phpunit/includes/libs/IEUrlExtensionTest.php create mode 100644 tests/phpunit/includes/libs/IPSetTest.php create mode 100644 tests/phpunit/includes/libs/JavaScriptMinifierTest.php create mode 100644 tests/phpunit/includes/libs/MWMessagePackTest.php create mode 100644 tests/phpunit/includes/libs/ProcessCacheLRUTest.php create mode 100644 tests/phpunit/includes/libs/RunningStatTest.php create mode 100644 tests/phpunit/includes/logging/LogFormatterTest.php create mode 100644 tests/phpunit/includes/logging/LogTests.i18n.php create mode 100644 tests/phpunit/includes/mail/MailAddressTest.php create mode 100644 tests/phpunit/includes/mail/UserMailerTest.php create mode 100644 tests/phpunit/includes/media/BitmapMetadataHandlerTest.php create mode 100644 tests/phpunit/includes/media/BitmapScalingTest.php create mode 100644 tests/phpunit/includes/media/DjVuTest.php create mode 100644 tests/phpunit/includes/media/ExifBitmapTest.php create mode 100644 tests/phpunit/includes/media/ExifRotationTest.php create mode 100644 tests/phpunit/includes/media/ExifTest.php create mode 100644 tests/phpunit/includes/media/FakeDimensionFile.php create mode 100644 tests/phpunit/includes/media/FormatMetadataTest.php create mode 100644 tests/phpunit/includes/media/GIFMetadataExtractorTest.php create mode 100644 tests/phpunit/includes/media/GIFTest.php create mode 100644 tests/phpunit/includes/media/IPTCTest.php create mode 100644 tests/phpunit/includes/media/JpegMetadataExtractorTest.php create mode 100644 tests/phpunit/includes/media/JpegTest.php create mode 100644 tests/phpunit/includes/media/MediaHandlerTest.php create mode 100644 tests/phpunit/includes/media/MediaWikiMediaTestCase.php create mode 100644 tests/phpunit/includes/media/PNGMetadataExtractorTest.php create mode 100644 tests/phpunit/includes/media/PNGTest.php create mode 100644 tests/phpunit/includes/media/SVGMetadataExtractorTest.php create mode 100644 tests/phpunit/includes/media/SVGTest.php create mode 100644 tests/phpunit/includes/media/TiffTest.php create mode 100644 tests/phpunit/includes/media/XCFTest.php create mode 100644 tests/phpunit/includes/media/XMPTest.php create mode 100644 tests/phpunit/includes/media/XMPValidateTest.php create mode 100644 tests/phpunit/includes/normal/CleanUpTest.php create mode 100644 tests/phpunit/includes/objectcache/BagOStuffTest.php create mode 100644 tests/phpunit/includes/parser/MagicVariableTest.php create mode 100644 tests/phpunit/includes/parser/MediaWikiParserTest.php create mode 100644 tests/phpunit/includes/parser/NewParserTest.php create mode 100644 tests/phpunit/includes/parser/ParserMethodsTest.php create mode 100644 tests/phpunit/includes/parser/ParserOutputTest.php create mode 100644 tests/phpunit/includes/parser/ParserPreloadTest.php create mode 100644 tests/phpunit/includes/parser/PreprocessorTest.php create mode 100644 tests/phpunit/includes/parser/TagHooksTest.php create mode 100644 tests/phpunit/includes/parser/TidyTest.php create mode 100644 tests/phpunit/includes/password/BcryptPasswordTest.php create mode 100644 tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php create mode 100644 tests/phpunit/includes/password/PasswordTestCase.php create mode 100644 tests/phpunit/includes/password/Pbkdf2PasswordTest.php create mode 100644 tests/phpunit/includes/poolcounter/PoolCounterTest.php create mode 100644 tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php create mode 100644 tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php create mode 100644 tests/phpunit/includes/resourceloader/ResourceLoaderTest.php create mode 100644 tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php create mode 100644 tests/phpunit/includes/search/SearchEngineTest.php create mode 100644 tests/phpunit/includes/search/SearchUpdateTest.php create mode 100644 tests/phpunit/includes/site/MediaWikiSiteTest.php create mode 100644 tests/phpunit/includes/site/SiteListTest.php create mode 100644 tests/phpunit/includes/site/SiteSQLStoreTest.php create mode 100644 tests/phpunit/includes/site/SiteTest.php create mode 100644 tests/phpunit/includes/site/TestSites.php create mode 100644 tests/phpunit/includes/skins/SkinFactoryTest.php create mode 100644 tests/phpunit/includes/skins/SkinTemplateTest.php create mode 100644 tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php create mode 100644 tests/phpunit/includes/specials/ImageListPagerTest.php create mode 100644 tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php create mode 100644 tests/phpunit/includes/specials/SpecialMIMESearchTest.php create mode 100644 tests/phpunit/includes/specials/SpecialMyLanguageTest.php create mode 100644 tests/phpunit/includes/specials/SpecialPreferencesTest.php create mode 100644 tests/phpunit/includes/specials/SpecialRecentchangesTest.php create mode 100644 tests/phpunit/includes/specials/SpecialSearchTest.php create mode 100644 tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php create mode 100644 tests/phpunit/includes/title/MediaWikiTitleCodecTest.php create mode 100644 tests/phpunit/includes/title/TitleValueTest.php create mode 100644 tests/phpunit/includes/upload/UploadBaseTest.php create mode 100644 tests/phpunit/includes/upload/UploadFromUrlTest.php create mode 100644 tests/phpunit/includes/upload/UploadStashTest.php create mode 100644 tests/phpunit/includes/utils/CdbTest.php create mode 100644 tests/phpunit/includes/utils/IPTest.php create mode 100644 tests/phpunit/includes/utils/MWCryptHKDFTest.php create mode 100644 tests/phpunit/includes/utils/StringUtilsTest.php create mode 100644 tests/phpunit/includes/utils/UIDGeneratorTest.php create mode 100644 tests/phpunit/includes/utils/ZipDirectoryReaderTest.php create mode 100644 tests/phpunit/install-phpunit.sh create mode 100644 tests/phpunit/languages/LanguageAmTest.php create mode 100644 tests/phpunit/languages/LanguageArTest.php create mode 100644 tests/phpunit/languages/LanguageArqTest.php create mode 100644 tests/phpunit/languages/LanguageBeTest.php create mode 100644 tests/phpunit/languages/LanguageBe_taraskTest.php create mode 100644 tests/phpunit/languages/LanguageBhoTest.php create mode 100644 tests/phpunit/languages/LanguageBsTest.php create mode 100644 tests/phpunit/languages/LanguageClassesTestCase.php create mode 100644 tests/phpunit/languages/LanguageCsTest.php create mode 100644 tests/phpunit/languages/LanguageCuTest.php create mode 100644 tests/phpunit/languages/LanguageCyTest.php create mode 100644 tests/phpunit/languages/LanguageDsbTest.php create mode 100644 tests/phpunit/languages/LanguageFrTest.php create mode 100644 tests/phpunit/languages/LanguageGaTest.php create mode 100644 tests/phpunit/languages/LanguageGdTest.php create mode 100644 tests/phpunit/languages/LanguageGvTest.php create mode 100644 tests/phpunit/languages/LanguageHeTest.php create mode 100644 tests/phpunit/languages/LanguageHiTest.php create mode 100644 tests/phpunit/languages/LanguageHrTest.php create mode 100644 tests/phpunit/languages/LanguageHsbTest.php create mode 100644 tests/phpunit/languages/LanguageHuTest.php create mode 100644 tests/phpunit/languages/LanguageHyTest.php create mode 100644 tests/phpunit/languages/LanguageKshTest.php create mode 100644 tests/phpunit/languages/LanguageLnTest.php create mode 100644 tests/phpunit/languages/LanguageLtTest.php create mode 100644 tests/phpunit/languages/LanguageLvTest.php create mode 100644 tests/phpunit/languages/LanguageMgTest.php create mode 100644 tests/phpunit/languages/LanguageMkTest.php create mode 100644 tests/phpunit/languages/LanguageMlTest.php create mode 100644 tests/phpunit/languages/LanguageMoTest.php create mode 100644 tests/phpunit/languages/LanguageMtTest.php create mode 100644 tests/phpunit/languages/LanguageNlTest.php create mode 100644 tests/phpunit/languages/LanguageNsoTest.php create mode 100644 tests/phpunit/languages/LanguagePlTest.php create mode 100644 tests/phpunit/languages/LanguageRoTest.php create mode 100644 tests/phpunit/languages/LanguageRuTest.php create mode 100644 tests/phpunit/languages/LanguageSeTest.php create mode 100644 tests/phpunit/languages/LanguageSgsTest.php create mode 100644 tests/phpunit/languages/LanguageShTest.php create mode 100644 tests/phpunit/languages/LanguageSkTest.php create mode 100644 tests/phpunit/languages/LanguageSlTest.php create mode 100644 tests/phpunit/languages/LanguageSmaTest.php create mode 100644 tests/phpunit/languages/LanguageSrTest.php create mode 100644 tests/phpunit/languages/LanguageTest.php create mode 100644 tests/phpunit/languages/LanguageTiTest.php create mode 100644 tests/phpunit/languages/LanguageTlTest.php create mode 100644 tests/phpunit/languages/LanguageTrTest.php create mode 100644 tests/phpunit/languages/LanguageUkTest.php create mode 100644 tests/phpunit/languages/LanguageUzTest.php create mode 100644 tests/phpunit/languages/LanguageWaTest.php create mode 100644 tests/phpunit/languages/SpecialPageAliasTest.php create mode 100644 tests/phpunit/languages/utils/CLDRPluralRuleEvaluatorTest.php create mode 100644 tests/phpunit/maintenance/DumpTestCase.php create mode 100644 tests/phpunit/maintenance/MaintenanceTest.php create mode 100644 tests/phpunit/maintenance/backupPrefetchTest.php create mode 100644 tests/phpunit/maintenance/backupTextPassTest.php create mode 100644 tests/phpunit/maintenance/backup_LogTest.php create mode 100644 tests/phpunit/maintenance/backup_PageTest.php create mode 100644 tests/phpunit/maintenance/fetchTextTest.php create mode 100644 tests/phpunit/mocks/filebackend/MockFSFile.php create mode 100644 tests/phpunit/mocks/filebackend/MockFileBackend.php create mode 100644 tests/phpunit/mocks/media/MockBitmapHandler.php create mode 100644 tests/phpunit/mocks/media/MockDjVuHandler.php create mode 100644 tests/phpunit/mocks/media/MockImageHandler.php create mode 100644 tests/phpunit/mocks/media/MockSvgHandler.php create mode 100644 tests/phpunit/phpunit.php create mode 100644 tests/phpunit/run-tests.bat create mode 100644 tests/phpunit/skins/SideBarTest.php create mode 100644 tests/phpunit/structure/AutoLoaderTest.php create mode 100644 tests/phpunit/structure/ResourcesTest.php create mode 100644 tests/phpunit/structure/StructureTest.php create mode 100644 tests/phpunit/suite.xml create mode 100644 tests/phpunit/suites/ExtensionsParserTestSuite.php create mode 100644 tests/phpunit/suites/ExtensionsTestSuite.php create mode 100644 tests/phpunit/suites/LessTestSuite.php create mode 100644 tests/phpunit/suites/UploadFromUrlTestSuite.php create mode 100644 tests/phpunit/tests/MediaWikiTestCaseTest.php (limited to 'tests/phpunit') diff --git a/tests/phpunit/LessFileCompilationTest.php b/tests/phpunit/LessFileCompilationTest.php new file mode 100644 index 00000000..71e0f4b2 --- /dev/null +++ b/tests/phpunit/LessFileCompilationTest.php @@ -0,0 +1,60 @@ + + */ +class LessFileCompilationTest extends ResourceLoaderTestCase { + + /** + * @var string $file + */ + protected $file; + + /** + * @var ResourceLoaderModule The ResourceLoader module that contains + * the file + */ + protected $module; + + /** + * @param string $file + * @param ResourceLoaderModule $module The ResourceLoader module that + * contains the file + */ + public function __construct( $file, ResourceLoaderModule $module ) { + parent::__construct( 'testLessFileCompilation' ); + + $this->file = $file; + $this->module = $module; + } + + public function testLessFileCompilation() { + $thisString = $this->toString(); + $this->assertTrue( + is_string( $this->file ) && is_file( $this->file ) && is_readable( $this->file ), + "$thisString must refer to a readable file" + ); + + $rlContext = static::getResourceLoaderContext(); + + // Bleh + $method = new ReflectionMethod( $this->module, 'getLessCompiler' ); + $method->setAccessible( true ); + $compiler = $method->invoke( $this->module, $rlContext ); + + $this->assertNotNull( $compiler->compileFile( $this->file ) ); + } + + public function getName( $withDataSet = true ) { + return $this->toString(); + } + + public function toString() { + $moduleName = $this->module->getName(); + + return "{$this->file} in the \"{$moduleName}\" module"; + } +} diff --git a/tests/phpunit/Makefile b/tests/phpunit/Makefile new file mode 100644 index 00000000..c3e2a303 --- /dev/null +++ b/tests/phpunit/Makefile @@ -0,0 +1,91 @@ +.PHONY: help test phpunit install coverage warning destructive parser noparser safe databaseless list-groups +.DEFAULT: warning + +SHELL = /bin/sh +CONFIG_FILE = ${PWD}/suite.xml +PHP = php +PU = ${PHP} phpunit.php --configuration ${CONFIG_FILE} ${FLAGS} + +all test: warning + +warning: + @echo "Run 'make help' to get usage" + @echo "" + @echo "WARNING -- some tests are DESTRUCTIVE and will alter your wiki." + @echo "DO NOT RUN THESE TESTS on a production wiki." + @echo "" + @echo "Until the default tests are made non-destructive, you can run" + @echo "the destructive tests like so:" + @echo "" + @echo " make destructive" + @echo "" + @echo "Some tests are expected to be safe, you can run them with" + @echo "" + @echo " make safe" + @echo "" + @echo "You are recommended to run the tests with read-only credentials." + @echo "" + @echo "If you don't have a database running, you can still run" + @echo "" + @echo " make databaseless" + @echo "" + +destructive: phpunit + +phpunit: + ${PU} + +install: + ./install-phpunit.sh + +tap: + ${PU} --tap + +coverage: + ${PU} --coverage-html ../../docs/code-coverage + +parser: + ${PU} --group Parser +parserfuzz: + @echo "******************************************************************" + @echo "* This WILL kill your computer by eating all memory AND all swap *" + @echo "* *" + @echo "* If you are on a production machine. ABORT NOW!! *" + @echo "* Press control+C to stop *" + @echo "* *" + @echo "******************************************************************" + ${PU} --group Parser,ParserFuzz +noparser: + ${PU} --exclude-group Parser,Broken,ParserFuzz,Stub + +safe: + ${PU} --exclude-group Broken,ParserFuzz,Destructive,Stub + +databaseless: + ${PU} --exclude-group Broken,ParserFuzz,Destructive,Database,Stub + +database: + ${PU} --exclude-group Broken,ParserFuzz,Destructive,Stub --group Database + +list-groups: + ${PU} --list-groups + +help: + # Usage: + # make [OPTION=value] + # + # Targets: + # phpunit (default) Run all the tests with phpunit + # install Install PHPUnit from phpunit.de + # tap Run the tests individually through Test::Harness's prove(1) + # help You're looking at it! + # coverage Run the tests and generates an HTML code coverage report + # You will need the Xdebug PHP extension for the later. + # [no]parser Skip or only run Parser tests + # + # list-groups List availabe Tests groups. + # + # Options: + # CONFIG_FILE Path to a PHPUnit configuration file (default: suite.xml) + # FLAGS Additional flags to pass to PHPUnit + # PHP Path to php diff --git a/tests/phpunit/MediaWikiLangTestCase.php b/tests/phpunit/MediaWikiLangTestCase.php new file mode 100644 index 00000000..53e67224 --- /dev/null +++ b/tests/phpunit/MediaWikiLangTestCase.php @@ -0,0 +1,32 @@ +getCode() ) { + throw new MWException( "Error in MediaWikiLangTestCase::setUp(): " . + "\$wgLanguageCode ('$wgLanguageCode') is different from " . + "\$wgContLang->getCode() (" . $wgContLang->getCode() . ")" ); + } + + // HACK: Call getLanguage() so the real $wgContLang is cached as the user language + // rather than our fake one. This is to avoid breaking other, unrelated tests. + RequestContext::getMain()->getLanguage(); + + $langCode = 'en'; # For mainpage to be 'Main Page' + $langObj = Language::factory( $langCode ); + + $this->setMwGlobals( array( + 'wgLanguageCode' => $langCode, + 'wgLang' => $langObj, + 'wgContLang' => $langObj, + ) ); + + MessageCache::singleton()->disable(); + } +} diff --git a/tests/phpunit/MediaWikiPHPUnitTestListener.php b/tests/phpunit/MediaWikiPHPUnitTestListener.php new file mode 100644 index 00000000..08463f12 --- /dev/null +++ b/tests/phpunit/MediaWikiPHPUnitTestListener.php @@ -0,0 +1,129 @@ +getName( true ); + } + + return $name; + } + + protected function getErrorName( Exception $exception ) { + $name = get_class( $exception ); + $name = "[$name] " . $exception->getMessage(); + + return $name; + } + + /** + * An error occurred. + * + * @param PHPUnit_Framework_Test $test + * @param Exception $e + * @param float $time + */ + public function addError( PHPUnit_Framework_Test $test, Exception $e, $time ) { + parent::addError( $test, $e, $time ); + wfDebugLog( + $this->logChannel, + 'ERROR in ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e ) + ); + } + + /** + * A failure occurred. + * + * @param PHPUnit_Framework_Test $test + * @param PHPUnit_Framework_AssertionFailedError $e + * @param float $time + */ + public function addFailure( PHPUnit_Framework_Test $test, + PHPUnit_Framework_AssertionFailedError $e, $time + ) { + parent::addFailure( $test, $e, $time ); + wfDebugLog( + $this->logChannel, + 'FAILURE in ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e ) + ); + } + + /** + * Incomplete test. + * + * @param PHPUnit_Framework_Test $test + * @param Exception $e + * @param float $time + */ + public function addIncompleteTest( PHPUnit_Framework_Test $test, Exception $e, $time ) { + parent::addIncompleteTest( $test, $e, $time ); + wfDebugLog( + $this->logChannel, + 'Incomplete test ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e ) + ); + } + + /** + * Skipped test. + * + * @param PHPUnit_Framework_Test $test + * @param Exception $e + * @param float $time + */ + public function addSkippedTest( PHPUnit_Framework_Test $test, Exception $e, $time ) { + parent::addSkippedTest( $test, $e, $time ); + wfDebugLog( + $this->logChannel, + 'Skipped test ' . $this->getTestName( $test ) . ': ' . $this->getErrorName( $e ) + ); + } + + /** + * A test suite started. + * + * @param PHPUnit_Framework_TestSuite $suite + */ + public function startTestSuite( PHPUnit_Framework_TestSuite $suite ) { + parent::startTestSuite( $suite ); + wfDebugLog( $this->logChannel, 'START suite ' . $suite->getName() ); + } + + /** + * A test suite ended. + * + * @param PHPUnit_Framework_TestSuite $suite + */ + public function endTestSuite( PHPUnit_Framework_TestSuite $suite ) { + parent::endTestSuite( $suite ); + wfDebugLog( $this->logChannel, 'END suite ' . $suite->getName() ); + } + + /** + * A test started. + * + * @param PHPUnit_Framework_Test $test + */ + public function startTest( PHPUnit_Framework_Test $test ) { + parent::startTest( $test ); + wfDebugLog( $this->logChannel, 'Start test ' . $this->getTestName( $test ) ); + } + + /** + * A test ended. + * + * @param PHPUnit_Framework_Test $test + * @param float $time + */ + public function endTest( PHPUnit_Framework_Test $test, $time ) { + parent::endTest( $test, $time ); + wfDebugLog( $this->logChannel, 'End test ' . $this->getTestName( $test ) ); + } +} diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php new file mode 100644 index 00000000..995853ea --- /dev/null +++ b/tests/phpunit/MediaWikiTestCase.php @@ -0,0 +1,1141 @@ +backupGlobals = false; + $this->backupStaticAttributes = false; + } + + public function __destruct() { + // Complain if self::setUp() was called, but not self::tearDown() + // $this->called['setUp'] will be checked by self::testMediaWikiTestCaseParentSetupCalled() + if ( isset( $this->called['setUp'] ) && !isset( $this->called['tearDown'] ) ) { + throw new MWException( get_called_class() . "::tearDown() must call parent::tearDown()" ); + } + } + + public function run( PHPUnit_Framework_TestResult $result = null ) { + /* Some functions require some kind of caching, and will end up using the db, + * which we can't allow, as that would open a new connection for mysql. + * Replace with a HashBag. They would not be going to persist anyway. + */ + ObjectCache::$instances[CACHE_DB] = new HashBagOStuff; + + $needsResetDB = false; + $logName = get_class( $this ) . '::' . $this->getName( false ); + + if ( $this->needsDB() ) { + // set up a DB connection for this test to use + + self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' ); + self::$reuseDB = $this->getCliArg( 'reuse-db' ); + + $this->db = wfGetDB( DB_MASTER ); + + $this->checkDbIsSupported(); + + if ( !self::$dbSetup ) { + wfProfileIn( $logName . ' (clone-db)' ); + + // switch to a temporary clone of the database + self::setupTestDB( $this->db, $this->dbPrefix() ); + + if ( ( $this->db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) { + $this->resetDB(); + } + + wfProfileOut( $logName . ' (clone-db)' ); + } + + wfProfileIn( $logName . ' (prepare-db)' ); + $this->addCoreDBData(); + $this->addDBData(); + wfProfileOut( $logName . ' (prepare-db)' ); + + $needsResetDB = true; + } + + wfProfileIn( $logName ); + parent::run( $result ); + wfProfileOut( $logName ); + + if ( $needsResetDB ) { + wfProfileIn( $logName . ' (reset-db)' ); + $this->resetDB(); + wfProfileOut( $logName . ' (reset-db)' ); + } + } + + /** + * @since 1.21 + * + * @return bool + */ + public function usesTemporaryTables() { + return self::$useTemporaryTables; + } + + /** + * Obtains a new temporary file name + * + * The obtained filename is enlisted to be removed upon tearDown + * + * @since 1.20 + * + * @return string Absolute name of the temporary file + */ + protected function getNewTempFile() { + $fileName = tempnam( wfTempDir(), 'MW_PHPUnit_' . get_class( $this ) . '_' ); + $this->tmpFiles[] = $fileName; + + return $fileName; + } + + /** + * obtains a new temporary directory + * + * The obtained directory is enlisted to be removed (recursively with all its contained + * files) upon tearDown. + * + * @since 1.20 + * + * @return string Absolute name of the temporary directory + */ + protected function getNewTempDirectory() { + // Starting of with a temporary /file/. + $fileName = $this->getNewTempFile(); + + // Converting the temporary /file/ to a /directory/ + // + // The following is not atomic, but at least we now have a single place, + // where temporary directory creation is bundled and can be improved + unlink( $fileName ); + $this->assertTrue( wfMkdirParents( $fileName ) ); + + return $fileName; + } + + protected function setUp() { + wfProfileIn( __METHOD__ ); + parent::setUp(); + $this->called['setUp'] = true; + + $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) ); + + // Cleaning up temporary files + foreach ( $this->tmpFiles as $fileName ) { + if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) { + unlink( $fileName ); + } elseif ( is_dir( $fileName ) ) { + wfRecursiveRemoveDir( $fileName ); + } + } + + if ( $this->needsDB() && $this->db ) { + // Clean up open transactions + while ( $this->db->trxLevel() > 0 ) { + $this->db->rollback(); + } + + // don't ignore DB errors + $this->db->ignoreErrors( false ); + } + + wfProfileOut( __METHOD__ ); + } + + protected function tearDown() { + wfProfileIn( __METHOD__ ); + + $this->called['tearDown'] = true; + // Cleaning up temporary files + foreach ( $this->tmpFiles as $fileName ) { + if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) { + unlink( $fileName ); + } elseif ( is_dir( $fileName ) ) { + wfRecursiveRemoveDir( $fileName ); + } + } + + if ( $this->needsDB() && $this->db ) { + // Clean up open transactions + while ( $this->db->trxLevel() > 0 ) { + $this->db->rollback(); + } + + // don't ignore DB errors + $this->db->ignoreErrors( false ); + } + + // Restore mw globals + foreach ( $this->mwGlobals as $key => $value ) { + $GLOBALS[$key] = $value; + } + $this->mwGlobals = array(); + RequestContext::resetMain(); + MediaHandler::resetCache(); + + $phpErrorLevel = intval( ini_get( 'error_reporting' ) ); + + if ( $phpErrorLevel !== $this->phpErrorLevel ) { + ini_set( 'error_reporting', $this->phpErrorLevel ); + + $oldHex = strtoupper( dechex( $this->phpErrorLevel ) ); + $newHex = strtoupper( dechex( $phpErrorLevel ) ); + $message = "PHP error_reporting setting was left dirty: " + . "was 0x$oldHex before test, 0x$newHex after test!"; + + $this->fail( $message ); + } + + parent::tearDown(); + wfProfileOut( __METHOD__ ); + } + + /** + * Make sure MediaWikiTestCase extending classes have called their + * parent setUp method + */ + final public function testMediaWikiTestCaseParentSetupCalled() { + $this->assertArrayHasKey( 'setUp', $this->called, + get_called_class() . "::setUp() must call parent::setUp()" + ); + } + + /** + * Sets a global, maintaining a stashed version of the previous global to be + * restored in tearDown + * + * The key is added to the array of globals that will be reset afterwards + * in the tearDown(). + * + * @example + * + * protected function setUp() { + * $this->setMwGlobals( 'wgRestrictStuff', true ); + * } + * + * function testFoo() {} + * + * function testBar() {} + * $this->assertTrue( self::getX()->doStuff() ); + * + * $this->setMwGlobals( 'wgRestrictStuff', false ); + * $this->assertTrue( self::getX()->doStuff() ); + * } + * + * function testQuux() {} + * + * + * @param array|string $pairs Key to the global variable, or an array + * of key/value pairs. + * @param mixed $value Value to set the global to (ignored + * if an array is given as first argument). + * + * @since 1.21 + */ + protected function setMwGlobals( $pairs, $value = null ) { + if ( is_string( $pairs ) ) { + $pairs = array( $pairs => $value ); + } + + $this->stashMwGlobals( array_keys( $pairs ) ); + + foreach ( $pairs as $key => $value ) { + $GLOBALS[$key] = $value; + } + } + + /** + * Stashes the global, will be restored in tearDown() + * + * Individual test functions may override globals through the setMwGlobals() function + * or directly. When directly overriding globals their keys should first be passed to this + * method in setUp to avoid breaking global state for other tests + * + * That way all other tests are executed with the same settings (instead of using the + * unreliable local settings for most tests and fix it only for some tests). + * + * @param array|string $globalKeys Key to the global variable, or an array of keys. + * + * @throws Exception When trying to stash an unset global + * @since 1.23 + */ + protected function stashMwGlobals( $globalKeys ) { + if ( is_string( $globalKeys ) ) { + $globalKeys = array( $globalKeys ); + } + + foreach ( $globalKeys as $globalKey ) { + // NOTE: make sure we only save the global once or a second call to + // setMwGlobals() on the same global would override the original + // value. + if ( !array_key_exists( $globalKey, $this->mwGlobals ) ) { + if ( !array_key_exists( $globalKey, $GLOBALS ) ) { + throw new Exception( "Global with key {$globalKey} doesn't exist and cant be stashed" ); + } + // NOTE: we serialize then unserialize the value in case it is an object + // this stops any objects being passed by reference. We could use clone + // and if is_object but this does account for objects within objects! + try { + $this->mwGlobals[$globalKey] = unserialize( serialize( $GLOBALS[$globalKey] ) ); + } + // NOTE; some things such as Closures are not serializable + // in this case just set the value! + catch ( Exception $e ) { + $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey]; + } + } + } + } + + /** + * Merges the given values into a MW global array variable. + * Useful for setting some entries in a configuration array, instead of + * setting the entire array. + * + * @param string $name The name of the global, as in wgFooBar + * @param array $values The array containing the entries to set in that global + * + * @throws MWException If the designated global is not an array. + * + * @since 1.21 + */ + protected function mergeMwGlobalArrayValue( $name, $values ) { + if ( !isset( $GLOBALS[$name] ) ) { + $merged = $values; + } else { + if ( !is_array( $GLOBALS[$name] ) ) { + throw new MWException( "MW global $name is not an array." ); + } + + // NOTE: do not use array_merge, it screws up for numeric keys. + $merged = $GLOBALS[$name]; + foreach ( $values as $k => $v ) { + $merged[$k] = $v; + } + } + + $this->setMwGlobals( $name, $merged ); + } + + /** + * @return string + * @since 1.18 + */ + public function dbPrefix() { + return $this->db->getType() == 'oracle' ? self::ORA_DB_PREFIX : self::DB_PREFIX; + } + + /** + * @return bool + * @since 1.18 + */ + public function needsDB() { + # if the test says it uses database tables, it needs the database + if ( $this->tablesUsed ) { + return true; + } + + # if the test says it belongs to the Database group, it needs the database + $rc = new ReflectionClass( $this ); + if ( preg_match( '/@group +Database/im', $rc->getDocComment() ) ) { + return true; + } + + return false; + } + + /** + * Stub. If a test needs to add additional data to the database, it should + * implement this method and do so + * + * @since 1.18 + */ + public function addDBData() { + } + + private function addCoreDBData() { + if ( $this->db->getType() == 'oracle' ) { + + # Insert 0 user to prevent FK violations + # Anonymous user + $this->db->insert( 'user', array( + 'user_id' => 0, + 'user_name' => 'Anonymous' ), __METHOD__, array( 'IGNORE' ) ); + + # Insert 0 page to prevent FK violations + # Blank page + $this->db->insert( 'page', array( + 'page_id' => 0, + 'page_namespace' => 0, + 'page_title' => ' ', + 'page_restrictions' => null, + 'page_counter' => 0, + 'page_is_redirect' => 0, + 'page_is_new' => 0, + 'page_random' => 0, + 'page_touched' => $this->db->timestamp(), + 'page_latest' => 0, + 'page_len' => 0 ), __METHOD__, array( 'IGNORE' ) ); + } + + User::resetIdByNameCache(); + + //Make sysop user + $user = User::newFromName( 'UTSysop' ); + + if ( $user->idForName() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTSysopPassword' ); + + $user->addGroup( 'sysop' ); + $user->addGroup( 'bureaucrat' ); + $user->saveSettings(); + } + + //Make 1 page with 1 revision + $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); + if ( $page->getId() == 0 ) { + $page->doEditContent( + new WikitextContent( 'UTContent' ), + 'UTPageSummary', + EDIT_NEW, + false, + User::newFromName( 'UTSysop' ) ); + } + } + + /** + * Restores MediaWiki to using the table set (table prefix) it was using before + * setupTestDB() was called. Useful if we need to perform database operations + * after the test run has finished (such as saving logs or profiling info). + * + * @since 1.21 + */ + public static function teardownTestDB() { + if ( !self::$dbSetup ) { + return; + } + + CloneDatabase::changePrefix( self::$oldTablePrefix ); + + self::$oldTablePrefix = false; + self::$dbSetup = false; + } + + /** + * Creates an empty skeleton of the wiki database by cloning its structure + * to equivalent tables using the given $prefix. Then sets MediaWiki to + * use the new set of tables (aka schema) instead of the original set. + * + * This is used to generate a dummy table set, typically consisting of temporary + * tables, that will be used by tests instead of the original wiki database tables. + * + * @since 1.21 + * + * @note the original table prefix is stored in self::$oldTablePrefix. This is used + * by teardownTestDB() to return the wiki to using the original table set. + * + * @note this method only works when first called. Subsequent calls have no effect, + * even if using different parameters. + * + * @param DatabaseBase $db The database connection + * @param string $prefix The prefix to use for the new table set (aka schema). + * + * @throws MWException If the database table prefix is already $prefix + */ + public static function setupTestDB( DatabaseBase $db, $prefix ) { + global $wgDBprefix; + if ( $wgDBprefix === $prefix ) { + throw new MWException( + 'Cannot run unit tests, the database prefix is already "' . $prefix . '"' ); + } + + if ( self::$dbSetup ) { + return; + } + + $tablesCloned = self::listTables( $db ); + $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix ); + $dbClone->useTemporaryTables( self::$useTemporaryTables ); + + self::$dbSetup = true; + self::$oldTablePrefix = $wgDBprefix; + + if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) { + CloneDatabase::changePrefix( $prefix ); + + return; + } else { + $dbClone->cloneTableStructure(); + } + + if ( $db->getType() == 'oracle' ) { + $db->query( 'BEGIN FILL_WIKI_INFO; END;' ); + } + } + + /** + * Empty all tables so they can be repopulated for tests + */ + private function resetDB() { + if ( $this->db ) { + if ( $this->db->getType() == 'oracle' ) { + if ( self::$useTemporaryTables ) { + wfGetLB()->closeAll(); + $this->db = wfGetDB( DB_MASTER ); + } else { + foreach ( $this->tablesUsed as $tbl ) { + if ( $tbl == 'interwiki' ) { + continue; + } + $this->db->query( 'TRUNCATE TABLE ' . $this->db->tableName( $tbl ), __METHOD__ ); + } + } + } else { + foreach ( $this->tablesUsed as $tbl ) { + if ( $tbl == 'interwiki' || $tbl == 'user' ) { + continue; + } + $this->db->delete( $tbl, '*', __METHOD__ ); + } + } + } + } + + /** + * @since 1.18 + * + * @param string $func + * @param array $args + * + * @return mixed + * @throws MWException + */ + public function __call( $func, $args ) { + static $compatibility = array( + 'assertEmpty' => 'assertEmpty2', // assertEmpty was added in phpunit 3.7.32 + ); + + if ( isset( $compatibility[$func] ) ) { + return call_user_func_array( array( $this, $compatibility[$func] ), $args ); + } else { + throw new MWException( "Called non-existant $func method on " + . get_class( $this ) ); + } + } + + /** + * Used as a compatibility method for phpunit < 3.7.32 + * @param string $value + * @param string $msg + */ + private function assertEmpty2( $value, $msg ) { + return $this->assertTrue( $value == '', $msg ); + } + + private static function unprefixTable( $tableName ) { + global $wgDBprefix; + + return substr( $tableName, strlen( $wgDBprefix ) ); + } + + private static function isNotUnittest( $table ) { + return strpos( $table, 'unittest_' ) !== 0; + } + + /** + * @since 1.18 + * + * @param DataBaseBase $db + * + * @return array + */ + public static function listTables( $db ) { + global $wgDBprefix; + + $tables = $db->listTables( $wgDBprefix, __METHOD__ ); + + if ( $db->getType() === 'mysql' ) { + # bug 43571: cannot clone VIEWs under MySQL + $views = $db->listViews( $wgDBprefix, __METHOD__ ); + $tables = array_diff( $tables, $views ); + } + $tables = array_map( array( __CLASS__, 'unprefixTable' ), $tables ); + + // Don't duplicate test tables from the previous fataled run + $tables = array_filter( $tables, array( __CLASS__, 'isNotUnittest' ) ); + + if ( $db->getType() == 'sqlite' ) { + $tables = array_flip( $tables ); + // these are subtables of searchindex and don't need to be duped/dropped separately + unset( $tables['searchindex_content'] ); + unset( $tables['searchindex_segdir'] ); + unset( $tables['searchindex_segments'] ); + $tables = array_flip( $tables ); + } + + return $tables; + } + + /** + * @throws MWException + * @since 1.18 + */ + protected function checkDbIsSupported() { + if ( !in_array( $this->db->getType(), $this->supportedDBs ) ) { + throw new MWException( $this->db->getType() . " is not currently supported for unit testing." ); + } + } + + /** + * @since 1.18 + * @param string $offset + * @return mixed + */ + public function getCliArg( $offset ) { + if ( isset( PHPUnitMaintClass::$additionalOptions[$offset] ) ) { + return PHPUnitMaintClass::$additionalOptions[$offset]; + } + } + + /** + * @since 1.18 + * @param string $offset + * @param mixed $value + */ + public function setCliArg( $offset, $value ) { + PHPUnitMaintClass::$additionalOptions[$offset] = $value; + } + + /** + * Don't throw a warning if $function is deprecated and called later + * + * @since 1.19 + * + * @param string $function + */ + public function hideDeprecated( $function ) { + wfSuppressWarnings(); + wfDeprecated( $function ); + wfRestoreWarnings(); + } + + /** + * Asserts that the given database query yields the rows given by $expectedRows. + * The expected rows should be given as indexed (not associative) arrays, with + * the values given in the order of the columns in the $fields parameter. + * Note that the rows are sorted by the columns given in $fields. + * + * @since 1.20 + * + * @param string|array $table The table(s) to query + * @param string|array $fields The columns to include in the result (and to sort by) + * @param string|array $condition "where" condition(s) + * @param array $expectedRows An array of arrays giving the expected rows. + * + * @throws MWException If this test cases's needsDB() method doesn't return true. + * Test cases can use "@group Database" to enable database test support, + * or list the tables under testing in $this->tablesUsed, or override the + * needsDB() method. + */ + protected function assertSelect( $table, $fields, $condition, array $expectedRows ) { + if ( !$this->needsDB() ) { + throw new MWException( 'When testing database state, the test cases\'s needDB()' . + ' method should return true. Use @group Database or $this->tablesUsed.' ); + } + + $db = wfGetDB( DB_SLAVE ); + + $res = $db->select( $table, $fields, $condition, wfGetCaller(), array( 'ORDER BY' => $fields ) ); + $this->assertNotEmpty( $res, "query failed: " . $db->lastError() ); + + $i = 0; + + foreach ( $expectedRows as $expected ) { + $r = $res->fetchRow(); + self::stripStringKeys( $r ); + + $i += 1; + $this->assertNotEmpty( $r, "row #$i missing" ); + + $this->assertEquals( $expected, $r, "row #$i mismatches" ); + } + + $r = $res->fetchRow(); + self::stripStringKeys( $r ); + + $this->assertFalse( $r, "found extra row (after #$i)" ); + } + + /** + * Utility method taking an array of elements and wrapping + * each element in it's own array. Useful for data providers + * that only return a single argument. + * + * @since 1.20 + * + * @param array $elements + * + * @return array + */ + protected function arrayWrap( array $elements ) { + return array_map( + function ( $element ) { + return array( $element ); + }, + $elements + ); + } + + /** + * Assert that two arrays are equal. By default this means that both arrays need to hold + * the same set of values. Using additional arguments, order and associated key can also + * be set as relevant. + * + * @since 1.20 + * + * @param array $expected + * @param array $actual + * @param bool $ordered If the order of the values should match + * @param bool $named If the keys should match + */ + protected function assertArrayEquals( array $expected, array $actual, + $ordered = false, $named = false + ) { + if ( !$ordered ) { + $this->objectAssociativeSort( $expected ); + $this->objectAssociativeSort( $actual ); + } + + if ( !$named ) { + $expected = array_values( $expected ); + $actual = array_values( $actual ); + } + + call_user_func_array( + array( $this, 'assertEquals' ), + array_merge( array( $expected, $actual ), array_slice( func_get_args(), 4 ) ) + ); + } + + /** + * Put each HTML element on its own line and then equals() the results + * + * Use for nicely formatting of PHPUnit diff output when comparing very + * simple HTML + * + * @since 1.20 + * + * @param string $expected HTML on oneline + * @param string $actual HTML on oneline + * @param string $msg Optional message + */ + protected function assertHTMLEquals( $expected, $actual, $msg = '' ) { + $expected = str_replace( '>', ">\n", $expected ); + $actual = str_replace( '>', ">\n", $actual ); + + $this->assertEquals( $expected, $actual, $msg ); + } + + /** + * Does an associative sort that works for objects. + * + * @since 1.20 + * + * @param array $array + */ + protected function objectAssociativeSort( array &$array ) { + uasort( + $array, + function ( $a, $b ) { + return serialize( $a ) > serialize( $b ) ? 1 : -1; + } + ); + } + + /** + * Utility function for eliminating all string keys from an array. + * Useful to turn a database result row as returned by fetchRow() into + * a pure indexed array. + * + * @since 1.20 + * + * @param mixed $r The array to remove string keys from. + */ + protected static function stripStringKeys( &$r ) { + if ( !is_array( $r ) ) { + return; + } + + foreach ( $r as $k => $v ) { + if ( is_string( $k ) ) { + unset( $r[$k] ); + } + } + } + + /** + * Asserts that the provided variable is of the specified + * internal type or equals the $value argument. This is useful + * for testing return types of functions that return a certain + * type or *value* when not set or on error. + * + * @since 1.20 + * + * @param string $type + * @param mixed $actual + * @param mixed $value + * @param string $message + */ + protected function assertTypeOrValue( $type, $actual, $value = false, $message = '' ) { + if ( $actual === $value ) { + $this->assertTrue( true, $message ); + } else { + $this->assertType( $type, $actual, $message ); + } + } + + /** + * Asserts the type of the provided value. This can be either + * in internal type such as boolean or integer, or a class or + * interface the value extends or implements. + * + * @since 1.20 + * + * @param string $type + * @param mixed $actual + * @param string $message + */ + protected function assertType( $type, $actual, $message = '' ) { + if ( class_exists( $type ) || interface_exists( $type ) ) { + $this->assertInstanceOf( $type, $actual, $message ); + } else { + $this->assertInternalType( $type, $actual, $message ); + } + } + + /** + * Returns true if the given namespace defaults to Wikitext + * according to $wgNamespaceContentModels + * + * @param int $ns The namespace ID to check + * + * @return bool + * @since 1.21 + */ + protected function isWikitextNS( $ns ) { + global $wgNamespaceContentModels; + + if ( isset( $wgNamespaceContentModels[$ns] ) ) { + return $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT; + } + + return true; + } + + /** + * Returns the ID of a namespace that defaults to Wikitext. + * + * @throws MWException If there is none. + * @return int The ID of the wikitext Namespace + * @since 1.21 + */ + protected function getDefaultWikitextNS() { + global $wgNamespaceContentModels; + + static $wikitextNS = null; // this is not going to change + if ( $wikitextNS !== null ) { + return $wikitextNS; + } + + // quickly short out on most common case: + if ( !isset( $wgNamespaceContentModels[NS_MAIN] ) ) { + return NS_MAIN; + } + + // NOTE: prefer content namespaces + $namespaces = array_unique( array_merge( + MWNamespace::getContentNamespaces(), + array( NS_MAIN, NS_HELP, NS_PROJECT ), // prefer these + MWNamespace::getValidNamespaces() + ) ); + + $namespaces = array_diff( $namespaces, array( + NS_FILE, NS_CATEGORY, NS_MEDIAWIKI, NS_USER // don't mess with magic namespaces + ) ); + + $talk = array_filter( $namespaces, function ( $ns ) { + return MWNamespace::isTalk( $ns ); + } ); + + // prefer non-talk pages + $namespaces = array_diff( $namespaces, $talk ); + $namespaces = array_merge( $namespaces, $talk ); + + // check default content model of each namespace + foreach ( $namespaces as $ns ) { + if ( !isset( $wgNamespaceContentModels[$ns] ) || + $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT + ) { + + $wikitextNS = $ns; + + return $wikitextNS; + } + } + + // give up + // @todo Inside a test, we could skip the test as incomplete. + // But frequently, this is used in fixture setup. + throw new MWException( "No namespace defaults to wikitext!" ); + } + + /** + * Check, if $wgDiff3 is set and ready to merge + * Will mark the calling test as skipped, if not ready + * + * @since 1.21 + */ + protected function checkHasDiff3() { + global $wgDiff3; + + # This check may also protect against code injection in + # case of broken installations. + wfSuppressWarnings(); + $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 ); + wfRestoreWarnings(); + + if ( !$haveDiff3 ) { + $this->markTestSkipped( "Skip test, since diff3 is not configured" ); + } + } + + /** + * Check whether we have the 'gzip' commandline utility, will skip + * the test whenever "gzip -V" fails. + * + * Result is cached at the process level. + * + * @return bool + * + * @since 1.21 + */ + protected function checkHasGzip() { + static $haveGzip; + + if ( $haveGzip === null ) { + $retval = null; + wfShellExec( 'gzip -V', $retval ); + $haveGzip = ( $retval === 0 ); + } + + if ( !$haveGzip ) { + $this->markTestSkipped( "Skip test, requires the gzip utility in PATH" ); + } + + return $haveGzip; + } + + /** + * Check if $extName is a loaded PHP extension, will skip the + * test whenever it is not loaded. + * + * @since 1.21 + * @param string $extName + * @return bool + */ + protected function checkPHPExtension( $extName ) { + $loaded = extension_loaded( $extName ); + if ( !$loaded ) { + $this->markTestSkipped( "PHP extension '$extName' is not loaded, skipping." ); + } + + return $loaded; + } + + /** + * Asserts that an exception of the specified type occurs when running + * the provided code. + * + * @since 1.21 + * @deprecated since 1.22 Use setExpectedException + * + * @param callable $code + * @param string $expected + * @param string $message + */ + protected function assertException( $code, $expected = 'Exception', $message = '' ) { + $pokemons = null; + + try { + call_user_func( $code ); + } catch ( Exception $pokemons ) { + // Gotta Catch 'Em All! + } + + if ( $message === '' ) { + $message = 'An exception of type "' . $expected . '" should have been thrown'; + } + + $this->assertInstanceOf( $expected, $pokemons, $message ); + } + + /** + * Asserts that the given string is a valid HTML snippet. + * Wraps the given string in the required top level tags and + * then calls assertValidHtmlDocument(). + * The snippet is expected to be HTML 5. + * + * @since 1.23 + * + * @note Will mark the test as skipped if the "tidy" module is not installed. + * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially) + * when automatic tidying is disabled. + * + * @param string $html An HTML snippet (treated as the contents of the body tag). + */ + protected function assertValidHtmlSnippet( $html ) { + $html = 'test' . $html . ''; + $this->assertValidHtmlDocument( $html ); + } + + /** + * Asserts that the given string is valid HTML document. + * + * @since 1.23 + * + * @note Will mark the test as skipped if the "tidy" module is not installed. + * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially) + * when automatic tidying is disabled. + * + * @param string $html A complete HTML document + */ + protected function assertValidHtmlDocument( $html ) { + // Note: we only validate if the tidy PHP extension is available. + // In case wgTidyInternal is false, MWTidy would fall back to the command line version + // of tidy. In that case however, we can not reliably detect whether a failing validation + // is due to malformed HTML, or caused by tidy not being installed as a command line tool. + // That would cause all HTML assertions to fail on a system that has no tidy installed. + if ( !$GLOBALS['wgTidyInternal'] ) { + $this->markTestSkipped( 'Tidy extension not installed' ); + } + + $errorBuffer = ''; + MWTidy::checkErrors( $html, $errorBuffer ); + $allErrors = preg_split( '/[\r\n]+/', $errorBuffer ); + + // Filter Tidy warnings which aren't useful for us. + // Tidy eg. often cries about parameters missing which have actually + // been deprecated since HTML4, thus we should not care about them. + $errors = preg_grep( + '/^(.*Warning: (trimming empty|.* lacks ".*?" attribute).*|\s*)$/m', + $allErrors, PREG_GREP_INVERT + ); + + $this->assertEmpty( $errors, implode( "\n", $errors ) ); + } + + /** + * Note: we are overriding this method to remove the deprecated error + * @see https://bugzilla.wikimedia.org/show_bug.cgi?id=69505 + * @see https://github.com/sebastianbergmann/phpunit/issues/1292 + * + * @param array $matcher + * @param string $actual + * @param string $message + * @param bool $isHtml + */ + public static function assertTag( $matcher, $actual, $message = '', $isHtml = true ) { + //trigger_error(__METHOD__ . ' is deprecated', E_USER_DEPRECATED); + + $dom = PHPUnit_Util_XML::load( $actual, $isHtml ); + $tags = PHPUnit_Util_XML::findNodes( $dom, $matcher, $isHtml ); + $matched = count( $tags ) > 0 && $tags[0] instanceof DOMNode; + + self::assertTrue( $matched, $message ); + } +} diff --git a/tests/phpunit/README b/tests/phpunit/README new file mode 100644 index 00000000..0a32ba17 --- /dev/null +++ b/tests/phpunit/README @@ -0,0 +1,53 @@ +== MediaWiki PHPUnit Tests == + +The unit tests for MediaWiki are implemented using the PHPUnit testing +framework and require PHPUnit to run. + + +=== WARNING === + +Some of the unit tests are DESTRUCTIVE and WILL ALTER YOUR WIKI'S CONTENTS. + +DO NOT RUN THESE TESTS ON A PRODUCTION SYSTEM OR ON ANY SYSTEM WHERE YOU NEED +TO RETAIN YOUR DATA. + + +== Installation == + +If PHPUnit is not installed, follow the installation instructions in the +PHPUnit Manual at: + + http://www.phpunit.de/manual/current/en/installation.html + +- or - + +On Unix-like operating systems, run: + + make install + + +== Running tests == + +The tests are run from your operating system's command line. + +Ensure that you are in the tests/phpunit directory of your MediaWiki +installation. + + +On Unix-like operating systems, the tests runs are controlled with a makefile. +Run command: + + make help + +for a full list of options for running tests. + + +On Windows-family operating systems, run the 'run-tests.bat' batch file. + + +=== Writing tests === + +A guide to writing unit tests for MediaWiki can be found at: + + http://mediawiki.org/wiki/Unit_Testing + diff --git a/tests/phpunit/ResourceLoaderTestCase.php b/tests/phpunit/ResourceLoaderTestCase.php new file mode 100644 index 00000000..f5f302e0 --- /dev/null +++ b/tests/phpunit/ResourceLoaderTestCase.php @@ -0,0 +1,95 @@ + $lang, + 'modules' => 'startup', + 'only' => 'scripts', + 'skin' => 'vector', + 'target' => 'test', + ) ); + return new ResourceLoaderContext( $resourceLoader, $request ); + } + + protected function setUp() { + parent::setUp(); + + ResourceLoader::clearCache(); + + $this->setMwGlobals( array( + // For ResourceLoader::inDebugMode since it doesn't have context + 'wgResourceLoaderDebug' => true, + + // Avoid influence from wgInvalidateCacheOnLocalSettingsChange + 'wgCacheEpoch' => '20140101000000', + + // For ResourceLoader::__construct() + 'wgResourceLoaderSources' => array(), + + // For wfScript() + 'wgScriptPath' => '/w', + 'wgScriptExtension' => '.php', + 'wgScript' => '/w/index.php', + 'wgLoadScript' => '/w/load.php', + ) ); + } +} + +/* Stubs */ + +class ResourceLoaderTestModule extends ResourceLoaderModule { + protected $dependencies = array(); + protected $group = null; + protected $source = 'local'; + protected $script = ''; + protected $styles = ''; + protected $skipFunction = null; + protected $isRaw = false; + protected $targets = array( 'test' ); + + public function __construct( $options = array() ) { + foreach ( $options as $key => $value ) { + $this->$key = $value; + } + } + + public function getScript( ResourceLoaderContext $context ) { + return $this->script; + } + + public function getStyles( ResourceLoaderContext $context ) { + return array( '' => $this->styles ); + } + + public function getDependencies() { + return $this->dependencies; + } + + public function getGroup() { + return $this->group; + } + + public function getSource() { + return $this->source; + } + + public function getSkipFunction() { + return $this->skipFunction; + } + + public function isRaw() { + return $this->isRaw; + } +} + +class ResourceLoaderFileModuleTestModule extends ResourceLoaderFileModule { +} + +class ResourceLoaderWikiModuleTestModule extends ResourceLoaderWikiModule { + // Override expected via PHPUnit mocks and stubs + protected function getPages( ResourceLoaderContext $context ) { + return array(); + } +} diff --git a/tests/phpunit/TODO b/tests/phpunit/TODO new file mode 100644 index 00000000..cd9b9e2d --- /dev/null +++ b/tests/phpunit/TODO @@ -0,0 +1,20 @@ +== Things To Do == + +* Most of the tests are named poorly; + naming should describe a use case in story-like language, + not simply identify the unit under test. + An example would be the difference between "testCalculate" + and "testAddingIntegersTogetherWorks". + +* Many of the tests make multiple assertions, and are thus not unitary tests. + By using data-providers and more use-case oriented test selection + nearly all of these cases can be easily resolved. + +* Some of the test files are either incorrectly named or in the wrong folder. + Tests should be organized in a mirrored structure to the source they are testing, + and named the same, with the exception of the word "Test" at the end. + +* Shared set-up code or base classes are present, + but usually named improperly or appear to be poorly factored. + Support code should share as much of the same naming as the code it's supporting, + and test and test-case depenencies should be considered to resolve other shared needs. diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php new file mode 100644 index 00000000..121aade9 --- /dev/null +++ b/tests/phpunit/bootstrap.php @@ -0,0 +1,36 @@ + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/phpunit/data/media/LoremIpsum.djvu b/tests/phpunit/data/media/LoremIpsum.djvu new file mode 100644 index 00000000..42f47cd0 Binary files /dev/null and b/tests/phpunit/data/media/LoremIpsum.djvu differ diff --git a/tests/phpunit/data/media/Png-native-test.png b/tests/phpunit/data/media/Png-native-test.png new file mode 100644 index 00000000..a0b81ca9 Binary files /dev/null and b/tests/phpunit/data/media/Png-native-test.png differ diff --git a/tests/phpunit/data/media/QA_icon.svg b/tests/phpunit/data/media/QA_icon.svg new file mode 100644 index 00000000..6b5d86e4 --- /dev/null +++ b/tests/phpunit/data/media/QA_icon.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + \ No newline at end of file diff --git a/tests/phpunit/data/media/README b/tests/phpunit/data/media/README new file mode 100644 index 00000000..9913f685 --- /dev/null +++ b/tests/phpunit/data/media/README @@ -0,0 +1,61 @@ +This directory contains media files for use with the +tests in includes/media directory. + +Image credits: + +QA_icon.svg: +http://es.wikipedia.org/wiki/Archivo:QA_icon.svg +GNU Lesser General Public License +~~helix84 (16.4.2007), Philverney (6.12.2005) David Vignoni + +Gtk-media-play-ltr.svg +http://commons.wikimedia.org/wiki/File:Gtk-media-play-ltr.svg +GNU Lesser General Public License +http://ftp.gnome.org/pub/GNOME/sources/gnome-themes-extras/0.9/gnome-themes-extras-0.9.0.tar.gz +David Vignoni + +US_states_by_total_state_tax_revenue.svg +http://commons.wikimedia.org/wiki/File:US_states_by_total_state_tax_revenue.svg +CC BY 3.0 +TastyCakes on English Wikipedia + +greyscale-na-png.png, rgb-png.png, Xmp-exif-multilingual_test.jpg +greyscale-png.png, 1bit-png.png, Png-native-test.png, rgb-na-png.png, +test.tiff, test.jpg, jpeg-comment-multiple.jpg, jpeg-comment-utf.jpg, +jpeg-comment-iso8859-1.jpg, jpeg-comment-binary.jpg, jpeg-xmp-psir.jpg, +jpeg-xmp-alt.jpg, animated.gif, exif-user-comment.jpg, animated-xmp.gif, +iptc-timetest-invalid.jpg, jpeg-iptc-bad-hash.jpg, iptc-timetest.jpg, +xmp.png, nonanimated.gif, exif-gps.jpg, jpeg-xmp-psir.xmp, jpeg-iptc-good-hash.jpg, +jpeg-padding-even.jpg, jpeg-padding-odd.jpg +Are all by Bawolff. I don't think they contain enough originality to +claim copyright, but on the off chance they do, feel free to use them +however you feel fit, without restriction. + +Animated_PNG_example_bouncing_beach_ball.png +http://commons.wikimedia.org/wiki/File:Animated_PNG_example_bouncing_beach_ball.png (originally http://www.treebuilder.de/default.asp?file=89031.xml ) +Public Domain +Holger Will + +Tux.svg +https://commons.wikimedia.org/wiki/File:Tux.svg +Larry Ewing, Simon Budig, Anja Gerwinski +"The copyright holder of this file allows anyone to use it for any purpose, provided that the copyright holder is properly attributed. Redistribution, derivative work, commercial use, and all other use is permitted." + +Speech_bubbles.svg (Modified slightly) +https://commons.wikimedia.org/wiki/File:Speech_bubbles.svg +CC BY-SA 3.0 +Jarry1250 + +Soccer_ball_animated.svg +https://commons.wikimedia.org/wiki/File:Soccer_ball_animated.svg +GFDL 1.2 or later, CC-BY-SA 3.0 unported, CC-BY-SA 2.5 generic, CC-BY-SA 2.0 generic, or CC-BY-SA 1.0 generic +Pumbaa80 + +Bishzilla_blink.gif +https://commons.wikimedia.org/wiki/File:Bishzilla_blink.gif +Public domain +Bishonen + +say-test.ogg +Public domain +Brian Wolff diff --git a/tests/phpunit/data/media/Soccer_ball_animated.svg b/tests/phpunit/data/media/Soccer_ball_animated.svg new file mode 100644 index 00000000..6bd82fc4 --- /dev/null +++ b/tests/phpunit/data/media/Soccer_ball_animated.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/phpunit/data/media/Speech_bubbles.svg b/tests/phpunit/data/media/Speech_bubbles.svg new file mode 100644 index 00000000..6b1ef7a9 --- /dev/null +++ b/tests/phpunit/data/media/Speech_bubbles.svg @@ -0,0 +1,14 @@ + + + + + + + Hallo!BonjourHallo!Hello! + Hallo! Wiegeht's?Bonjour,ça va?Hallo! Hoegaat het?Hello! Howare you? + Ça va bien,et toi?Goed,met jou?I'm well, you? + + + + + diff --git a/tests/phpunit/data/media/Toll_Texas_1.svg b/tests/phpunit/data/media/Toll_Texas_1.svg new file mode 100644 index 00000000..73004e3e --- /dev/null +++ b/tests/phpunit/data/media/Toll_Texas_1.svg @@ -0,0 +1,150 @@ + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/phpunit/data/media/Tux.svg b/tests/phpunit/data/media/Tux.svg new file mode 100644 index 00000000..39561078 --- /dev/null +++ b/tests/phpunit/data/media/Tux.svg @@ -0,0 +1,902 @@ + + + Tux + For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg b/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg new file mode 100644 index 00000000..9afea859 --- /dev/null +++ b/tests/phpunit/data/media/US_states_by_total_state_tax_revenue.svg @@ -0,0 +1,248 @@ + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/phpunit/data/media/Wikimedia-logo.svg b/tests/phpunit/data/media/Wikimedia-logo.svg new file mode 100644 index 00000000..1e17acbe --- /dev/null +++ b/tests/phpunit/data/media/Wikimedia-logo.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg b/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg new file mode 100644 index 00000000..f7b23025 Binary files /dev/null and b/tests/phpunit/data/media/Xmp-exif-multilingual_test.jpg differ diff --git a/tests/phpunit/data/media/animated-xmp.gif b/tests/phpunit/data/media/animated-xmp.gif new file mode 100644 index 00000000..fcba079d Binary files /dev/null and b/tests/phpunit/data/media/animated-xmp.gif differ diff --git a/tests/phpunit/data/media/animated.gif b/tests/phpunit/data/media/animated.gif new file mode 100644 index 00000000..a8f248b3 Binary files /dev/null and b/tests/phpunit/data/media/animated.gif differ diff --git a/tests/phpunit/data/media/broken_exif_date.jpg b/tests/phpunit/data/media/broken_exif_date.jpg new file mode 100644 index 00000000..82f62f57 Binary files /dev/null and b/tests/phpunit/data/media/broken_exif_date.jpg differ diff --git a/tests/phpunit/data/media/exif-gps.jpg b/tests/phpunit/data/media/exif-gps.jpg new file mode 100644 index 00000000..40137340 Binary files /dev/null and b/tests/phpunit/data/media/exif-gps.jpg differ diff --git a/tests/phpunit/data/media/exif-user-comment.jpg b/tests/phpunit/data/media/exif-user-comment.jpg new file mode 100644 index 00000000..9f23966a Binary files /dev/null and b/tests/phpunit/data/media/exif-user-comment.jpg differ diff --git a/tests/phpunit/data/media/greyscale-na-png.png b/tests/phpunit/data/media/greyscale-na-png.png new file mode 100644 index 00000000..4a4b7452 Binary files /dev/null and b/tests/phpunit/data/media/greyscale-na-png.png differ diff --git a/tests/phpunit/data/media/greyscale-png.png b/tests/phpunit/data/media/greyscale-png.png new file mode 100644 index 00000000..340a67b4 Binary files /dev/null and b/tests/phpunit/data/media/greyscale-png.png differ diff --git a/tests/phpunit/data/media/iptc-invalid-psir.jpg b/tests/phpunit/data/media/iptc-invalid-psir.jpg new file mode 100644 index 00000000..01b9acf3 Binary files /dev/null and b/tests/phpunit/data/media/iptc-invalid-psir.jpg differ diff --git a/tests/phpunit/data/media/iptc-timetest-invalid.jpg b/tests/phpunit/data/media/iptc-timetest-invalid.jpg new file mode 100644 index 00000000..b03e192a Binary files /dev/null and b/tests/phpunit/data/media/iptc-timetest-invalid.jpg differ diff --git a/tests/phpunit/data/media/iptc-timetest.jpg b/tests/phpunit/data/media/iptc-timetest.jpg new file mode 100644 index 00000000..db9932ba Binary files /dev/null and b/tests/phpunit/data/media/iptc-timetest.jpg differ diff --git a/tests/phpunit/data/media/jpeg-comment-binary.jpg b/tests/phpunit/data/media/jpeg-comment-binary.jpg new file mode 100644 index 00000000..b467fe43 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-comment-binary.jpg differ diff --git a/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg b/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg new file mode 100644 index 00000000..d9ffbac1 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-comment-iso8859-1.jpg differ diff --git a/tests/phpunit/data/media/jpeg-comment-multiple.jpg b/tests/phpunit/data/media/jpeg-comment-multiple.jpg new file mode 100644 index 00000000..363c7385 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-comment-multiple.jpg differ diff --git a/tests/phpunit/data/media/jpeg-comment-utf.jpg b/tests/phpunit/data/media/jpeg-comment-utf.jpg new file mode 100644 index 00000000..d6d35b4b Binary files /dev/null and b/tests/phpunit/data/media/jpeg-comment-utf.jpg differ diff --git a/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg b/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg new file mode 100644 index 00000000..6464c5b8 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-iptc-bad-hash.jpg differ diff --git a/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg b/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg new file mode 100644 index 00000000..ef970854 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-iptc-good-hash.jpg differ diff --git a/tests/phpunit/data/media/jpeg-padding-even.jpg b/tests/phpunit/data/media/jpeg-padding-even.jpg new file mode 100644 index 00000000..c83c66bd Binary files /dev/null and b/tests/phpunit/data/media/jpeg-padding-even.jpg differ diff --git a/tests/phpunit/data/media/jpeg-padding-odd.jpg b/tests/phpunit/data/media/jpeg-padding-odd.jpg new file mode 100644 index 00000000..25b93308 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-padding-odd.jpg differ diff --git a/tests/phpunit/data/media/jpeg-xmp-alt.jpg b/tests/phpunit/data/media/jpeg-xmp-alt.jpg new file mode 100644 index 00000000..0e2c3f63 Binary files /dev/null and b/tests/phpunit/data/media/jpeg-xmp-alt.jpg differ diff --git a/tests/phpunit/data/media/jpeg-xmp-psir.jpg b/tests/phpunit/data/media/jpeg-xmp-psir.jpg new file mode 100644 index 00000000..4d19fcbe Binary files /dev/null and b/tests/phpunit/data/media/jpeg-xmp-psir.jpg differ diff --git a/tests/phpunit/data/media/jpeg-xmp-psir.xmp b/tests/phpunit/data/media/jpeg-xmp-psir.xmp new file mode 100644 index 00000000..fee6ee18 --- /dev/null +++ b/tests/phpunit/data/media/jpeg-xmp-psir.xmp @@ -0,0 +1,35 @@ + + + + + + jpeg-xmp-psir.jpg + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/phpunit/data/media/landscape-plain.jpg b/tests/phpunit/data/media/landscape-plain.jpg new file mode 100644 index 00000000..cf296555 Binary files /dev/null and b/tests/phpunit/data/media/landscape-plain.jpg differ diff --git a/tests/phpunit/data/media/nonanimated.gif b/tests/phpunit/data/media/nonanimated.gif new file mode 100644 index 00000000..9e52a7f0 Binary files /dev/null and b/tests/phpunit/data/media/nonanimated.gif differ diff --git a/tests/phpunit/data/media/portrait-rotated.jpg b/tests/phpunit/data/media/portrait-rotated.jpg new file mode 100644 index 00000000..445feaed Binary files /dev/null and b/tests/phpunit/data/media/portrait-rotated.jpg differ diff --git a/tests/phpunit/data/media/rgb-na-png.png b/tests/phpunit/data/media/rgb-na-png.png new file mode 100644 index 00000000..2f2a5ca0 Binary files /dev/null and b/tests/phpunit/data/media/rgb-na-png.png differ diff --git a/tests/phpunit/data/media/rgb-png.png b/tests/phpunit/data/media/rgb-png.png new file mode 100644 index 00000000..6f40cc92 Binary files /dev/null and b/tests/phpunit/data/media/rgb-png.png differ diff --git a/tests/phpunit/data/media/say-test.ogg b/tests/phpunit/data/media/say-test.ogg new file mode 100644 index 00000000..5d814fb2 Binary files /dev/null and b/tests/phpunit/data/media/say-test.ogg differ diff --git a/tests/phpunit/data/media/test.jpg b/tests/phpunit/data/media/test.jpg new file mode 100644 index 00000000..cb084253 Binary files /dev/null and b/tests/phpunit/data/media/test.jpg differ diff --git a/tests/phpunit/data/media/test.tiff b/tests/phpunit/data/media/test.tiff new file mode 100644 index 00000000..6a36f760 Binary files /dev/null and b/tests/phpunit/data/media/test.tiff differ diff --git a/tests/phpunit/data/media/xmp.png b/tests/phpunit/data/media/xmp.png new file mode 100644 index 00000000..6b9f7a87 Binary files /dev/null and b/tests/phpunit/data/media/xmp.png differ diff --git a/tests/phpunit/data/parser/LoremIpsum.djvu b/tests/phpunit/data/parser/LoremIpsum.djvu new file mode 100644 index 00000000..42f47cd0 Binary files /dev/null and b/tests/phpunit/data/parser/LoremIpsum.djvu differ diff --git a/tests/phpunit/data/parser/headbg.jpg b/tests/phpunit/data/parser/headbg.jpg new file mode 100644 index 00000000..5491c6e4 Binary files /dev/null and b/tests/phpunit/data/parser/headbg.jpg differ diff --git a/tests/phpunit/data/parser/wiki.png b/tests/phpunit/data/parser/wiki.png new file mode 100644 index 00000000..8c421183 Binary files /dev/null and b/tests/phpunit/data/parser/wiki.png differ diff --git a/tests/phpunit/data/upload/headbg.jpg b/tests/phpunit/data/upload/headbg.jpg new file mode 100644 index 00000000..5491c6e4 Binary files /dev/null and b/tests/phpunit/data/upload/headbg.jpg differ diff --git a/tests/phpunit/data/xmp/1.result.php b/tests/phpunit/data/xmp/1.result.php new file mode 100644 index 00000000..beead1bd --- /dev/null +++ b/tests/phpunit/data/xmp/1.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) +); diff --git a/tests/phpunit/data/xmp/1.xmp b/tests/phpunit/data/xmp/1.xmp new file mode 100644 index 00000000..66e15427 --- /dev/null +++ b/tests/phpunit/data/xmp/1.xmp @@ -0,0 +1,11 @@ + + + + +True 0 1 False False + + diff --git a/tests/phpunit/data/xmp/2.result.php b/tests/phpunit/data/xmp/2.result.php new file mode 100644 index 00000000..beead1bd --- /dev/null +++ b/tests/phpunit/data/xmp/2.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) +); diff --git a/tests/phpunit/data/xmp/2.xmp b/tests/phpunit/data/xmp/2.xmp new file mode 100644 index 00000000..0fa6a894 --- /dev/null +++ b/tests/phpunit/data/xmp/2.xmp @@ -0,0 +1,12 @@ + + + + + +True 1 False False + + diff --git a/tests/phpunit/data/xmp/3-invalid.result.php b/tests/phpunit/data/xmp/3-invalid.result.php new file mode 100644 index 00000000..5741b2c9 --- /dev/null +++ b/tests/phpunit/data/xmp/3-invalid.result.php @@ -0,0 +1,7 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ) +); diff --git a/tests/phpunit/data/xmp/3-invalid.xmp b/tests/phpunit/data/xmp/3-invalid.xmp new file mode 100644 index 00000000..2425e254 --- /dev/null +++ b/tests/phpunit/data/xmp/3-invalid.xmp @@ -0,0 +1,31 @@ + + + + + + + + +0/10 + +fred + + + + + + + + +1 +False + + False False + + diff --git a/tests/phpunit/data/xmp/3.result.php b/tests/phpunit/data/xmp/3.result.php new file mode 100644 index 00000000..beead1bd --- /dev/null +++ b/tests/phpunit/data/xmp/3.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) +); diff --git a/tests/phpunit/data/xmp/3.xmp b/tests/phpunit/data/xmp/3.xmp new file mode 100644 index 00000000..2cf19883 --- /dev/null +++ b/tests/phpunit/data/xmp/3.xmp @@ -0,0 +1,29 @@ + + + + + + + +0/10 + +fred + + + + + + + +True + +1 +False + + False False + + diff --git a/tests/phpunit/data/xmp/4.result.php b/tests/phpunit/data/xmp/4.result.php new file mode 100644 index 00000000..5741b2c9 --- /dev/null +++ b/tests/phpunit/data/xmp/4.result.php @@ -0,0 +1,7 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ) +); diff --git a/tests/phpunit/data/xmp/4.xmp b/tests/phpunit/data/xmp/4.xmp new file mode 100644 index 00000000..29eb614b --- /dev/null +++ b/tests/phpunit/data/xmp/4.xmp @@ -0,0 +1,22 @@ + + + + + + + +0/10 + + +True 0 1 False False + + + + + + diff --git a/tests/phpunit/data/xmp/5.result.php b/tests/phpunit/data/xmp/5.result.php new file mode 100644 index 00000000..5741b2c9 --- /dev/null +++ b/tests/phpunit/data/xmp/5.result.php @@ -0,0 +1,7 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ) +); diff --git a/tests/phpunit/data/xmp/5.xmp b/tests/phpunit/data/xmp/5.xmp new file mode 100644 index 00000000..3cc61d68 --- /dev/null +++ b/tests/phpunit/data/xmp/5.xmp @@ -0,0 +1,16 @@ + + + + + + +True 0 1 False False + + + + + + diff --git a/tests/phpunit/data/xmp/6.result.php b/tests/phpunit/data/xmp/6.result.php new file mode 100644 index 00000000..beead1bd --- /dev/null +++ b/tests/phpunit/data/xmp/6.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) +); diff --git a/tests/phpunit/data/xmp/6.xmp b/tests/phpunit/data/xmp/6.xmp new file mode 100644 index 00000000..f435ab23 --- /dev/null +++ b/tests/phpunit/data/xmp/6.xmp @@ -0,0 +1,18 @@ + + + + +0/10 + + + + + +True 0 1 False False + + diff --git a/tests/phpunit/data/xmp/7.result.php b/tests/phpunit/data/xmp/7.result.php new file mode 100644 index 00000000..115cdc92 --- /dev/null +++ b/tests/phpunit/data/xmp/7.result.php @@ -0,0 +1,52 @@ + + array( + 'CameraOwnerName' => 'Me!', + ), + 'xmp-general' => + array( + 'LicenseUrl' => 'http://creativecommons.com/cc-by-2.9', + 'ImageDescription' => + array( + 'x-default' => 'Test image for the cc: xmp: xmpRights: namespaces in xmp', + '_type' => 'lang', + ), + 'ObjectName' => + array( + 'x-default' => 'xmp core/xmp rights/cc ns test', + '_type' => 'lang', + ), + 'DateTimeDigitized' => '2005:04:03', + 'Software' => 'The one true editor: Vi (ok i used gimp)', + 'Identifier' => + array( + 0 => 'http://example.com/identifierurl', + 1 => 'urn:sha1:342524abcdef', + '_type' => 'ul', + ), + 'Label' => 'Test image', + 'DateTimeMetadata' => '2011:05:12', + 'DateTime' => '2007:03:04 06:34:10', + 'Nickname' => 'My little xmp test image', + 'Rating' => '5', + 'RightsCertificate' => 'http://example.com/rights-certificate/', + 'Copyrighted' => 'True', + 'CopyrightOwner' => + array( + 0 => 'Bawolff is copyright owner', + '_type' => 'ul', + ), + 'UsageTerms' => + array( + 'x-default' => 'do whatever you want', + 'en-gb' => 'Do whatever you want in british english', + '_type' => 'lang', + ), + 'WebStatement' => 'http://example.com/web_statement', + ), + 'xmp-deprecated' => + array( + 'Identifier' => 'http://example.com/identifierurl/wrong', + ), +); diff --git a/tests/phpunit/data/xmp/7.xmp b/tests/phpunit/data/xmp/7.xmp new file mode 100644 index 00000000..e18e13d9 --- /dev/null +++ b/tests/phpunit/data/xmp/7.xmp @@ -0,0 +1,67 @@ + + + + + + Me! + + + + http://creativecommons.com/cc-by-2.9 + + + + + + Test image for the cc: xmp: xmpRights: namespaces in xmp + + + http://example.com/identifierurl/wrong + + + xmp core/xmp rights/cc ns test + + + + + + 2005-04-03 + The one true editor: Vi (ok i used gimp) + + + http://example.com/identifierurl + + urn:sha1:342524abcdef + + + Test image + 2011-05-12 + 2007-03-04T12:34:10-06:00 + My little xmp test image + 7 + + + + http://example.com/rights-certificate/ + True + + + Bawolff is copyright owner + + + + + do whatever you want + Do whatever you want in british english + + + http://example.com/web_statement + + + + diff --git a/tests/phpunit/data/xmp/README b/tests/phpunit/data/xmp/README new file mode 100644 index 00000000..bd949176 --- /dev/null +++ b/tests/phpunit/data/xmp/README @@ -0,0 +1,3 @@ +This directory contains a bunch of XMP files +as well as a bunch of php files containing what the +parsed version of the XMP looks like. diff --git a/tests/phpunit/data/xmp/bag-for-seq.result.php b/tests/phpunit/data/xmp/bag-for-seq.result.php new file mode 100644 index 00000000..b5244f88 --- /dev/null +++ b/tests/phpunit/data/xmp/bag-for-seq.result.php @@ -0,0 +1,10 @@ + array( + 'Artist' => array( + '_type' => 'ul', + 0 => 'The author', + ) + ) +); diff --git a/tests/phpunit/data/xmp/bag-for-seq.xmp b/tests/phpunit/data/xmp/bag-for-seq.xmp new file mode 100644 index 00000000..c6ed5b7c --- /dev/null +++ b/tests/phpunit/data/xmp/bag-for-seq.xmp @@ -0,0 +1 @@ + The author diff --git a/tests/phpunit/data/xmp/doctype-included.result.php b/tests/phpunit/data/xmp/doctype-included.result.php new file mode 100644 index 00000000..9a9cc36a --- /dev/null +++ b/tests/phpunit/data/xmp/doctype-included.result.php @@ -0,0 +1,3 @@ + ]> + + + + +True 0 1 False False + + diff --git a/tests/phpunit/data/xmp/doctype-not-included.xmp b/tests/phpunit/data/xmp/doctype-not-included.xmp new file mode 100644 index 00000000..9a40b4b0 --- /dev/null +++ b/tests/phpunit/data/xmp/doctype-not-included.xmp @@ -0,0 +1,11 @@ + + + + +True 0 1 False False + + diff --git a/tests/phpunit/data/xmp/flash.result.php b/tests/phpunit/data/xmp/flash.result.php new file mode 100644 index 00000000..018c0ac1 --- /dev/null +++ b/tests/phpunit/data/xmp/flash.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '127' + ) +); diff --git a/tests/phpunit/data/xmp/flash.xmp b/tests/phpunit/data/xmp/flash.xmp new file mode 100644 index 00000000..b1373cc2 --- /dev/null +++ b/tests/phpunit/data/xmp/flash.xmp @@ -0,0 +1,11 @@ + + + + +True 3 3 True True + + diff --git a/tests/phpunit/data/xmp/gps.result.php b/tests/phpunit/data/xmp/gps.result.php new file mode 100644 index 00000000..bf7fb219 --- /dev/null +++ b/tests/phpunit/data/xmp/gps.result.php @@ -0,0 +1,11 @@ + + array( + 'GPSAltitude' => -3.14159265301, + 'GPSDOP' => '5/1', + 'GPSLatitude' => 88.51805555, + 'GPSLongitude' => -21.12356945, + 'GPSVersionID' => '2.2.0.0' + ) +); diff --git a/tests/phpunit/data/xmp/gps.xmp b/tests/phpunit/data/xmp/gps.xmp new file mode 100644 index 00000000..e52d2c8a --- /dev/null +++ b/tests/phpunit/data/xmp/gps.xmp @@ -0,0 +1,17 @@ + + + + + + 103993/33102 + 1 + 5/1 + 88,31.083333N + 21,7.414167W + 2.2.0.0 + + + + + diff --git a/tests/phpunit/data/xmp/invalid-child-not-struct.result.php b/tests/phpunit/data/xmp/invalid-child-not-struct.result.php new file mode 100644 index 00000000..5741b2c9 --- /dev/null +++ b/tests/phpunit/data/xmp/invalid-child-not-struct.result.php @@ -0,0 +1,7 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ) +); diff --git a/tests/phpunit/data/xmp/invalid-child-not-struct.xmp b/tests/phpunit/data/xmp/invalid-child-not-struct.xmp new file mode 100644 index 00000000..6aa0c10b --- /dev/null +++ b/tests/phpunit/data/xmp/invalid-child-not-struct.xmp @@ -0,0 +1,12 @@ + + + +True 0 1 False False + + + + diff --git a/tests/phpunit/data/xmp/no-namespace.result.php b/tests/phpunit/data/xmp/no-namespace.result.php new file mode 100644 index 00000000..3ff69201 --- /dev/null +++ b/tests/phpunit/data/xmp/no-namespace.result.php @@ -0,0 +1,7 @@ + + array( + 'FNumber' => '28/10', + ) +); diff --git a/tests/phpunit/data/xmp/no-namespace.xmp b/tests/phpunit/data/xmp/no-namespace.xmp new file mode 100644 index 00000000..7d6cdb2f --- /dev/null +++ b/tests/phpunit/data/xmp/no-namespace.xmp @@ -0,0 +1,11 @@ + + + + + + diff --git a/tests/phpunit/data/xmp/no-recognized-props.result.php b/tests/phpunit/data/xmp/no-recognized-props.result.php new file mode 100644 index 00000000..b3ca9f5a --- /dev/null +++ b/tests/phpunit/data/xmp/no-recognized-props.result.php @@ -0,0 +1,2 @@ + + + + + diff --git a/tests/phpunit/data/xmp/utf16BE.result.php b/tests/phpunit/data/xmp/utf16BE.result.php new file mode 100644 index 00000000..ac7ea506 --- /dev/null +++ b/tests/phpunit/data/xmp/utf16BE.result.php @@ -0,0 +1,12 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ), + 'xmp-general' => + array( + 'Label' => '􊯍' + ), +); diff --git a/tests/phpunit/data/xmp/utf16BE.xmp b/tests/phpunit/data/xmp/utf16BE.xmp new file mode 100644 index 00000000..0cf60d60 Binary files /dev/null and b/tests/phpunit/data/xmp/utf16BE.xmp differ diff --git a/tests/phpunit/data/xmp/utf16LE.result.php b/tests/phpunit/data/xmp/utf16LE.result.php new file mode 100644 index 00000000..ac7ea506 --- /dev/null +++ b/tests/phpunit/data/xmp/utf16LE.result.php @@ -0,0 +1,12 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ), + 'xmp-general' => + array( + 'Label' => '􊯍' + ), +); diff --git a/tests/phpunit/data/xmp/utf16LE.xmp b/tests/phpunit/data/xmp/utf16LE.xmp new file mode 100644 index 00000000..66d71f4c Binary files /dev/null and b/tests/phpunit/data/xmp/utf16LE.xmp differ diff --git a/tests/phpunit/data/xmp/utf32BE.result.php b/tests/phpunit/data/xmp/utf32BE.result.php new file mode 100644 index 00000000..ac7ea506 --- /dev/null +++ b/tests/phpunit/data/xmp/utf32BE.result.php @@ -0,0 +1,12 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ), + 'xmp-general' => + array( + 'Label' => '􊯍' + ), +); diff --git a/tests/phpunit/data/xmp/utf32BE.xmp b/tests/phpunit/data/xmp/utf32BE.xmp new file mode 100644 index 00000000..06afdf92 Binary files /dev/null and b/tests/phpunit/data/xmp/utf32BE.xmp differ diff --git a/tests/phpunit/data/xmp/utf32LE.result.php b/tests/phpunit/data/xmp/utf32LE.result.php new file mode 100644 index 00000000..ac7ea506 --- /dev/null +++ b/tests/phpunit/data/xmp/utf32LE.result.php @@ -0,0 +1,12 @@ + + array( + 'DigitalZoomRatio' => '0/10', + ), + 'xmp-general' => + array( + 'Label' => '􊯍' + ), +); diff --git a/tests/phpunit/data/xmp/utf32LE.xmp b/tests/phpunit/data/xmp/utf32LE.xmp new file mode 100644 index 00000000..bf2097fe Binary files /dev/null and b/tests/phpunit/data/xmp/utf32LE.xmp differ diff --git a/tests/phpunit/data/xmp/xmpExt.result.php b/tests/phpunit/data/xmp/xmpExt.result.php new file mode 100644 index 00000000..beead1bd --- /dev/null +++ b/tests/phpunit/data/xmp/xmpExt.result.php @@ -0,0 +1,8 @@ + + array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) +); diff --git a/tests/phpunit/data/xmp/xmpExt.xmp b/tests/phpunit/data/xmp/xmpExt.xmp new file mode 100644 index 00000000..da0383f8 --- /dev/null +++ b/tests/phpunit/data/xmp/xmpExt.xmp @@ -0,0 +1,13 @@ + + + + +True 0 1 False False + + diff --git a/tests/phpunit/data/xmp/xmpExt2.xmp b/tests/phpunit/data/xmp/xmpExt2.xmp new file mode 100644 index 00000000..060abb2c --- /dev/null +++ b/tests/phpunit/data/xmp/xmpExt2.xmp @@ -0,0 +1,8 @@ + + + + + diff --git a/tests/phpunit/data/zip/cd-gap.zip b/tests/phpunit/data/zip/cd-gap.zip new file mode 100644 index 00000000..b5ae6ccd Binary files /dev/null and b/tests/phpunit/data/zip/cd-gap.zip differ diff --git a/tests/phpunit/data/zip/cd-truncated.zip b/tests/phpunit/data/zip/cd-truncated.zip new file mode 100644 index 00000000..4d40d7d4 Binary files /dev/null and b/tests/phpunit/data/zip/cd-truncated.zip differ diff --git a/tests/phpunit/data/zip/class-trailing-null.zip b/tests/phpunit/data/zip/class-trailing-null.zip new file mode 100644 index 00000000..31dcf3d8 Binary files /dev/null and b/tests/phpunit/data/zip/class-trailing-null.zip differ diff --git a/tests/phpunit/data/zip/class-trailing-slash.zip b/tests/phpunit/data/zip/class-trailing-slash.zip new file mode 100644 index 00000000..9eb1f037 Binary files /dev/null and b/tests/phpunit/data/zip/class-trailing-slash.zip differ diff --git a/tests/phpunit/data/zip/class.zip b/tests/phpunit/data/zip/class.zip new file mode 100644 index 00000000..98a625b7 Binary files /dev/null and b/tests/phpunit/data/zip/class.zip differ diff --git a/tests/phpunit/data/zip/empty.zip b/tests/phpunit/data/zip/empty.zip new file mode 100644 index 00000000..15cb0ecb Binary files /dev/null and b/tests/phpunit/data/zip/empty.zip differ diff --git a/tests/phpunit/data/zip/looks-like-zip64.zip b/tests/phpunit/data/zip/looks-like-zip64.zip new file mode 100644 index 00000000..7428cddd Binary files /dev/null and b/tests/phpunit/data/zip/looks-like-zip64.zip differ diff --git a/tests/phpunit/data/zip/nosig.zip b/tests/phpunit/data/zip/nosig.zip new file mode 100644 index 00000000..a22c73a4 Binary files /dev/null and b/tests/phpunit/data/zip/nosig.zip differ diff --git a/tests/phpunit/data/zip/split.zip b/tests/phpunit/data/zip/split.zip new file mode 100644 index 00000000..6984ae6d Binary files /dev/null and b/tests/phpunit/data/zip/split.zip differ diff --git a/tests/phpunit/data/zip/trail.zip b/tests/phpunit/data/zip/trail.zip new file mode 100644 index 00000000..50bcea12 Binary files /dev/null and b/tests/phpunit/data/zip/trail.zip differ diff --git a/tests/phpunit/data/zip/wrong-cd-start-disk.zip b/tests/phpunit/data/zip/wrong-cd-start-disk.zip new file mode 100644 index 00000000..59b45938 Binary files /dev/null and b/tests/phpunit/data/zip/wrong-cd-start-disk.zip differ diff --git a/tests/phpunit/data/zip/wrong-central-entry-sig.zip b/tests/phpunit/data/zip/wrong-central-entry-sig.zip new file mode 100644 index 00000000..05329b43 Binary files /dev/null and b/tests/phpunit/data/zip/wrong-central-entry-sig.zip differ diff --git a/tests/phpunit/docs/ExportDemoTest.php b/tests/phpunit/docs/ExportDemoTest.php new file mode 100644 index 00000000..8288cae0 --- /dev/null +++ b/tests/phpunit/docs/ExportDemoTest.php @@ -0,0 +1,31 @@ +load( $fname ); + + // Ensure, the demo is for the current version + $this->assertEquals( + $dom->documentElement->getAttribute( 'version' ), + $version, + 'export-demo.xml should have the current version' + ); + + $this->assertTrue( + $dom->schemaValidate( "../../docs/export-" . $version . ".xsd" ), + "schemaValidate has found an error" + ); + } + +} diff --git a/tests/phpunit/includes/ArrayUtilsTest.php b/tests/phpunit/includes/ArrayUtilsTest.php new file mode 100644 index 00000000..7bdb1ca4 --- /dev/null +++ b/tests/phpunit/includes/ArrayUtilsTest.php @@ -0,0 +1,311 @@ +assertSame( + ArrayUtils::findLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target + ), $expected + ); + } + + function provideFindLowerBound() { + $self = $this; + $indexValueCallback = function ( $size ) use ( $self ) { + return function ( $val ) use ( $self, $size ) { + $self->assertTrue( $val >= 0 ); + $self->assertTrue( $val < $size ); + return $val; + }; + }; + $comparisonCallback = function ( $a, $b ) { + return $a - $b; + }; + + return array( + array( + $indexValueCallback( 0 ), + 0, + $comparisonCallback, + 1, + false, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + -1, + false, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 0, + 0, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 1, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + -1, + false, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0.5, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1, + 1, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1.5, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1.5, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 2, + 2, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 3, + 2, + ), + ); + } + + /** + * @covers ArrayUtils::arrayDiffAssocRecursive + * @dataProvider provideArrayDiffAssocRecursive + */ + function testArrayDiffAssocRecursive( $expected ) { + $args = func_get_args(); + array_shift( $args ); + $this->assertEquals( call_user_func_array( + 'ArrayUtils::arrayDiffAssocRecursive', $args + ), $expected ); + } + + function provideArrayDiffAssocRecursive() { + return array( + array( + array(), + array(), + array(), + ), + array( + array(), + array(), + array(), + array(), + ), + array( + array( 1 ), + array( 1 ), + array(), + ), + array( + array( 1 ), + array( 1 ), + array(), + array(), + ), + array( + array(), + array(), + array( 1 ), + ), + array( + array(), + array(), + array( 1 ), + array( 2 ), + ), + array( + array( '' => 1 ), + array( '' => 1 ), + array(), + ), + array( + array(), + array(), + array( '' => 1 ), + ), + array( + array( 1 ), + array( 1 ), + array( 2 ), + ), + array( + array(), + array( 1 ), + array( 2 ), + array( 1 ), + ), + array( + array(), + array( 1 ), + array( 1, 2 ), + ), + array( + array( 1 => 1 ), + array( 1 => 1 ), + array( 1 ), + ), + array( + array(), + array( 1 => 1 ), + array( 1 ), + array( 1 => 1), + ), + array( + array(), + array( 1 => 1 ), + array( 1, 1, 1 ), + ), + array( + array(), + array( array() ), + array(), + ), + array( + array(), + array( array( array() ) ), + array(), + ), + array( + array( 1, array( 1 ) ), + array( 1, array( 1 ) ), + array(), + ), + array( + array( 1 ), + array( 1, array( 1 ) ), + array( 2, array( 1 ) ), + ), + array( + array(), + array( 1, array( 1 ) ), + array( 2, array( 1 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 ), + array( 1, array() ), + array( 2 ), + ), + array( + array(), + array( 1, array() ), + array( 2 ), + array( 1 ), + ), + array( + array( 1, array( 1 => 2 ) ), + array( 1, array( 1, 2 ) ), + array( 2, array( 1 ) ), + ), + array( + array( 1 ), + array( 1, array( 1, 2 ) ), + array( 2, array( 1 ) ), + array( 2, array( 1 => 2 ) ), + ), + array( + array( 1 => array( 1, 2 ) ), + array( 1, array( 1, 2 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 => array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 => array( array( 2 ), 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3 ) ) ), + ), + array( + array( 1 => array( 1 => 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3, 0 => 2 ) ) ), + ), + array( + array( 1 => array( 1 => 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3 ) ) ), + array( 1 => array( array( 2 ) ) ), + ), + array( + array(), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1 => array( 1 => 2, 0 => array( 1 => 3, 0 => 2 ) ), 0 => 1 ), + ), + array( + array(), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1 => array( 1 => 2 ) ), + array( 1 => array( array( 1 => 3 ) ) ), + array( 1 => array( array( 2 ) ) ), + array( 1 ), + ), + ); + } +} diff --git a/tests/phpunit/includes/ArticleTablesTest.php b/tests/phpunit/includes/ArticleTablesTest.php new file mode 100644 index 00000000..9f2b7a05 --- /dev/null +++ b/tests/phpunit/includes/ArticleTablesTest.php @@ -0,0 +1,53 @@ +mRights = array( 'createpage', 'edit', 'purge' ); + $this->setMwGlobals( 'wgLanguageCode', 'es' ); + $this->setMwGlobals( 'wgContLang', Language::factory( 'es' ) ); + $this->setMwGlobals( 'wgLang', Language::factory( 'fr' ) ); + + $page->doEditContent( + new WikitextContent( '{{:{{int:history}}}}' ), + 'Test code for bug 14404', + 0, + false, + $user + ); + $templates1 = $title->getTemplateLinksFrom(); + + $this->setMwGlobals( 'wgLang', Language::factory( 'de' ) ); + $page = WikiPage::factory( $title ); // In order to force the re-rendering of the same wikitext + + // We need an edit, a purge is not enough to regenerate the tables + $page->doEditContent( + new WikitextContent( '{{:{{int:history}}}}' ), + 'Test code for bug 14404', + EDIT_UPDATE, + false, + $user + ); + $templates2 = $title->getTemplateLinksFrom(); + + /** + * @var Title[] $templates1 + * @var Title[] $templates2 + */ + $this->assertEquals( $templates1, $templates2 ); + $this->assertEquals( $templates1[0]->getFullText(), 'Historial' ); + } +} diff --git a/tests/phpunit/includes/ArticleTest.php b/tests/phpunit/includes/ArticleTest.php new file mode 100644 index 00000000..ae069eaf --- /dev/null +++ b/tests/phpunit/includes/ArticleTest.php @@ -0,0 +1,95 @@ +title = Title::makeTitle( NS_MAIN, 'SomePage' ); + $this->article = new Article( $this->title ); + } + + /** cleanup title object and its article object */ + protected function tearDown() { + parent::tearDown(); + $this->title = null; + $this->article = null; + } + + /** + * @covers Article::__get + */ + public function testImplementsGetMagic() { + $this->assertEquals( false, $this->article->mLatest, "Article __get magic" ); + } + + /** + * @depends testImplementsGetMagic + * @covers Article::__set + */ + public function testImplementsSetMagic() { + $this->article->mLatest = 2; + $this->assertEquals( 2, $this->article->mLatest, "Article __set magic" ); + } + + /** + * @depends testImplementsSetMagic + * @covers Article::__call + */ + public function testImplementsCallMagic() { + $this->article->mLatest = 33; + $this->article->mDataLoaded = true; + $this->assertEquals( 33, $this->article->getLatest(), "Article __call magic" ); + } + + /** + * @covers Article::__get + * @covers Article::__set + */ + public function testGetOrSetOnNewProperty() { + $this->article->ext_someNewProperty = 12; + $this->assertEquals( 12, $this->article->ext_someNewProperty, + "Article get/set magic on new field" ); + + $this->article->ext_someNewProperty = -8; + $this->assertEquals( -8, $this->article->ext_someNewProperty, + "Article get/set magic on update to new field" ); + } + + /** + * Checks for the existence of the backwards compatibility static functions + * (forwarders to WikiPage class) + * + * @covers Article::selectFields + * @covers Article::onArticleCreate + * @covers Article::onArticleDelete + * @covers Article::onArticleEdit + * @covers Article::getAutosummary + */ + public function testStaticFunctions() { + $this->hideDeprecated( 'Article::selectFields' ); + $this->hideDeprecated( 'Article::getAutosummary' ); + $this->hideDeprecated( 'WikiPage::getAutosummary' ); + $this->hideDeprecated( 'CategoryPage::getAutosummary' ); // Inherited from Article + + $this->assertEquals( WikiPage::selectFields(), Article::selectFields(), + "Article static functions" ); + $this->assertEquals( true, is_callable( "Article::onArticleCreate" ), + "Article static functions" ); + $this->assertEquals( true, is_callable( "Article::onArticleDelete" ), + "Article static functions" ); + $this->assertEquals( true, is_callable( "ImagePage::onArticleEdit" ), + "Article static functions" ); + $this->assertTrue( is_string( CategoryPage::getAutosummary( '', '', 0 ) ), + "Article static functions" ); + } +} diff --git a/tests/phpunit/includes/BlockTest.php b/tests/phpunit/includes/BlockTest.php new file mode 100644 index 00000000..b248d24e --- /dev/null +++ b/tests/phpunit/includes/BlockTest.php @@ -0,0 +1,368 @@ +setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ) + ) ); + } + + function addDBData() { + + $user = User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTBlockeePassword' ); + + $user->saveSettings(); + } + + // Delete the last round's block if it's still there + $oldBlock = Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + $this->block = new Block( 'UTBlockee', $user->getID(), 0, + 'Parce que', 0, false, time() + 100500 + ); + $this->madeAt = wfTimestamp( TS_MW ); + + $this->block->insert(); + // save up ID for use in assertion. Since ID is an autoincrement, + // its value might change depending on the order the tests are run. + // ApiBlockTest insert its own blocks! + $newBlockId = $this->block->getId(); + if ( $newBlockId ) { + $this->blockId = $newBlockId; + } else { + throw new MWException( "Failed to insert block for BlockTest; old leftover block remaining?" ); + } + + $this->addXffBlocks(); + } + + /** + * debug function : dump the ipblocks table + */ + function dumpBlocks() { + $v = $this->db->select( 'ipblocks', '*' ); + print "Got " . $v->numRows() . " rows. Full dump follow:\n"; + foreach ( $v as $row ) { + print_r( $row ); + } + } + + /** + * @covers Block::newFromTarget + */ + public function testINewFromTargetReturnsCorrectBlock() { + $this->assertTrue( + $this->block->equals( Block::newFromTarget( 'UTBlockee' ) ), + "newFromTarget() returns the same block as the one that was made" + ); + } + + /** + * @covers Block::newFromID + */ + public function testINewFromIDReturnsCorrectBlock() { + $this->assertTrue( + $this->block->equals( Block::newFromID( $this->blockId ) ), + "newFromID() returns the same block as the one that was made" + ); + } + + /** + * per bug 26425 + */ + public function testBug26425BlockTimestampDefaultsToTime() { + // delta to stop one-off errors when things happen to go over a second mark. + $delta = abs( $this->madeAt - $this->block->mTimestamp ); + $this->assertLessThan( + 2, + $delta, + "If no timestamp is specified, the block is recorded as time()" + ); + } + + /** + * CheckUser since being changed to use Block::newFromTarget started failing + * because the new function didn't accept empty strings like Block::load() + * had. Regression bug 29116. + * + * @dataProvider provideBug29116Data + * @covers Block::newFromTarget + */ + public function testBug29116NewFromTargetWithEmptyIp( $vagueTarget ) { + $block = Block::newFromTarget( 'UTBlockee', $vagueTarget ); + $this->assertTrue( + $this->block->equals( $block ), + "newFromTarget() returns the same block as the one that was made when " + . "given empty vagueTarget param " . var_export( $vagueTarget, true ) + ); + } + + public static function provideBug29116Data() { + return array( + array( null ), + array( '' ), + array( false ) + ); + } + + /** + * @covers Block::prevents + */ + public function testBlockedUserCanNotCreateAccount() { + $username = 'BlockedUserToCreateAccountWith'; + $u = User::newFromName( $username ); + $u->setPassword( 'NotRandomPass' ); + $u->addToDatabase(); + unset( $u ); + + // Sanity check + $this->assertNull( + Block::newFromTarget( $username ), + "$username should not be blocked" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertFalse( + $u->isBlockedFromCreateAccount(), + "Our sandbox user should be able to create account before being blocked" + ); + + // Foreign perspective (blockee not on current wiki)... + $block = new Block( + /* $address */ $username, + /* $user */ 14146, + /* $by */ 0, + /* $reason */ 'crosswiki block...', + /* $timestamp */ wfTimestampNow(), + /* $auto */ false, + /* $expiry */ $this->db->getInfinity(), + /* anonOnly */ false, + /* $createAccount */ true, + /* $enableAutoblock */ true, + /* $hideName (ipb_deleted) */ true, + /* $blockEmail */ true, + /* $allowUsertalk */ false, + /* $byName */ 'MetaWikiUser' + ); + $block->insert(); + + // Reload block from DB + $userBlock = Block::newFromTarget( $username ); + $this->assertTrue( + (bool)$block->prevents( 'createaccount' ), + "Block object in DB should prevents 'createaccount'" + ); + + $this->assertInstanceOf( + 'Block', + $userBlock, + "'$username' block block object should be existent" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertTrue( + (bool)$u->isBlockedFromCreateAccount(), + "Our sandbox user '$username' should NOT be able to create account" + ); + } + + /** + * @covers Block::insert + */ + public function testCrappyCrossWikiBlocks() { + // Delete the last round's block if it's still there + $oldBlock = Block::newFromTarget( 'UserOnForeignWiki' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + // Foreign perspective (blockee not on current wiki)... + $block = new Block( + /* $address */ 'UserOnForeignWiki', + /* $user */ 14146, + /* $by */ 0, + /* $reason */ 'crosswiki block...', + /* $timestamp */ wfTimestampNow(), + /* $auto */ false, + /* $expiry */ $this->db->getInfinity(), + /* anonOnly */ false, + /* $createAccount */ true, + /* $enableAutoblock */ true, + /* $hideName (ipb_deleted) */ true, + /* $blockEmail */ true, + /* $allowUsertalk */ false, + /* $byName */ 'MetaWikiUser' + ); + + $res = $block->insert( $this->db ); + $this->assertTrue( (bool)$res['id'], 'Block succeeded' ); + + // Local perspective (blockee on current wiki)... + $user = User::newFromName( 'UserOnForeignWiki' ); + $user->addToDatabase(); + // Set user ID to match the test value + $this->db->update( 'user', array( 'user_id' => 14146 ), array( 'user_id' => $user->getId() ) ); + $user = null; // clear + + $block = Block::newFromID( $res['id'] ); + $this->assertEquals( + 'UserOnForeignWiki', + $block->getTarget()->getName(), + 'Correct blockee name' + ); + $this->assertEquals( '14146', $block->getTarget()->getId(), 'Correct blockee id' ); + $this->assertEquals( 'MetaWikiUser', $block->getBlocker(), 'Correct blocker name' ); + $this->assertEquals( 'MetaWikiUser', $block->getByName(), 'Correct blocker name' ); + $this->assertEquals( 0, $block->getBy(), 'Correct blocker id' ); + } + + protected function addXffBlocks() { + static $inited = false; + + if ( $inited ) { + return; + } + + $inited = true; + + $blockList = array( + array( 'target' => '70.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ), + array( 'target' => '2001:4860:4001::/48', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range6 Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ), + array( 'target' => '60.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Softblock with AC Disabled', + 'ACDisable' => true, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + array( 'target' => '50.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + array( 'target' => '50.1.1.1', + 'type' => Block::TYPE_IP, + 'desc' => 'Exact Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + ); + + foreach ( $blockList as $insBlock ) { + $target = $insBlock['target']; + + if ( $insBlock['type'] === Block::TYPE_IP ) { + $target = User::newFromName( IP::sanitizeIP( $target ), false )->getName(); + } elseif ( $insBlock['type'] === Block::TYPE_RANGE ) { + $target = IP::sanitizeRange( $target ); + } + + $block = new Block(); + $block->setTarget( $target ); + $block->setBlocker( 'testblocker@global' ); + $block->mReason = $insBlock['desc']; + $block->mExpiry = 'infinity'; + $block->prevents( 'createaccount', $insBlock['ACDisable'] ); + $block->isHardblock( $insBlock['isHardblock'] ); + $block->isAutoblocking( $insBlock['isAutoBlocking'] ); + $block->insert(); + } + } + + public static function providerXff() { + return array( + array( 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '1.2.3.4, 50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 3, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 50.1.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, <$A_BUNCH-OF{INVALID}TEXT\>, 60.2.1.1, 2.3.4.5', + 'count' => 1, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 50.2.1.1, 2001:4860:4001:802::1003, 2.3.4.5', + 'count' => 2, + 'result' => 'Range6 Hardblock' + ), + ); + } + + /** + * @dataProvider providerXff + * @covers Block::getBlocksForIPList + * @covers Block::chooseBlock + */ + public function testBlocksOnXff( $xff, $exCount, $exResult ) { + $list = array_map( 'trim', explode( ',', $xff ) ); + $xffblocks = Block::getBlocksForIPList( $list, true ); + $this->assertEquals( $exCount, count( $xffblocks ), 'Number of blocks for ' . $xff ); + $block = Block::chooseBlock( $xffblocks, $list ); + $this->assertEquals( $exResult, $block->mReason, 'Correct block type for XFF header ' . $xff ); + } +} diff --git a/tests/phpunit/includes/CollationTest.php b/tests/phpunit/includes/CollationTest.php new file mode 100644 index 00000000..74b12967 --- /dev/null +++ b/tests/phpunit/includes/CollationTest.php @@ -0,0 +1,117 @@ +checkPHPExtension( 'intl' ); + } + + /** + * Test to make sure, that if you + * have "X" and "XY", the binary + * sortkey also has "X" being a + * prefix of "XY". Our collation + * code makes this assumption. + * + * @param string $lang Language code for collator + * @param string $base Base string + * @param string $extended String containing base as a prefix. + * + * @dataProvider prefixDataProvider + */ + public function testIsPrefix( $lang, $base, $extended ) { + $cp = Collator::create( $lang ); + $cp->setStrength( Collator::PRIMARY ); + $baseBin = $cp->getSortKey( $base ); + // Remove sortkey terminator + $baseBin = rtrim( $baseBin, "\0" ); + $extendedBin = $cp->getSortKey( $extended ); + $this->assertStringStartsWith( $baseBin, $extendedBin, "$base is not a prefix of $extended" ); + } + + public static function prefixDataProvider() { + return array( + array( 'en', 'A', 'AA' ), + array( 'en', 'A', 'AAA' ), + array( 'en', 'Д', 'ДЂ' ), + array( 'en', 'Д', 'ДA' ), + // 'Ʒ' should expand to 'Z ' (note space). + array( 'fi', 'Z', 'Ʒ' ), + // 'Þ' should expand to 'th' + array( 'sv', 't', 'Þ' ), + // Javanese is a limited use alphabet, so should have 3 bytes + // per character, so do some tests with it. + array( 'en', 'ꦲ', 'ꦲꦤ' ), + array( 'en', 'ꦲ', 'ꦲД' ), + array( 'en', 'A', 'Aꦲ' ), + ); + } + + /** + * Opposite of testIsPrefix + * + * @dataProvider notPrefixDataProvider + */ + public function testNotIsPrefix( $lang, $base, $extended ) { + $cp = Collator::create( $lang ); + $cp->setStrength( Collator::PRIMARY ); + $baseBin = $cp->getSortKey( $base ); + // Remove sortkey terminator + $baseBin = rtrim( $baseBin, "\0" ); + $extendedBin = $cp->getSortKey( $extended ); + $this->assertStringStartsNotWith( $baseBin, $extendedBin, "$base is a prefix of $extended" ); + } + + public static function notPrefixDataProvider() { + return array( + array( 'en', 'A', 'B' ), + array( 'en', 'AC', 'ABC' ), + array( 'en', 'Z', 'Ʒ' ), + array( 'en', 'A', 'ꦲ' ), + ); + } + + /** + * Test correct first letter is fetched. + * + * @param string $collation Collation name (aka uca-en) + * @param string $string String to get first letter of + * @param string $firstLetter Expected first letter. + * + * @dataProvider firstLetterProvider + */ + public function testGetFirstLetter( $collation, $string, $firstLetter ) { + $col = Collation::factory( $collation ); + $this->assertEquals( $firstLetter, $col->getFirstLetter( $string ) ); + } + + function firstLetterProvider() { + return array( + array( 'uppercase', 'Abc', 'A' ), + array( 'uppercase', 'abc', 'A' ), + array( 'identity', 'abc', 'a' ), + array( 'uca-en', 'abc', 'A' ), + array( 'uca-en', ' ', ' ' ), + array( 'uca-en', 'Êveryone', 'E' ), + array( 'uca-vi', 'Êveryone', 'Ê' ), + // Make sure thorn is not a first letter. + array( 'uca-sv', 'The', 'T' ), + array( 'uca-sv', 'Å', 'Å' ), + array( 'uca-hu', 'dzsdo', 'Dzs' ), + array( 'uca-hu', 'dzdso', 'Dz' ), + array( 'uca-hu', 'CSD', 'Cs' ), + array( 'uca-root', 'CSD', 'C' ), + array( 'uca-fi', 'Ǥ', 'G' ), + array( 'uca-fi', 'Ŧ', 'T' ), + array( 'uca-fi', 'Ʒ', 'Z' ), + array( 'uca-fi', 'Ŋ', 'N' ), + ); + } +} diff --git a/tests/phpunit/includes/DiffHistoryBlobTest.php b/tests/phpunit/includes/DiffHistoryBlobTest.php new file mode 100644 index 00000000..e28a92cf --- /dev/null +++ b/tests/phpunit/includes/DiffHistoryBlobTest.php @@ -0,0 +1,40 @@ +checkPHPExtension( 'hash' ); + $this->checkPHPExtension( 'xdiff' ); + + if ( !function_exists( 'xdiff_string_rabdiff' ) ) { + $this->markTestSkipped( 'The version of xdiff extension is lower than 1.5.0' ); + + return; + } + } + + /** + * Test for DiffHistoryBlob::xdiffAdler32() + * @dataProvider provideXdiffAdler32 + * @covers DiffHistoryBlob::xdiffAdler32 + */ + public function testXdiffAdler32( $input ) { + $xdiffHash = substr( xdiff_string_rabdiff( $input, '' ), 0, 4 ); + $dhb = new DiffHistoryBlob; + $myHash = $dhb->xdiffAdler32( $input ); + $this->assertSame( bin2hex( $xdiffHash ), bin2hex( $myHash ), + "Hash of " . addcslashes( $input, "\0..\37!@\@\177..\377" ) ); + } + + public static function provideXdiffAdler32() { + return array( + array( '', 'Empty string' ), + array( "\0", 'Null' ), + array( "\0\0\0", "Several nulls" ), + array( "Hello", "An ASCII string" ), + array( str_repeat( "x", 6000 ), "A string larger than xdiff's NMAX (5552)" ) + ); + } +} diff --git a/tests/phpunit/includes/EditPageTest.php b/tests/phpunit/includes/EditPageTest.php new file mode 100644 index 00000000..702fce4c --- /dev/null +++ b/tests/phpunit/includes/EditPageTest.php @@ -0,0 +1,499 @@ +assertEquals( $title, $extracted ); + } + + public static function provideExtractSectionTitle() { + return array( + array( + "== Test ==\n\nJust a test section.", + "Test" + ), + array( + "An initial section, no header.", + false + ), + array( + "An initial section with a fake heder (bug 32617)\n\n== Test == ??\nwtf", + false + ), + array( + "== Section ==\nfollowed by a fake == Non-section == ??\nnoooo", + "Section" + ), + array( + "== Section== \t\r\n followed by whitespace (bug 35051)", + 'Section', + ), + ); + } + + protected function forceRevisionDate( WikiPage $page, $timestamp ) { + $dbw = wfGetDB( DB_MASTER ); + + $dbw->update( 'revision', + array( 'rev_timestamp' => $dbw->timestamp( $timestamp ) ), + array( 'rev_id' => $page->getLatest() ) ); + + $page->clear(); + } + + /** + * User input text is passed to rtrim() by edit page. This is a simple + * wrapper around assertEquals() which calls rrtrim() to normalize the + * expected and actual texts. + * @param string $expected + * @param string $actual + * @param string $msg + */ + protected function assertEditedTextEquals( $expected, $actual, $msg = '' ) { + return $this->assertEquals( rtrim( $expected ), rtrim( $actual ), $msg ); + } + + /** + * Performs an edit and checks the result. + * + * @param string|Title $title The title of the page to edit + * @param string|null $baseText Some text to create the page with before attempting the edit. + * @param User|string|null $user The user to perform the edit as. + * @param array $edit An array of request parameters used to define the edit to perform. + * Some well known fields are: + * * wpTextbox1: the text to submit + * * wpSummary: the edit summary + * * wpEditToken: the edit token (will be inserted if not provided) + * * wpEdittime: timestamp of the edit's base revision (will be inserted + * if not provided) + * * wpStarttime: timestamp when the edit started (will be inserted if not provided) + * * wpSectionTitle: the section to edit + * * wpMinorEdit: mark as minor edit + * * wpWatchthis: whether to watch the page + * @param int|null $expectedCode The expected result code (EditPage::AS_XXX constants). + * Set to null to skip the check. + * @param string|null $expectedText The text expected to be on the page after the edit. + * Set to null to skip the check. + * @param string|null $message An optional message to show along with any error message. + * + * @return WikiPage The page that was just edited, useful for getting the edit's rev_id, etc. + */ + protected function assertEdit( $title, $baseText, $user = null, array $edit, + $expectedCode = null, $expectedText = null, $message = null + ) { + if ( is_string( $title ) ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + } + $this->assertNotNull( $title ); + + if ( is_string( $user ) ) { + $user = User::newFromName( $user ); + + if ( $user->getId() === 0 ) { + $user->addToDatabase(); + } + } + + $page = WikiPage::factory( $title ); + + if ( $baseText !== null ) { + $content = ContentHandler::makeContent( $baseText, $title ); + $page->doEditContent( $content, "base text for test" ); + $this->forceRevisionDate( $page, '20120101000000' ); + + //sanity check + $page->clear(); + $currentText = ContentHandler::getContentText( $page->getContent() ); + + # EditPage rtrim() the user input, so we alter our expected text + # to reflect that. + $this->assertEditedTextEquals( $baseText, $currentText ); + } + + if ( $user == null ) { + $user = $GLOBALS['wgUser']; + } else { + $this->setMwGlobals( 'wgUser', $user ); + } + + if ( !isset( $edit['wpEditToken'] ) ) { + $edit['wpEditToken'] = $user->getEditToken(); + } + + if ( !isset( $edit['wpEdittime'] ) ) { + $edit['wpEdittime'] = $page->exists() ? $page->getTimestamp() : ''; + } + + if ( !isset( $edit['wpStarttime'] ) ) { + $edit['wpStarttime'] = wfTimestampNow(); + } + + $req = new FauxRequest( $edit, true ); // session ?? + + $article = new Article( $title ); + $article->getContext()->setTitle( $title ); + $ep = new EditPage( $article ); + $ep->setContextTitle( $title ); + $ep->importFormData( $req ); + + $bot = isset( $edit['bot'] ) ? (bool)$edit['bot'] : false; + + // this is where the edit happens! + // Note: don't want to use EditPage::AttemptSave, because it messes with $wgOut + // and throws exceptions like PermissionsError + $status = $ep->internalAttemptSave( $result, $bot ); + + if ( $expectedCode !== null ) { + // check edit code + $this->assertEquals( $expectedCode, $status->value, + "Expected result code mismatch. $message" ); + } + + $page = WikiPage::factory( $title ); + + if ( $expectedText !== null ) { + // check resulting page text + $content = $page->getContent(); + $text = ContentHandler::getContentText( $content ); + + # EditPage rtrim() the user input, so we alter our expected text + # to reflect that. + $this->assertEditedTextEquals( $expectedText, $text, + "Expected article text mismatch. $message" ); + } + + return $page; + } + + public static function provideCreatePages() { + return array( + array( 'expected article being created', + 'EditPageTest_testCreatePage', + null, + 'Hello World!', + EditPage::AS_SUCCESS_NEW_ARTICLE, + 'Hello World!' + ), + array( 'expected article not being created if empty', + 'EditPageTest_testCreatePage', + null, + '', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected MediaWiki: page being created', + 'MediaWiki:January', + 'UTSysop', + 'Not January', + EditPage::AS_SUCCESS_NEW_ARTICLE, + 'Not January' + ), + array( 'expected not-registered MediaWiki: page not being created if empty', + 'MediaWiki:EditPageTest_testCreatePage', + 'UTSysop', + '', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected registered MediaWiki: page being created even if empty', + 'MediaWiki:January', + 'UTSysop', + '', + EditPage::AS_SUCCESS_NEW_ARTICLE, + '' + ), + array( 'expected registered MediaWiki: page whose default content is empty not being created if empty', + 'MediaWiki:Ipb-default-expiry', + 'UTSysop', + '', + EditPage::AS_BLANK_ARTICLE, + '' + ), + array( 'expected MediaWiki: page not being created if text equals default message', + 'MediaWiki:January', + 'UTSysop', + 'January', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected empty article being created', + 'EditPageTest_testCreatePage', + null, + '', + EditPage::AS_SUCCESS_NEW_ARTICLE, + '', + true + ), + ); + } + + /** + * @dataProvider provideCreatePages + * @covers EditPage + */ + public function testCreatePage( $desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false ) { + $edit = array( 'wpTextbox1' => $editText ); + if ( $ignoreBlank ) { + $edit['wpIgnoreBlankArticle'] = 1; + } + + $page = $this->assertEdit( $pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc ); + + if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) { + $page->doDeleteArticleReal( $pageTitle ); + } + } + + public function testUpdatePage() { + $text = "one"; + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => 'first update', + ); + + $page = $this->assertEdit( 'EditPageTest_testUpdatePage', "zero", null, $edit, + EditPage::AS_SUCCESS_UPDATE, $text, + "expected successfull update with given text" ); + + $this->forceRevisionDate( $page, '20120101000000' ); + + $text = "two"; + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => 'second update', + ); + + $this->assertEdit( 'EditPageTest_testUpdatePage', null, null, $edit, + EditPage::AS_SUCCESS_UPDATE, $text, + "expected successfull update with given text" ); + } + + public static function provideSectionEdit() { + $text = 'Intro + +== one == +first section. + +== two == +second section. +'; + + $sectionOne = '== one == +hello +'; + + $newSection = '== new section == + +hello +'; + + $textWithNewSectionOne = preg_replace( + '/== one ==.*== two ==/ms', + "$sectionOne\n== two ==", $text + ); + + $textWithNewSectionAdded = "$text\n$newSection"; + + return array( + array( #0 + $text, + '', + 'hello', + 'replace all', + 'hello' + ), + + array( #1 + $text, + '1', + $sectionOne, + 'replace first section', + $textWithNewSectionOne, + ), + + array( #2 + $text, + 'new', + 'hello', + 'new section', + $textWithNewSectionAdded, + ), + ); + } + + /** + * @dataProvider provideSectionEdit + * @covers EditPage + */ + public function testSectionEdit( $base, $section, $text, $summary, $expected ) { + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => $summary, + 'wpSection' => $section, + ); + + $this->assertEdit( 'EditPageTest_testSectionEdit', $base, null, $edit, + EditPage::AS_SUCCESS_UPDATE, $expected, + "expected successfull update of section" ); + } + + public static function provideAutoMerge() { + $tests = array(); + + $tests[] = array( #0: plain conflict + "Elmo", # base edit user + "one\n\ntwo\n\nthree\n", + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n", + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => "(one)\n\ntwo\n\nthree\n", + ), + EditPage::AS_CONFLICT_DETECTED, # expected code + "ONE\n\ntwo\n\nthree\n", # expected text + 'expected edit conflict', # message + ); + + $tests[] = array( #1: successful merge + "Elmo", # base edit user + "one\n\ntwo\n\nthree\n", + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n", + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => "one\n\ntwo\n\nTHREE\n", + ), + EditPage::AS_SUCCESS_UPDATE, # expected code + "ONE\n\ntwo\n\nTHREE\n", # expected text + 'expected automatic merge', # message + ); + + $text = "Intro\n\n"; + $text .= "== first section ==\n\n"; + $text .= "one\n\ntwo\n\nthree\n\n"; + $text .= "== second section ==\n\n"; + $text .= "four\n\nfive\n\nsix\n\n"; + + // extract the first section. + $section = preg_replace( '/.*(== first section ==.*)== second section ==.*/sm', '$1', $text ); + + // generate expected text after merge + $expected = str_replace( 'one', 'ONE', str_replace( 'three', 'THREE', $text ) ); + + $tests[] = array( #2: merge in section + "Elmo", # base edit user + $text, + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => str_replace( 'one', 'ONE', $section ), + 'wpSection' => '1' + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => str_replace( 'three', 'THREE', $section ), + 'wpSection' => '1' + ), + EditPage::AS_SUCCESS_UPDATE, # expected code + $expected, # expected text + 'expected automatic section merge', # message + ); + + // see whether it makes a difference who did the base edit + $testsWithAdam = array_map( function ( $test ) { + $test[0] = 'Adam'; // change base edit user + return $test; + }, $tests ); + + $testsWithBerta = array_map( function ( $test ) { + $test[0] = 'Berta'; // change base edit user + return $test; + }, $tests ); + + return array_merge( $tests, $testsWithAdam, $testsWithBerta ); + } + + /** + * @dataProvider provideAutoMerge + * @covers EditPage + */ + public function testAutoMerge( $baseUser, $text, $adamsEdit, $bertasEdit, + $expectedCode, $expectedText, $message = null + ) { + $this->checkHasDiff3(); + + //create page + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( 'EditPageTest_testAutoMerge', $ns ); + $page = WikiPage::factory( $title ); + + if ( $page->exists() ) { + $page->doDeleteArticle( "clean slate for testing" ); + } + + $baseEdit = array( + 'wpTextbox1' => $text, + ); + + $page = $this->assertEdit( 'EditPageTest_testAutoMerge', null, + $baseUser, $baseEdit, null, null, __METHOD__ ); + + $this->forceRevisionDate( $page, '20120101000000' ); + + $edittime = $page->getTimestamp(); + + // start timestamps for conflict detection + if ( !isset( $adamsEdit['wpStarttime'] ) ) { + $adamsEdit['wpStarttime'] = 1; + } + + if ( !isset( $bertasEdit['wpStarttime'] ) ) { + $bertasEdit['wpStarttime'] = 2; + } + + $starttime = wfTimestampNow(); + $adamsTime = wfTimestamp( + TS_MW, + (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$adamsEdit['wpStarttime'] + ); + $bertasTime = wfTimestamp( + TS_MW, + (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$bertasEdit['wpStarttime'] + ); + + $adamsEdit['wpStarttime'] = $adamsTime; + $bertasEdit['wpStarttime'] = $bertasTime; + + $adamsEdit['wpSummary'] = 'Adam\'s edit'; + $bertasEdit['wpSummary'] = 'Bertas\'s edit'; + + $adamsEdit['wpEdittime'] = $edittime; + $bertasEdit['wpEdittime'] = $edittime; + + // first edit + $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Adam', $adamsEdit, + EditPage::AS_SUCCESS_UPDATE, null, "expected successfull update" ); + + // second edit + $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Berta', $bertasEdit, + $expectedCode, $expectedText, $message ); + } +} diff --git a/tests/phpunit/includes/ExternalStoreTest.php b/tests/phpunit/includes/ExternalStoreTest.php new file mode 100644 index 00000000..07c2957c --- /dev/null +++ b/tests/phpunit/includes/ExternalStoreTest.php @@ -0,0 +1,87 @@ +setMwGlobals( 'wgExternalStores', false ); + + $this->assertFalse( + ExternalStore::fetchFromURL( 'FOO://cluster1/200' ), + 'Deny if wgExternalStores is not set to a non-empty array' + ); + + $this->setMwGlobals( 'wgExternalStores', array( 'FOO' ) ); + + $this->assertEquals( + ExternalStore::fetchFromURL( 'FOO://cluster1/200' ), + 'Hello', + 'Allow FOO://cluster1/200' + ); + $this->assertEquals( + ExternalStore::fetchFromURL( 'FOO://cluster1/300/0' ), + 'Hello', + 'Allow FOO://cluster1/300/0' + ); + # Assertions for r68900 + $this->assertFalse( + ExternalStore::fetchFromURL( 'ftp.example.org' ), + 'Deny domain ftp.example.org' + ); + $this->assertFalse( + ExternalStore::fetchFromURL( '/example.txt' ), + 'Deny path /example.txt' + ); + $this->assertFalse( + ExternalStore::fetchFromURL( 'http://' ), + 'Deny protocol http://' + ); + } +} + +class ExternalStoreFOO { + + protected $data = array( + 'cluster1' => array( + '200' => 'Hello', + '300' => array( + 'Hello', 'World', + ), + ), + ); + + /** + * Fetch data from given URL + * @param string $url An url of the form FOO://cluster/id or FOO://cluster/id/itemid. + * @return mixed + */ + function fetchFromURL( $url ) { + // Based on ExternalStoreDB + $path = explode( '/', $url ); + $cluster = $path[2]; + $id = $path[3]; + if ( isset( $path[4] ) ) { + $itemID = $path[4]; + } else { + $itemID = false; + } + + if ( !isset( $this->data[$cluster][$id] ) ) { + return null; + } + + if ( $itemID !== false + && is_array( $this->data[$cluster][$id] ) + && isset( $this->data[$cluster][$id][$itemID] ) + ) { + return $this->data[$cluster][$id][$itemID]; + } + + return $this->data[$cluster][$id]; + } +} diff --git a/tests/phpunit/includes/ExtraParserTest.php b/tests/phpunit/includes/ExtraParserTest.php new file mode 100644 index 00000000..4a4130e0 --- /dev/null +++ b/tests/phpunit/includes/ExtraParserTest.php @@ -0,0 +1,218 @@ +setMwGlobals( array( + 'wgShowDBErrorBacktrace' => true, + 'wgLanguageCode' => 'en', + 'wgContLang' => $contLang, + 'wgLang' => Language::factory( 'en' ), + 'wgMemc' => new EmptyBagOStuff, + 'wgAlwaysUseTidy' => false, + 'wgCleanSignatures' => true, + ) ); + + $this->options = ParserOptions::newFromUserAndLang( new User, $contLang ); + $this->options->setTemplateCallback( array( __CLASS__, 'statelessFetchTemplate' ) ); + $this->parser = new Parser; + + MagicWord::clearCache(); + } + + /** + * @see Bug 8689 + * @covers Parser::parse + */ + public function testLongNumericLinesDontKillTheParser() { + $longLine = '1.' . str_repeat( '1234567890', 100000 ) . "\n"; + + $title = Title::newFromText( 'Unit test' ); + $options = ParserOptions::newFromUser( new User() ); + $this->assertEquals( "

$longLine

", + $this->parser->parse( $longLine, $title, $options )->getText() ); + } + + /** + * Test the parser entry points + * @covers Parser::parse + */ + public function testParse() { + $title = Title::newFromText( __FUNCTION__ ); + $parserOutput = $this->parser->parse( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options ); + $this->assertEquals( + "

Test\nContent of Template:Foo\nContent of Template:Bar\n

", + $parserOutput->getText() + ); + } + + /** + * @covers Parser::preSaveTransform + */ + public function testPreSaveTransform() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->preSaveTransform( + "Test\r\n{{subst:Foo}}\n{{Bar}}", + $title, + new User(), + $this->options + ); + + $this->assertEquals( "Test\nContent of ''Template:Foo''\n{{Bar}}", $outputText ); + } + + /** + * @covers Parser::preprocess + */ + public function testPreprocess() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->preprocess( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options ); + + $this->assertEquals( + "Test\nContent of ''Template:Foo''\nContent of ''Template:Bar''", + $outputText + ); + } + + /** + * cleanSig() makes all templates substs and removes tildes + * @covers Parser::cleanSig + */ + public function testCleanSig() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" ); + + $this->assertEquals( "{{SUBST:Foo}} ", $outputText ); + } + + /** + * cleanSig() should do nothing if disabled + * @covers Parser::cleanSig + */ + public function testCleanSigDisabled() { + $this->setMwGlobals( 'wgCleanSignatures', false ); + + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" ); + + $this->assertEquals( "{{Foo}} ~~~~", $outputText ); + } + + /** + * cleanSigInSig() just removes tildes + * @dataProvider provideStringsForCleanSigInSig + * @covers Parser::cleanSigInSig + */ + public function testCleanSigInSig( $in, $out ) { + $this->assertEquals( Parser::cleanSigInSig( $in ), $out ); + } + + public static function provideStringsForCleanSigInSig() { + return array( + array( "{{Foo}} ~~~~", "{{Foo}} " ), + array( "~~~", "" ), + array( "~~~~~", "" ), + ); + } + + /** + * @covers Parser::getSection + */ + public function testGetSection() { + $outputText2 = $this->parser->getSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 2 + ); + $outputText1 = $this->parser->getSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 1 + ); + + $this->assertEquals( "=== Heading 2 ===\nSection 2", $outputText2 ); + $this->assertEquals( "== Heading 1 ==\nSection 1\n=== Heading 2 ===\nSection 2", $outputText1 ); + } + + /** + * @covers Parser::replaceSection + */ + public function testReplaceSection() { + $outputText = $this->parser->replaceSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 1, + "New section 1" + ); + + $this->assertEquals( "Section 0\nNew section 1\n\n== Heading 3 ==\nSection 3", $outputText ); + } + + /** + * Templates and comments are not affected, but noinclude/onlyinclude is. + * @covers Parser::getPreloadText + */ + public function testGetPreloadText() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->getPreloadText( + "{{Foo}} censored information ", + $title, + $this->options + ); + + $this->assertEquals( "{{Foo}} information ", $outputText ); + } + + /** + * @param Title $title + * @param bool $parser + * + * @return array + */ + static function statelessFetchTemplate( $title, $parser = false ) { + $text = "Content of ''" . $title->getFullText() . "''"; + $deps = array(); + + return array( + 'text' => $text, + 'finalTitle' => $title, + 'deps' => $deps ); + } + + /** + * @group Database + * @covers Parser::parse + */ + public function testTrackingCategory() { + $title = Title::newFromText( __FUNCTION__ ); + $catName = wfMessage( 'broken-file-category' )->inContentLanguage()->text(); + $cat = Title::makeTitleSafe( NS_CATEGORY, $catName ); + $expected = array( $cat->getDBkey() ); + $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options ); + $result = $parserOutput->getCategoryLinks(); + $this->assertEquals( $expected, $result ); + } + + /** + * @group Database + * @covers Parser::parse + */ + public function testTrackingCategorySpecial() { + // Special pages shouldn't have tracking cats. + $title = SpecialPage::getTitleFor( 'Contributions' ); + $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options ); + $result = $parserOutput->getCategoryLinks(); + $this->assertEmpty( $result ); + } +} diff --git a/tests/phpunit/includes/FallbackTest.php b/tests/phpunit/includes/FallbackTest.php new file mode 100644 index 00000000..c60170f3 --- /dev/null +++ b/tests/phpunit/includes/FallbackTest.php @@ -0,0 +1,72 @@ +markTestSkipped( + "The mb_string functions must be installed to test the fallback functions" + ); + } + + $sampleUTF = "Östergötland_coat_of_arms.png"; + + //mb_substr + $substr_params = array( + array( 0, 0 ), + array( 5, -4 ), + array( 33 ), + array( 100, -5 ), + array( -8, 10 ), + array( 1, 1 ), + array( 2, -1 ) + ); + + foreach ( $substr_params as $param_set ) { + $old_param_set = $param_set; + array_unshift( $param_set, $sampleUTF ); + + $this->assertEquals( + call_user_func_array( 'mb_substr', $param_set ), + call_user_func_array( 'Fallback::mb_substr', $param_set ), + 'Fallback mb_substr with params ' . implode( ', ', $old_param_set ) + ); + } + + //mb_strlen + $this->assertEquals( + mb_strlen( $sampleUTF ), + Fallback::mb_strlen( $sampleUTF ), + 'Fallback mb_strlen' + ); + + //mb_str(r?)pos + $strpos_params = array( + //array( 'ter' ), + //array( 'Ö' ), + //array( 'Ö', 3 ), + //array( 'oat_', 100 ), + //array( 'c', -10 ), + //Broken for now + ); + + foreach ( $strpos_params as $param_set ) { + $old_param_set = $param_set; + array_unshift( $param_set, $sampleUTF ); + + $this->assertEquals( + call_user_func_array( 'mb_strpos', $param_set ), + call_user_func_array( 'Fallback::mb_strpos', $param_set ), + 'Fallback mb_strpos with params ' . implode( ', ', $old_param_set ) + ); + + $this->assertEquals( + call_user_func_array( 'mb_strrpos', $param_set ), + call_user_func_array( 'Fallback::mb_strrpos', $param_set ), + 'Fallback mb_strrpos with params ' . implode( ', ', $old_param_set ) + ); + } + } +} diff --git a/tests/phpunit/includes/FauxRequestTest.php b/tests/phpunit/includes/FauxRequestTest.php new file mode 100644 index 00000000..745a5b42 --- /dev/null +++ b/tests/phpunit/includes/FauxRequestTest.php @@ -0,0 +1,18 @@ +setHeader( 'Content-Type', $value ); + + $this->assertEquals( $request->getHeader( 'Content-Type' ), $value ); + $this->assertEquals( $request->getHeader( 'CONTENT-TYPE' ), $value ); + $this->assertEquals( $request->getHeader( 'content-type' ), $value ); + } +} diff --git a/tests/phpunit/includes/FauxResponseTest.php b/tests/phpunit/includes/FauxResponseTest.php new file mode 100644 index 00000000..4a974ba2 --- /dev/null +++ b/tests/phpunit/includes/FauxResponseTest.php @@ -0,0 +1,118 @@ +response = new FauxResponse; + } + + /** + * @covers FauxResponse::getcookie + * @covers FauxResponse::setcookie + */ + public function testCookie() { + $this->assertEquals( null, $this->response->getcookie( 'key' ), 'Non-existing cookie' ); + $this->response->setcookie( 'key', 'val' ); + $this->assertEquals( 'val', $this->response->getcookie( 'key' ), 'Existing cookie' ); + } + + /** + * @covers FauxResponse::getheader + * @covers FauxResponse::header + */ + public function testHeader() { + $this->assertEquals( null, $this->response->getheader( 'Location' ), 'Non-existing header' ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getheader( 'Location' ), + 'Set header' + ); + + $this->response->header( 'Location: http://127.0.0.1/' ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getheader( 'Location' ), + 'Same header' + ); + + $this->response->header( 'Location: http://127.0.0.2/', false ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getheader( 'Location' ), + 'Same header with override disabled' + ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getheader( 'LOCATION' ), + 'Get header case insensitive' + ); + } + + /** + * @covers FauxResponse::getStatusCode + */ + public function testResponseCode() { + $this->response->header( 'HTTP/1.1 200' ); + $this->assertEquals( 200, $this->response->getStatusCode(), 'Header with no message' ); + + $this->response->header( 'HTTP/1.x 201' ); + $this->assertEquals( + 201, + $this->response->getStatusCode(), + 'Header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.1 202 OK' ); + $this->assertEquals( 202, $this->response->getStatusCode(), 'Normal header' ); + + $this->response->header( 'HTTP/1.x 203 OK' ); + $this->assertEquals( + 203, + $this->response->getStatusCode(), + 'Normal header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.x 204 OK', false, 205 ); + $this->assertEquals( + 205, + $this->response->getStatusCode(), + 'Third parameter overrides the HTTP/... header' + ); + + $this->response->header( 'Location: http://localhost/', false, 206 ); + $this->assertEquals( + 206, + $this->response->getStatusCode(), + 'Third parameter with another header' + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsInitializationTest.php b/tests/phpunit/includes/FormOptionsInitializationTest.php new file mode 100644 index 00000000..1531b569 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsInitializationTest.php @@ -0,0 +1,89 @@ +options; + } +} + +/** + * Test class for FormOptions initialization + * Ensure the FormOptions::add() does what we want it to do. + * + * Generated by PHPUnit on 2011-02-28 at 20:46:27. + * + * Copyright © 2011, Antoine Musso + * + * @author Antoine Musso + */ +class FormOptionsInitializationTest extends MediaWikiTestCase { + /** + * @var FormOptions + */ + protected $object; + + /** + * A new fresh and empty FormOptions object to test initialization + * with. + */ + protected function setUp() { + parent::setUp(); + $this->object = new FormOptionsExposed(); + } + + /** + * @covers FormOptionsExposed::add + */ + public function testAddStringOption() { + $this->object->add( 'foo', 'string value' ); + $this->assertEquals( + array( + 'foo' => array( + 'default' => 'string value', + 'consumed' => false, + 'type' => FormOptions::STRING, + 'value' => null, + ) + ), + $this->object->getOptions() + ); + } + + /** + * @covers FormOptionsExposed::add + */ + public function testAddIntegers() { + $this->object->add( 'one', 1 ); + $this->object->add( 'negone', -1 ); + $this->assertEquals( + array( + 'negone' => array( + 'default' => -1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ), + 'one' => array( + 'default' => 1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ) + ), + $this->object->getOptions() + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsTest.php b/tests/phpunit/includes/FormOptionsTest.php new file mode 100644 index 00000000..665fa390 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsTest.php @@ -0,0 +1,103 @@ +object = new FormOptions; + $this->object->add( 'string1', 'string one' ); + $this->object->add( 'string2', 'string two' ); + $this->object->add( 'integer', 0 ); + $this->object->add( 'float', 0.0 ); + $this->object->add( 'intnull', 0, FormOptions::INTNULL ); + } + + /** Helpers for testGuessType() */ + /* @{ */ + private function assertGuessBoolean( $data ) { + $this->guess( FormOptions::BOOL, $data ); + } + private function assertGuessInt( $data ) { + $this->guess( FormOptions::INT, $data ); + } + private function assertGuessFloat( $data ) { + $this->guess( FormOptions::FLOAT, $data ); + } + private function assertGuessString( $data ) { + $this->guess( FormOptions::STRING, $data ); + } + + /** Generic helper */ + private function guess( $expected, $data ) { + $this->assertEquals( + $expected, + FormOptions::guessType( $data ) + ); + } + /* @} */ + + /** + * Reuse helpers above assertGuessBoolean assertGuessInt assertGuessString + * @covers FormOptions::guessType + */ + public function testGuessTypeDetection() { + $this->assertGuessBoolean( true ); + $this->assertGuessBoolean( false ); + + $this->assertGuessInt( 0 ); + $this->assertGuessInt( -5 ); + $this->assertGuessInt( 5 ); + $this->assertGuessInt( 0x0F ); + + $this->assertGuessFloat( 0.0 ); + $this->assertGuessFloat( 1.5 ); + $this->assertGuessFloat( 1e3 ); + + $this->assertGuessString( 'true' ); + $this->assertGuessString( 'false' ); + $this->assertGuessString( '5' ); + $this->assertGuessString( '0' ); + $this->assertGuessString( '1.5' ); + } + + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnArrayThrowException() { + $this->object->guessType( array( 'foo' ) ); + } + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnNullThrowException() { + $this->object->guessType( null ); + } +} diff --git a/tests/phpunit/includes/GitInfoTest.php b/tests/phpunit/includes/GitInfoTest.php new file mode 100644 index 00000000..e22f5050 --- /dev/null +++ b/tests/phpunit/includes/GitInfoTest.php @@ -0,0 +1,42 @@ +setMwGlobals( 'wgGitInfoCacheDirectory', __DIR__ . '/../data/gitinfo' ); + } + + public function testValidJsonData() { + $dir = $GLOBALS['IP'] . '/testValidJsonData'; + $fixture = new GitInfo( $dir ); + + $this->assertTrue( $fixture->cacheIsComplete() ); + $this->assertEquals( 'refs/heads/master', $fixture->getHead() ); + $this->assertEquals( '0123456789abcdef0123456789abcdef01234567', + $fixture->getHeadSHA1() ); + $this->assertEquals( '1070884800', $fixture->getHeadCommitDate() ); + $this->assertEquals( 'master', $fixture->getCurrentBranch() ); + $this->assertContains( '0123456789abcdef0123456789abcdef01234567', + $fixture->getHeadViewUrl() ); + } + + public function testMissingJsonData() { + $dir = $GLOBALS['IP'] . '/testMissingJsonData'; + $fixture = new GitInfo( $dir ); + + $this->assertFalse( $fixture->cacheIsComplete() ); + + $this->assertEquals( false, $fixture->getHead() ); + $this->assertEquals( false, $fixture->getHeadSHA1() ); + $this->assertEquals( false, $fixture->getHeadCommitDate() ); + $this->assertEquals( false, $fixture->getCurrentBranch() ); + $this->assertEquals( false, $fixture->getHeadViewUrl() ); + + // After calling all the outputs, the cache should be complete + $this->assertTrue( $fixture->cacheIsComplete() ); + } + +} diff --git a/tests/phpunit/includes/GlobalFunctions/GlobalTest.php b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php new file mode 100644 index 00000000..3acc48e2 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php @@ -0,0 +1,745 @@ +setMwGlobals( array( + 'wgReadOnlyFile' => $readOnlyFile, + 'wgUrlProtocols' => array( + 'http://', + 'https://', + 'mailto:', + '//', + 'file://', # Non-default + ), + ) ); + } + + protected function tearDown() { + global $wgReadOnlyFile; + + if ( file_exists( $wgReadOnlyFile ) ) { + unlink( $wgReadOnlyFile ); + } + + parent::tearDown(); + } + + /** + * @dataProvider provideForWfArrayDiff2 + * @covers ::wfArrayDiff2 + */ + public function testWfArrayDiff2( $a, $b, $expected ) { + $this->assertEquals( + wfArrayDiff2( $a, $b ), $expected + ); + } + + // @todo Provide more tests + public static function provideForWfArrayDiff2() { + // $a $b $expected + return array( + array( + array( 'a', 'b' ), + array( 'a', 'b' ), + array(), + ), + array( + array( array( 'a' ), array( 'a', 'b', 'c' ) ), + array( array( 'a' ), array( 'a', 'b' ) ), + array( 1 => array( 'a', 'b', 'c' ) ), + ), + ); + } + + /* + * Test cases for random functions could hypothetically fail, + * even though they shouldn't. + */ + + /** + * @covers ::wfRandom + */ + public function testRandom() { + $this->assertFalse( + wfRandom() == wfRandom() + ); + } + + /** + * @covers ::wfRandomString + */ + public function testRandomString() { + $this->assertFalse( + wfRandomString() == wfRandomString() + ); + $this->assertEquals( + strlen( wfRandomString( 10 ) ), 10 + ); + $this->assertTrue( + preg_match( '/^[0-9a-f]+$/i', wfRandomString() ) === 1 + ); + } + + /** + * @covers ::wfUrlencode + */ + public function testUrlencode() { + $this->assertEquals( + "%E7%89%B9%E5%88%A5:Contributions/Foobar", + wfUrlencode( "\xE7\x89\xB9\xE5\x88\xA5:Contributions/Foobar" ) ); + } + + /** + * @covers ::wfExpandIRI + */ + public function testExpandIRI() { + $this->assertEquals( + "https://te.wikibooks.org/wiki/ఉబుంటు_వాడుకరి_మార్గదర్శని", + wfExpandIRI( "https://te.wikibooks.org/wiki/" + . "%E0%B0%89%E0%B0%AC%E0%B1%81%E0%B0%82%E0%B0%9F%E0%B1%81_" + . "%E0%B0%B5%E0%B0%BE%E0%B0%A1%E0%B1%81%E0%B0%95%E0%B0%B0%E0%B0%BF_" + . "%E0%B0%AE%E0%B0%BE%E0%B0%B0%E0%B1%8D%E0%B0%97%E0%B0%A6%E0%B0%B0" + . "%E0%B1%8D%E0%B0%B6%E0%B0%A8%E0%B0%BF" ) ); + } + + /** + * @covers ::wfReadOnly + */ + public function testReadOnlyEmpty() { + global $wgReadOnly; + $wgReadOnly = null; + + $this->assertFalse( wfReadOnly() ); + $this->assertFalse( wfReadOnly() ); + } + + /** + * @covers ::wfReadOnly + */ + public function testReadOnlySet() { + global $wgReadOnly, $wgReadOnlyFile; + + $f = fopen( $wgReadOnlyFile, "wt" ); + fwrite( $f, 'Message' ); + fclose( $f ); + $wgReadOnly = null; # Check on $wgReadOnlyFile + + $this->assertTrue( wfReadOnly() ); + $this->assertTrue( wfReadOnly() ); # Check cached + + unlink( $wgReadOnlyFile ); + $wgReadOnly = null; # Clean cache + + $this->assertFalse( wfReadOnly() ); + $this->assertFalse( wfReadOnly() ); + } + + public static function provideArrayToCGI() { + return array( + array( array(), '' ), // empty + array( array( 'foo' => 'bar' ), 'foo=bar' ), // string test + array( array( 'foo' => '' ), 'foo=' ), // empty string test + array( array( 'foo' => 1 ), 'foo=1' ), // number test + array( array( 'foo' => true ), 'foo=1' ), // true test + array( array( 'foo' => false ), '' ), // false test + array( array( 'foo' => null ), '' ), // null test + array( array( 'foo' => 'A&B=5+6@!"\'' ), 'foo=A%26B%3D5%2B6%40%21%22%27' ), // urlencoding test + array( + array( 'foo' => 'bar', 'baz' => 'is', 'asdf' => 'qwerty' ), + 'foo=bar&baz=is&asdf=qwerty' + ), // multi-item test + array( array( 'foo' => array( 'bar' => 'baz' ) ), 'foo%5Bbar%5D=baz' ), + array( + array( 'foo' => array( 'bar' => 'baz', 'qwerty' => 'asdf' ) ), + 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf' + ), + array( array( 'foo' => array( 'bar', 'baz' ) ), 'foo%5B0%5D=bar&foo%5B1%5D=baz' ), + array( + array( 'foo' => array( 'bar' => array( 'bar' => 'baz' ) ) ), + 'foo%5Bbar%5D%5Bbar%5D=baz' + ), + ); + } + + /** + * @dataProvider provideArrayToCGI + * @covers ::wfArrayToCgi + */ + public function testArrayToCGI( $array, $result ) { + $this->assertEquals( $result, wfArrayToCgi( $array ) ); + } + + /** + * @covers ::wfArrayToCgi + */ + public function testArrayToCGI2() { + $this->assertEquals( + "baz=bar&foo=bar", + wfArrayToCgi( + array( 'baz' => 'bar' ), + array( 'foo' => 'bar', 'baz' => 'overridden value' ) ) ); + } + + public static function provideCgiToArray() { + return array( + array( '', array() ), // empty + array( 'foo=bar', array( 'foo' => 'bar' ) ), // string + array( 'foo=', array( 'foo' => '' ) ), // empty string + array( 'foo', array( 'foo' => '' ) ), // missing = + array( 'foo=bar&qwerty=asdf', array( 'foo' => 'bar', 'qwerty' => 'asdf' ) ), // multiple value + array( 'foo=A%26B%3D5%2B6%40%21%22%27', array( 'foo' => 'A&B=5+6@!"\'' ) ), // urldecoding test + array( 'foo%5Bbar%5D=baz', array( 'foo' => array( 'bar' => 'baz' ) ) ), + array( + 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf', + array( 'foo' => array( 'bar' => 'baz', 'qwerty' => 'asdf' ) ) + ), + array( 'foo%5B0%5D=bar&foo%5B1%5D=baz', array( 'foo' => array( 0 => 'bar', 1 => 'baz' ) ) ), + array( + 'foo%5Bbar%5D%5Bbar%5D=baz', + array( 'foo' => array( 'bar' => array( 'bar' => 'baz' ) ) ) + ), + ); + } + + /** + * @dataProvider provideCgiToArray + * @covers ::wfCgiToArray + */ + public function testCgiToArray( $cgi, $result ) { + $this->assertEquals( $result, wfCgiToArray( $cgi ) ); + } + + public static function provideCgiRoundTrip() { + return array( + array( '' ), + array( 'foo=bar' ), + array( 'foo=' ), + array( 'foo=bar&baz=biz' ), + array( 'foo=A%26B%3D5%2B6%40%21%22%27' ), + array( 'foo%5Bbar%5D=baz' ), + array( 'foo%5B0%5D=bar&foo%5B1%5D=baz' ), + array( 'foo%5Bbar%5D%5Bbar%5D=baz' ), + ); + } + + /** + * @dataProvider provideCgiRoundTrip + * @covers ::wfArrayToCgi + */ + public function testCgiRoundTrip( $cgi ) { + $this->assertEquals( $cgi, wfArrayToCgi( wfCgiToArray( $cgi ) ) ); + } + + /** + * @covers ::mimeTypeMatch + */ + public function testMimeTypeMatch() { + $this->assertEquals( + 'text/html', + mimeTypeMatch( 'text/html', + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.3 ) ) ); + $this->assertEquals( + 'text/*', + mimeTypeMatch( 'text/html', + array( 'image/*' => 1.0, + 'text/*' => 0.5 ) ) ); + $this->assertEquals( + '*/*', + mimeTypeMatch( 'text/html', + array( '*/*' => 1.0 ) ) ); + $this->assertNull( + mimeTypeMatch( 'text/html', + array( 'image/png' => 1.0, + 'image/svg+xml' => 0.5 ) ) ); + } + + /** + * @covers ::wfNegotiateType + */ + public function testNegotiateType() { + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.5, + 'text/*' => 0.2 ), + array( 'text/html' => 1.0 ) ) ); + $this->assertEquals( + 'application/xhtml+xml', + wfNegotiateType( + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.5, + 'text/*' => 0.2 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'text/html' => 1.0, + 'text/plain' => 0.5, + 'text/*' => 0.5, + 'application/xhtml+xml' => 0.2 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'text/*' => 1.0, + 'image/*' => 0.7, + '*/*' => 0.3 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertNull( + wfNegotiateType( + array( 'text/*' => 1.0 ), + array( 'application/xhtml+xml' => 1.0 ) ) ); + } + + /** + * @covers ::wfDebug + * @covers ::wfDebugMem + */ + public function testDebugFunctionTest() { + + global $wgDebugLogFile, $wgDebugTimestamps; + + $old_log_file = $wgDebugLogFile; + $wgDebugLogFile = tempnam( wfTempDir(), 'mw-' ); + # @todo FIXME: $wgDebugTimestamps should be tested + $old_wgDebugTimestamps = $wgDebugTimestamps; + $wgDebugTimestamps = false; + + wfDebug( "This is a normal string" ); + $this->assertEquals( "This is a normal string", file_get_contents( $wgDebugLogFile ) ); + unlink( $wgDebugLogFile ); + + wfDebug( "This is nöt an ASCII string" ); + $this->assertEquals( "This is nöt an ASCII string", file_get_contents( $wgDebugLogFile ) ); + unlink( $wgDebugLogFile ); + + wfDebug( "\00305This has böth UTF and control chars\003" ); + $this->assertEquals( + " 05This has böth UTF and control chars ", + file_get_contents( $wgDebugLogFile ) + ); + unlink( $wgDebugLogFile ); + + wfDebugMem(); + $this->assertGreaterThan( + 1000, + preg_replace( '/\D/', '', file_get_contents( $wgDebugLogFile ) ) + ); + unlink( $wgDebugLogFile ); + + wfDebugMem( true ); + $this->assertGreaterThan( + 1000000, + preg_replace( '/\D/', '', file_get_contents( $wgDebugLogFile ) ) + ); + unlink( $wgDebugLogFile ); + + $wgDebugLogFile = $old_log_file; + $wgDebugTimestamps = $old_wgDebugTimestamps; + } + + /** + * @covers ::wfClientAcceptsGzip + */ + public function testClientAcceptsGzipTest() { + + $settings = array( + 'gzip' => true, + 'bzip' => false, + '*' => false, + 'compress, gzip' => true, + 'gzip;q=1.0' => true, + 'foozip' => false, + 'foo*zip' => false, + 'gzip;q=abcde' => true, //is this REALLY valid? + 'gzip;q=12345678.9' => true, + ' gzip' => true, + ); + + if ( isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) { + $old_server_setting = $_SERVER['HTTP_ACCEPT_ENCODING']; + } + + foreach ( $settings as $encoding => $expect ) { + $_SERVER['HTTP_ACCEPT_ENCODING'] = $encoding; + + $this->assertEquals( $expect, wfClientAcceptsGzip( true ), + "'$encoding' => " . wfBoolToStr( $expect ) ); + } + + if ( isset( $old_server_setting ) ) { + $_SERVER['HTTP_ACCEPT_ENCODING'] = $old_server_setting; + } + } + + /** + * @covers ::swap + */ + public function testSwapVarsTest() { + $this->hideDeprecated( 'swap' ); + + $var1 = 1; + $var2 = 2; + + $this->assertEquals( $var1, 1, 'var1 is set originally' ); + $this->assertEquals( $var2, 2, 'var1 is set originally' ); + + swap( $var1, $var2 ); + + $this->assertEquals( $var1, 2, 'var1 is swapped' ); + $this->assertEquals( $var2, 1, 'var2 is swapped' ); + } + + /** + * @covers ::wfPercent + */ + public function testWfPercentTest() { + + $pcts = array( + array( 6 / 7, '0.86%', 2, false ), + array( 3 / 3, '1%' ), + array( 22 / 7, '3.14286%', 5 ), + array( 3 / 6, '0.5%' ), + array( 1 / 3, '0%', 0 ), + array( 10 / 3, '0%', -1 ), + array( 3 / 4 / 5, '0.1%', 1 ), + array( 6 / 7 * 8, '6.8571428571%', 10 ), + ); + + foreach ( $pcts as $pct ) { + if ( !isset( $pct[2] ) ) { + $pct[2] = 2; + } + if ( !isset( $pct[3] ) ) { + $pct[3] = true; + } + + $this->assertEquals( wfPercent( $pct[0], $pct[2], $pct[3] ), $pct[1], $pct[1] ); + } + } + + /** + * test @see wfShorthandToInteger() + * @dataProvider provideShorthand + * @covers ::wfShorthandToInteger + */ + public function testWfShorthandToInteger( $shorthand, $expected ) { + $this->assertEquals( $expected, + wfShorthandToInteger( $shorthand ) + ); + } + + /** array( shorthand, expected integer ) */ + public static function provideShorthand() { + return array( + # Null, empty ... + array( '', -1 ), + array( ' ', -1 ), + array( null, -1 ), + + # Failures returns 0 :( + array( 'ABCDEFG', 0 ), + array( 'Ak', 0 ), + + # Int, strings with spaces + array( 1, 1 ), + array( ' 1 ', 1 ), + array( 1023, 1023 ), + array( ' 1023 ', 1023 ), + + # kilo, Mega, Giga + array( '1k', 1024 ), + array( '1K', 1024 ), + array( '1m', 1024 * 1024 ), + array( '1M', 1024 * 1024 ), + array( '1g', 1024 * 1024 * 1024 ), + array( '1G', 1024 * 1024 * 1024 ), + + # Negatives + array( -1, -1 ), + array( -500, -500 ), + array( '-500', -500 ), + array( '-1k', -1024 ), + + # Zeroes + array( '0', 0 ), + array( '0k', 0 ), + array( '0M', 0 ), + array( '0G', 0 ), + array( '-0', 0 ), + array( '-0k', 0 ), + array( '-0M', 0 ), + array( '-0G', 0 ), + ); + } + + /** + * @param string $old Text as it was in the database + * @param string $mine Text submitted while user was editing + * @param string $yours Text submitted by the user + * @param bool $expectedMergeResult Whether the merge should be a success + * @param string $expectedText Text after merge has been completed + * + * @dataProvider provideMerge() + * @group medium + * @covers ::wfMerge + */ + public function testMerge( $old, $mine, $yours, $expectedMergeResult, $expectedText ) { + $this->checkHasDiff3(); + + $mergedText = null; + $isMerged = wfMerge( $old, $mine, $yours, $mergedText ); + + $msg = 'Merge should be a '; + $msg .= $expectedMergeResult ? 'success' : 'failure'; + $this->assertEquals( $expectedMergeResult, $isMerged, $msg ); + + if ( $isMerged ) { + // Verify the merged text + $this->assertEquals( $expectedText, $mergedText, + 'is merged text as expected?' ); + } + } + + public static function provideMerge() { + $EXPECT_MERGE_SUCCESS = true; + $EXPECT_MERGE_FAILURE = false; + + return array( + // #0: clean merge + array( + // old: + "one one one\n" . // trimmed + "\n" . + "two two two", + + // mine: + "one one one ONE ONE\n" . + "\n" . + "two two two\n", // with tailing whitespace + + // yours: + "one one one\n" . + "\n" . + "two two TWO TWO", // trimmed + + // ok: + $EXPECT_MERGE_SUCCESS, + + // result: + "one one one ONE ONE\n" . + "\n" . + "two two TWO TWO\n", // note: will always end in a newline + ), + + // #1: conflict, fail + array( + // old: + "one one one", // trimmed + + // mine: + "one one one ONE ONE\n" . + "\n" . + "bla bla\n" . + "\n", // with tailing whitespace + + // yours: + "one one one\n" . + "\n" . + "two two", // trimmed + + $EXPECT_MERGE_FAILURE, + + // result: + null, + ), + ); + } + + /** + * @dataProvider provideMakeUrlIndexes() + * @covers ::wfMakeUrlIndexes + */ + public function testMakeUrlIndexes( $url, $expected ) { + $index = wfMakeUrlIndexes( $url ); + $this->assertEquals( $expected, $index, "wfMakeUrlIndexes(\"$url\")" ); + } + + public static function provideMakeUrlIndexes() { + return array( + array( + // just a regular :) + 'https://bugzilla.wikimedia.org/show_bug.cgi?id=28627', + array( 'https://org.wikimedia.bugzilla./show_bug.cgi?id=28627' ) + ), + array( + // mailtos are handled special + // is this really right though? that final . probably belongs earlier? + 'mailto:wiki@wikimedia.org', + array( 'mailto:org.wikimedia@wiki.' ) + ), + + // file URL cases per bug 28627... + array( + // three slashes: local filesystem path Unix-style + 'file:///whatever/you/like.txt', + array( 'file://./whatever/you/like.txt' ) + ), + array( + // three slashes: local filesystem path Windows-style + 'file:///c:/whatever/you/like.txt', + array( 'file://./c:/whatever/you/like.txt' ) + ), + array( + // two slashes: UNC filesystem path Windows-style + 'file://intranet/whatever/you/like.txt', + array( 'file://intranet./whatever/you/like.txt' ) + ), + // Multiple-slash cases that can sorta work on Mozilla + // if you hack it just right are kinda pathological, + // and unreliable cross-platform or on IE which means they're + // unlikely to appear on intranets. + // + // Those will survive the algorithm but with results that + // are less consistent. + + // protocol-relative URL cases per bug 29854... + array( + '//bugzilla.wikimedia.org/show_bug.cgi?id=28627', + array( + 'http://org.wikimedia.bugzilla./show_bug.cgi?id=28627', + 'https://org.wikimedia.bugzilla./show_bug.cgi?id=28627' + ) + ), + ); + } + + /** + * @dataProvider provideWfMatchesDomainList + * @covers ::wfMatchesDomainList + */ + public function testWfMatchesDomainList( $url, $domains, $expected, $description ) { + $actual = wfMatchesDomainList( $url, $domains ); + $this->assertEquals( $expected, $actual, $description ); + } + + public static function provideWfMatchesDomainList() { + $a = array(); + $protocols = array( 'HTTP' => 'http:', 'HTTPS' => 'https:', 'protocol-relative' => '' ); + foreach ( $protocols as $pDesc => $p ) { + $a = array_merge( $a, array( + array( + "$p//www.example.com", + array(), + false, + "No matches for empty domains array, $pDesc URL" + ), + array( + "$p//www.example.com", + array( 'www.example.com' ), + true, + "Exact match in domains array, $pDesc URL" + ), + array( + "$p//www.example.com", + array( 'example.com' ), + true, + "Match without subdomain in domains array, $pDesc URL" + ), + array( + "$p//www.example2.com", + array( 'www.example.com', 'www.example2.com', 'www.example3.com' ), + true, + "Exact match with other domains in array, $pDesc URL" + ), + array( + "$p//www.example2.com", + array( 'example.com', 'example2.com', 'example3,com' ), + true, + "Match without subdomain with other domains in array, $pDesc URL" + ), + array( + "$p//www.example4.com", + array( 'example.com', 'example2.com', 'example3,com' ), + false, + "Domain not in array, $pDesc URL" + ), + array( + "$p//nds-nl.wikipedia.org", + array( 'nl.wikipedia.org' ), + false, + "Non-matching substring of domain, $pDesc URL" + ), + ) ); + } + + return $a; + } + + /** + * @covers ::wfMkdirParents + */ + public function testWfMkdirParents() { + // Should not return true if file exists instead of directory + $fname = $this->getNewTempFile(); + wfSuppressWarnings(); + $ok = wfMkdirParents( $fname ); + wfRestoreWarnings(); + $this->assertFalse( $ok ); + } + + /** + * @dataProvider provideWfShellMaintenanceCmdList + * @covers ::wfShellMaintenanceCmd + */ + public function testWfShellMaintenanceCmd( $script, $parameters, $options, + $expected, $description + ) { + if ( wfIsWindows() ) { + // Approximation that's good enough for our purposes just now + $expected = str_replace( "'", '"', $expected ); + } + $actual = wfShellMaintenanceCmd( $script, $parameters, $options ); + $this->assertEquals( $expected, $actual, $description ); + } + + public static function provideWfShellMaintenanceCmdList() { + global $wgPhpCli; + + return array( + array( 'eval.php', array( '--help', '--test' ), array(), + "'$wgPhpCli' 'eval.php' '--help' '--test'", + "Called eval.php --help --test" ), + array( 'eval.php', array( '--help', '--test space' ), array( 'php' => 'php5' ), + "'php5' 'eval.php' '--help' '--test space'", + "Called eval.php --help --test with php option" ), + array( 'eval.php', array( '--help', '--test', 'X' ), array( 'wrapper' => 'MWScript.php' ), + "'$wgPhpCli' 'MWScript.php' 'eval.php' '--help' '--test' 'X'", + "Called eval.php --help --test with wrapper option" ), + array( + 'eval.php', + array( '--help', '--test', 'y' ), + array( 'php' => 'php5', 'wrapper' => 'MWScript.php' ), + "'php5' 'MWScript.php' 'eval.php' '--help' '--test' 'y'", + "Called eval.php --help --test with wrapper and php option" + ), + ); + } + /* @todo many more! */ +} diff --git a/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php b/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php new file mode 100644 index 00000000..9588ffdc --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php @@ -0,0 +1,32 @@ +assertEquals( $expected, wfIsBadImage( $name, $title, $blacklist ), $desc ); + } + + public static function provideWfIsBadImageList() { + $blacklist = '* [[File:Bad.jpg]] except [[Nasty page]]'; + + return array( + array( 'Bad.jpg', false, $blacklist, true, + 'Called on a bad image' ), + array( 'Bad.jpg', Title::makeTitle( NS_MAIN, 'A page' ), $blacklist, true, + 'Called on a bad image' ), + array( 'NotBad.jpg', false, $blacklist, false, + 'Called on a non-bad image' ), + array( 'Bad.jpg', Title::makeTitle( NS_MAIN, 'Nasty page' ), $blacklist, false, + 'Called on a bad image but is on a whitelisted page' ), + array( 'File:Bad.jpg', false, $blacklist, false, + 'Called on a bad image with File:' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/README b/tests/phpunit/includes/GlobalFunctions/README new file mode 100644 index 00000000..0042bdac --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/README @@ -0,0 +1,2 @@ +This directory hold tests for includes/GlobalFunctions.php file +which is a pile of functions. diff --git a/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php new file mode 100644 index 00000000..13f49f79 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php @@ -0,0 +1,112 @@ +assertEquals( + $output, + wfAssembleUrl( $parts ), + "Testing $partsDump assembles to $output" + ); + } + + /** + * Provider of URL parts for testing wfAssembleUrl() + * + * @return array + */ + public static function provideURLParts() { + $schemes = array( + '' => array(), + '//' => array( + 'delimiter' => '//', + ), + 'http://' => array( + 'scheme' => 'http', + 'delimiter' => '://', + ), + ); + + $hosts = array( + '' => array(), + 'example.com' => array( + 'host' => 'example.com', + ), + 'example.com:123' => array( + 'host' => 'example.com', + 'port' => 123, + ), + 'id@example.com' => array( + 'user' => 'id', + 'host' => 'example.com', + ), + 'id@example.com:123' => array( + 'user' => 'id', + 'host' => 'example.com', + 'port' => 123, + ), + 'id:key@example.com' => array( + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + ), + 'id:key@example.com:123' => array( + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + 'port' => 123, + ), + ); + + $cases = array(); + foreach ( $schemes as $scheme => $schemeParts ) { + foreach ( $hosts as $host => $hostParts ) { + foreach ( array( '', '/path' ) as $path ) { + foreach ( array( '', 'query' ) as $query ) { + foreach ( array( '', 'fragment' ) as $fragment ) { + $parts = array_merge( + $schemeParts, + $hostParts + ); + $url = $scheme . + $host . + $path; + + if ( $path ) { + $parts['path'] = $path; + } + if ( $query ) { + $parts['query'] = $query; + $url .= '?' . $query; + } + if ( $fragment ) { + $parts['fragment'] = $fragment; + $url .= '#' . $fragment; + } + + $cases[] = array( + $parts, + $url, + ); + } + } + } + } + } + + $complexURL = 'http://id:key@example.org:321' . + '/over/there?name=ferret&foo=bar#nose'; + $cases[] = array( + wfParseUrl( $complexURL ), + $complexURL, + ); + + return $cases; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php b/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php new file mode 100644 index 00000000..166d641f --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php @@ -0,0 +1,121 @@ +assertEquals( $expected, wfBCP47( $code ), + "Applying BCP47 standard to lower case '$code'" + ); + + $code = strtoupper( $code ); + $this->assertEquals( $expected, wfBCP47( $code ), + "Applying BCP47 standard to upper case '$code'" + ); + } + + /** + * Array format is ($code, $expected) + */ + public static function provideLanguageCodes() { + return array( + // Extracted from BCP47 (list not exhaustive) + # 2.1.1 + array( 'en-ca-x-ca', 'en-CA-x-ca' ), + array( 'sgn-be-fr', 'sgn-BE-FR' ), + array( 'az-latn-x-latn', 'az-Latn-x-latn' ), + # 2.2 + array( 'sr-Latn-RS', 'sr-Latn-RS' ), + array( 'az-arab-ir', 'az-Arab-IR' ), + + # 2.2.5 + array( 'sl-nedis', 'sl-nedis' ), + array( 'de-ch-1996', 'de-CH-1996' ), + + # 2.2.6 + array( + 'en-latn-gb-boont-r-extended-sequence-x-private', + 'en-Latn-GB-boont-r-extended-sequence-x-private' + ), + + // Examples from BCP47 Appendix A + # Simple language subtag: + array( 'DE', 'de' ), + array( 'fR', 'fr' ), + array( 'ja', 'ja' ), + + # Language subtag plus script subtag: + array( 'zh-hans', 'zh-Hans' ), + array( 'sr-cyrl', 'sr-Cyrl' ), + array( 'sr-latn', 'sr-Latn' ), + + # Extended language subtags and their primary language subtag + # counterparts: + array( 'zh-cmn-hans-cn', 'zh-cmn-Hans-CN' ), + array( 'cmn-hans-cn', 'cmn-Hans-CN' ), + array( 'zh-yue-hk', 'zh-yue-HK' ), + array( 'yue-hk', 'yue-HK' ), + + # Language-Script-Region: + array( 'zh-hans-cn', 'zh-Hans-CN' ), + array( 'sr-latn-RS', 'sr-Latn-RS' ), + + # Language-Variant: + array( 'sl-rozaj', 'sl-rozaj' ), + array( 'sl-rozaj-biske', 'sl-rozaj-biske' ), + array( 'sl-nedis', 'sl-nedis' ), + + # Language-Region-Variant: + array( 'de-ch-1901', 'de-CH-1901' ), + array( 'sl-it-nedis', 'sl-IT-nedis' ), + + # Language-Script-Region-Variant: + array( 'hy-latn-it-arevela', 'hy-Latn-IT-arevela' ), + + # Language-Region: + array( 'de-de', 'de-DE' ), + array( 'en-us', 'en-US' ), + array( 'es-419', 'es-419' ), + + # Private use subtags: + array( 'de-ch-x-phonebk', 'de-CH-x-phonebk' ), + array( 'az-arab-x-aze-derbend', 'az-Arab-x-aze-derbend' ), + /** + * Previous test does not reflect the BCP which states: + * az-Arab-x-AZE-derbend + * AZE being private, it should be lower case, hence the test above + * should probably be: + * array( 'az-arab-x-aze-derbend', 'az-Arab-x-AZE-derbend' ), + */ + + # Private use registry values: + array( 'x-whatever', 'x-whatever' ), + array( 'qaa-qaaa-qm-x-southern', 'qaa-Qaaa-QM-x-southern' ), + array( 'de-qaaa', 'de-Qaaa' ), + array( 'sr-latn-qm', 'sr-Latn-QM' ), + array( 'sr-qaaa-rs', 'sr-Qaaa-RS' ), + + # Tags that use extensions + array( 'en-us-u-islamcal', 'en-US-u-islamcal' ), + array( 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ), + array( 'en-a-myext-b-another', 'en-a-myext-b-another' ), + + # Invalid: + // de-419-DE + // a-DE + // ar-a-aaa-b-bbb-a-ccc + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php b/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php new file mode 100644 index 00000000..9d55e85c --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php @@ -0,0 +1,195 @@ +assertSame( $base2, wfBaseConvert( $base3, '3', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base5, '5', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base8, '8', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base10, '10', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base16, '16', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base36, '36', '2' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase3( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base3, wfBaseConvert( $base2, '2', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base5, '5', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base8, '8', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base10, '10', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base16, '16', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base36, '36', '3' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase5( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base5, wfBaseConvert( $base2, '2', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base3, '3', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base8, '8', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base10, '10', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base16, '16', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base36, '36', '5' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase8( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base8, wfBaseConvert( $base2, '2', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base3, '3', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base5, '5', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base10, '10', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base16, '16', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base36, '36', '8' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase10( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base10, wfBaseConvert( $base2, '2', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base3, '3', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base5, '5', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base8, '8', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base16, '16', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base36, '36', '10' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase16( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base16, wfBaseConvert( $base2, '2', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base3, '3', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base5, '5', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base8, '8', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base10, '10', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base36, '36', '16' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase36( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base36, wfBaseConvert( $base2, '2', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base3, '3', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base5, '5', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base8, '8', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base10, '10', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base16, '16', '36' ) ); + } + + public function testLargeNumber() { + $this->assertSame( '1100110001111010000000101110100', wfBaseConvert( 'sd89ys', 36, 2 ) ); + $this->assertSame( '11102112120221201101', wfBaseConvert( 'sd89ys', 36, 3 ) ); + $this->assertSame( '12003102232400', wfBaseConvert( 'sd89ys', 36, 5 ) ); + $this->assertSame( '14617200564', wfBaseConvert( 'sd89ys', 36, 8 ) ); + $this->assertSame( '1715274100', wfBaseConvert( 'sd89ys', 36, 10 ) ); + $this->assertSame( '663d0174', wfBaseConvert( 'sd89ys', 36, 16 ) ); + } + + public static function provideNumbers() { + $x = array(); + $chars = '0123456789abcdefghijklmnopqrstuvwxyz'; + for ( $i = 0; $i < 50; $i++ ) { + $base = mt_rand( 2, 36 ); + $len = mt_rand( 10, 100 ); + + $str = ''; + for ( $j = 0; $j < $len; $j++ ) { + $str .= $chars[mt_rand( 0, $base - 1 )]; + } + + $x[] = array( $base, $str ); + } + + return $x; + } + + /** + * @dataProvider provideNumbers + */ + public function testIdentity( $base, $number ) { + $this->assertSame( $number, wfBaseConvert( $number, $base, $base, strlen( $number ) ) ); + } + + public function testInvalid() { + $this->assertFalse( wfBaseConvert( '101', 1, 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 1 ) ); + $this->assertFalse( wfBaseConvert( '101', 37, 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 37 ) ); + $this->assertFalse( wfBaseConvert( 'abcde', 10, 11 ) ); + $this->assertFalse( wfBaseConvert( '12930', 2, 10 ) ); + $this->assertFalse( wfBaseConvert( '101', 'abc', 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 'abc' ) ); + } + + public function testPadding() { + $number = "10101010101"; + $this->assertSame( + strlen( $number ) + 5, + strlen( wfBaseConvert( $number, 2, 2, strlen( $number ) + 5 ) ) + ); + $this->assertSame( + strlen( $number ), + strlen( wfBaseConvert( $number, 2, 2, strlen( $number ) - 5 ) ) + ); + } + + public function testLeadingZero() { + $this->assertSame( '24', wfBaseConvert( '010', 36, 16 ) ); + $this->assertSame( '37d4', wfBaseConvert( '0b10', 36, 16 ) ); + $this->assertSame( 'a734', wfBaseConvert( '0x10', 36, 16 ) ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php new file mode 100644 index 00000000..705730a7 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php @@ -0,0 +1,40 @@ +assertEquals( $basename, wfBaseName( $fullpath ), + "wfBaseName('$fullpath') => '$basename'" ); + } + + public static function providePaths() { + return array( + array( '', '' ), + array( '/', '' ), + array( '\\', '' ), + array( '//', '' ), + array( '\\\\', '' ), + array( 'a', 'a' ), + array( 'aaaa', 'aaaa' ), + array( '/a', 'a' ), + array( '\\a', 'a' ), + array( '/aaaa', 'aaaa' ), + array( '\\aaaa', 'aaaa' ), + array( '/aaaa/', 'aaaa' ), + array( '\\aaaa\\', 'aaaa' ), + array( '\\aaaa\\', 'aaaa' ), + array( + '/mnt/upload3/wikipedia/en/thumb/8/8b/' + . 'Zork_Grand_Inquisitor_box_cover.jpg/93px-Zork_Grand_Inquisitor_box_cover.jpg', + '93px-Zork_Grand_Inquisitor_box_cover.jpg' + ), + array( 'C:\\Progra~1\\Wikime~1\\Wikipe~1\\VIEWER.EXE', 'VIEWER.EXE' ), + array( 'Östergötland_coat_of_arms.png', 'Östergötland_coat_of_arms.png' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php new file mode 100644 index 00000000..a69defb3 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php @@ -0,0 +1,117 @@ +getProtocol() + $this->setMwGlobals( array( + 'wgServer' => $server, + 'wgCanonicalServer' => $canServer, + 'wgRequest' => new FauxRequest( array(), false, null, $httpsMode ? 'https' : 'http' ) + ) ); + + $this->assertEquals( $fullUrl, wfExpandUrl( $shortUrl, $defaultProto ), $message ); + } + + /** + * Provider of URL examples for testing wfExpandUrl() + * + * @return array + */ + public static function provideExpandableUrls() { + $modes = array( 'http', 'https' ); + $servers = array( + 'http' => 'http://example.com', + 'https' => 'https://example.com', + 'protocol-relative' => '//example.com' + ); + $defaultProtos = array( + 'http' => PROTO_HTTP, + 'https' => PROTO_HTTPS, + 'protocol-relative' => PROTO_RELATIVE, + 'current' => PROTO_CURRENT, + 'canonical' => PROTO_CANONICAL + ); + + $retval = array(); + foreach ( $modes as $mode ) { + $httpsMode = $mode == 'https'; + foreach ( $servers as $serverDesc => $server ) { + foreach ( $modes as $canServerMode ) { + $canServer = "$canServerMode://example2.com"; + foreach ( $defaultProtos as $protoDesc => $defaultProto ) { + $retval[] = array( + 'http://example.com', 'http://example.com', + $defaultProto, $server, $canServer, $httpsMode, + "Testing fully qualified http URLs (no need to expand) " + . "(defaultProto: $protoDesc , wgServer: $server, " + . "wgCanonicalServer: $canServer, current request protocol: $mode )" + ); + $retval[] = array( + 'https://example.com', 'https://example.com', + $defaultProto, $server, $canServer, $httpsMode, + "Testing fully qualified https URLs (no need to expand) " + . "(defaultProto: $protoDesc , wgServer: $server, " + . "wgCanonicalServer: $canServer, current request protocol: $mode )" + ); + # Would be nice to support this, see fixme on wfExpandUrl() + $retval[] = array( + "wiki/FooBar", 'wiki/FooBar', + $defaultProto, $server, $canServer, $httpsMode, + "Test non-expandable relative URLs (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + + // Determine expected protocol + if ( $protoDesc == 'protocol-relative' ) { + $p = ''; + } elseif ( $protoDesc == 'current' ) { + $p = "$mode:"; + } elseif ( $protoDesc == 'canonical' ) { + $p = "$canServerMode:"; + } else { + $p = $protoDesc . ':'; + } + // Determine expected server name + if ( $protoDesc == 'canonical' ) { + $srv = $canServer; + } elseif ( $serverDesc == 'protocol-relative' ) { + $srv = $p . $server; + } else { + $srv = $server; + } + + $retval[] = array( + "$p//wikipedia.org", '//wikipedia.org', + $defaultProto, $server, $canServer, $httpsMode, + "Test protocol-relative URL (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + $retval[] = array( + "$srv/wiki/FooBar", + '/wiki/FooBar', + $defaultProto, + $server, + $canServer, + $httpsMode, + "Testing expanding URL beginning with / (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + } + } + } + } + + return $retval; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php new file mode 100644 index 00000000..bb2b33fe --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php @@ -0,0 +1,46 @@ +assertEquals( __METHOD__, wfGetCaller( 1 ) ); + } + + function callerOne() { + return wfGetCaller(); + } + + public function testOne() { + $this->assertEquals( 'WfGetCallerTest::testOne', self::callerOne() ); + } + + function intermediateFunction( $level = 2, $n = 0 ) { + if ( $n > 0 ) { + return self::intermediateFunction( $level, $n - 1 ); + } + + return wfGetCaller( $level ); + } + + public function testTwo() { + $this->assertEquals( 'WfGetCallerTest::testTwo', self::intermediateFunction() ); + } + + public function testN() { + $this->assertEquals( 'WfGetCallerTest::testN', self::intermediateFunction( 2, 0 ) ); + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( 1, 0 ) + ); + + for ( $i = 0; $i < 10; $i++ ) { + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( $i + 1, $i ) + ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php new file mode 100644 index 00000000..232fa922 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php @@ -0,0 +1,157 @@ +setMwGlobals( 'wgUrlProtocols', array( + '//', + 'http://', + 'https://', + 'file://', + 'mailto:', + ) ); + } + + /** + * @dataProvider provideURLs + */ + public function testWfParseUrl( $url, $parts ) { + $this->assertEquals( + $parts, + wfParseUrl( $url ) + ); + } + + /** + * Provider of URLs for testing wfParseUrl() + * + * @return array + */ + public static function provideURLs() { + return array( + array( + '//example.org', + array( + 'scheme' => '', + 'delimiter' => '//', + 'host' => 'example.org', + ) + ), + array( + 'http://example.org', + array( + 'scheme' => 'http', + 'delimiter' => '://', + 'host' => 'example.org', + ) + ), + array( + 'https://example.org', + array( + 'scheme' => 'https', + 'delimiter' => '://', + 'host' => 'example.org', + ) + ), + array( + 'http://id:key@example.org:123/path?foo=bar#baz', + array( + 'scheme' => 'http', + 'delimiter' => '://', + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.org', + 'port' => 123, + 'path' => '/path', + 'query' => 'foo=bar', + 'fragment' => 'baz', + ) + ), + array( + 'file://example.org/etc/php.ini', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => 'example.org', + 'path' => '/etc/php.ini', + ) + ), + array( + 'file:///etc/php.ini', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => '', + 'path' => '/etc/php.ini', + ) + ), + array( + 'file:///c:/', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => '', + 'path' => '/c:/', + ) + ), + array( + 'mailto:id@example.org', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => 'id@example.org', + 'path' => '', + ) + ), + array( + 'mailto:id@example.org?subject=Foo', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => 'id@example.org', + 'path' => '', + 'query' => 'subject=Foo', + ) + ), + array( + 'mailto:?subject=Foo', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => '', + 'path' => '', + 'query' => 'subject=Foo', + ) + ), + array( + 'invalid://test/', + false + ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php new file mode 100644 index 00000000..1faad52a --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php @@ -0,0 +1,93 @@ +assertEquals( + $outputPath, + wfRemoveDotSegments( $inputPath ), + "Testing $inputPath expands to $outputPath" + ); + } + + /** + * Provider of URL paths for testing wfRemoveDotSegments() + * + * @return array + */ + public static function providePaths() { + return array( + array( '/a/b/c/./../../g', '/a/g' ), + array( 'mid/content=5/../6', 'mid/6' ), + array( '/a//../b', '/a/b' ), + array( '/.../a', '/.../a' ), + array( '.../a', '.../a' ), + array( '', '' ), + array( '/', '/' ), + array( '//', '//' ), + array( '.', '' ), + array( '..', '' ), + array( '...', '...' ), + array( '/.', '/' ), + array( '/..', '/' ), + array( './', '' ), + array( '../', '' ), + array( './a', 'a' ), + array( '../a', 'a' ), + array( '../../a', 'a' ), + array( '.././a', 'a' ), + array( './../a', 'a' ), + array( '././a', 'a' ), + array( '../../', '' ), + array( '.././', '' ), + array( './../', '' ), + array( '././', '' ), + array( '../..', '' ), + array( '../.', '' ), + array( './..', '' ), + array( './.', '' ), + array( '/../../a', '/a' ), + array( '/.././a', '/a' ), + array( '/./../a', '/a' ), + array( '/././a', '/a' ), + array( '/../../', '/' ), + array( '/.././', '/' ), + array( '/./../', '/' ), + array( '/././', '/' ), + array( '/../..', '/' ), + array( '/../.', '/' ), + array( '/./..', '/' ), + array( '/./.', '/' ), + array( 'b/../../a', '/a' ), + array( 'b/.././a', '/a' ), + array( 'b/./../a', '/a' ), + array( 'b/././a', 'b/a' ), + array( 'b/../../', '/' ), + array( 'b/.././', '/' ), + array( 'b/./../', '/' ), + array( 'b/././', 'b/' ), + array( 'b/../..', '/' ), + array( 'b/../.', '/' ), + array( 'b/./..', '/' ), + array( 'b/./.', 'b/' ), + array( '/b/../../a', '/a' ), + array( '/b/.././a', '/a' ), + array( '/b/./../a', '/a' ), + array( '/b/././a', '/b/a' ), + array( '/b/../../', '/' ), + array( '/b/.././', '/' ), + array( '/b/./../', '/' ), + array( '/b/././', '/b/' ), + array( '/b/../..', '/' ), + array( '/b/../.', '/' ), + array( '/b/./..', '/' ), + array( '/b/./.', '/b/' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php new file mode 100644 index 00000000..fcd26f54 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php @@ -0,0 +1,20 @@ +assertEquals( 333333, strlen( $output ) ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php new file mode 100644 index 00000000..67284d27 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php @@ -0,0 +1,31 @@ +assertEquals( + wfShorthandToInteger( $input ), + $output, + $description + ); + } + + public static function provideABunchOfShorthands() { + return array( + array( '', -1, 'Empty string' ), + array( ' ', -1, 'String of spaces' ), + array( '1G', 1024 * 1024 * 1024, 'One gig uppercased' ), + array( '1g', 1024 * 1024 * 1024, 'One gig lowercased' ), + array( '1M', 1024 * 1024, 'One meg uppercased' ), + array( '1m', 1024 * 1024, 'One meg lowercased' ), + array( '1K', 1024, 'One kb uppercased' ), + array( '1k', 1024, 'One kb lowercased' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php new file mode 100644 index 00000000..bea496c4 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php @@ -0,0 +1,196 @@ +assertEquals( $output, wfTimestamp( $format, $input ), $desc ); + } + + public static function provideNormalTimestamps() { + $t = gmmktime( 12, 34, 56, 1, 15, 2001 ); + + return array( + // TS_UNIX + array( $t, TS_MW, '20010115123456', 'TS_UNIX to TS_MW' ), + array( -30281104, TS_MW, '19690115123456', 'Negative TS_UNIX to TS_MW' ), + array( $t, TS_UNIX, 979562096, 'TS_UNIX to TS_UNIX' ), + array( $t, TS_DB, '2001-01-15 12:34:56', 'TS_UNIX to TS_DB' ), + + array( $t, TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_ISO_8601_BASIC to TS_DB' ), + + // TS_MW + array( '20010115123456', TS_MW, '20010115123456', 'TS_MW to TS_MW' ), + array( '20010115123456', TS_UNIX, 979562096, 'TS_MW to TS_UNIX' ), + array( '20010115123456', TS_DB, '2001-01-15 12:34:56', 'TS_MW to TS_DB' ), + array( '20010115123456', TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_MW to TS_ISO_8601_BASIC' ), + + // TS_DB + array( '2001-01-15 12:34:56', TS_MW, '20010115123456', 'TS_DB to TS_MW' ), + array( '2001-01-15 12:34:56', TS_UNIX, 979562096, 'TS_DB to TS_UNIX' ), + array( '2001-01-15 12:34:56', TS_DB, '2001-01-15 12:34:56', 'TS_DB to TS_DB' ), + array( + '2001-01-15 12:34:56', + TS_ISO_8601_BASIC, + '20010115T123456Z', + 'TS_DB to TS_ISO_8601_BASIC' + ), + + # rfc2822 section 3.3 + array( '20010115123456', TS_RFC2822, 'Mon, 15 Jan 2001 12:34:56 GMT', 'TS_MW to TS_RFC2822' ), + array( 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ), + array( + ' Mon, 15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 with leading space to TS_MW' + ), + array( + '15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 without optional day-of-week to TS_MW' + ), + + # FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space + # obs-FWS = 1*WSP *(CRLF 1*WSP) ; Section 4.2 + array( 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ), + + # WSP = SP / HTAB ; rfc2234 + array( + "Mon, 15 Jan\x092001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB to TS_MW' + ), + array( + "Mon, 15 Jan\x09 \x09 2001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB and SP to TS_MW' + ), + array( + 'Sun, 6 Nov 94 08:49:37 GMT', + TS_MW, + '19941106084937', + 'TS_RFC2822 with obsolete year to TS_MW' + ), + ); + } + + /** + * This test checks wfTimestamp() with values outside. + * It needs PHP 64 bits or PHP > 5.1. + * See r74778 and bug 25451 + * @dataProvider provideOldTimestamps + */ + public function testOldTimestamps( $input, $outputType, $output, $message ) { + $timestamp = wfTimestamp( $outputType, $input ); + if ( substr( $output, 0, 1 ) === '/' ) { + // Bug 64946: Day of the week calculations for very old + // timestamps varies from system to system. + $this->assertRegExp( $output, $timestamp, $message ); + } else { + $this->assertEquals( $output, $timestamp, $message ); + } + } + + public static function provideOldTimestamps() { + return array( + array( + '19011213204554', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:54 GMT', + 'Earliest time according to PHP documentation' + ), + array( '20380119031407', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:07 GMT', 'Latest 32 bit time' ), + array( '19011213204552', TS_UNIX, '-2147483648', 'Earliest 32 bit unix time' ), + array( '20380119031407', TS_UNIX, '2147483647', 'Latest 32 bit unix time' ), + array( '19011213204552', TS_RFC2822, 'Fri, 13 Dec 1901 20:45:52 GMT', 'Earliest 32 bit time' ), + array( + '19011213204551', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:51 GMT', 'Earliest 32 bit time - 1' + ), + array( '20380119031408', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:08 GMT', 'Latest 32 bit time + 1' ), + array( '19011212000000', TS_MW, '19011212000000', 'Convert to itself r74778#c10645' ), + array( '19011213204551', TS_UNIX, '-2147483649', 'Earliest 32 bit unix time - 1' ), + array( '20380119031408', TS_UNIX, '2147483648', 'Latest 32 bit unix time + 1' ), + array( '-2147483649', TS_MW, '19011213204551', '1901 negative unix time to MediaWiki' ), + array( '-5331871504', TS_MW, '18010115123456', '1801 negative unix time to MediaWiki' ), + array( + '0117-08-09 12:34:56', + TS_RFC2822, + '/, 09 Aug 0117 12:34:56 GMT$/', + 'Death of Roman Emperor [[Trajan]]' + ), + + /* @todo FIXME: 00 to 101 years are taken as being in [1970-2069] */ + array( '-58979923200', TS_RFC2822, '/, 01 Jan 0101 00:00:00 GMT$/', '1/1/101' ), + array( '-62135596800', TS_RFC2822, 'Mon, 01 Jan 0001 00:00:00 GMT', 'Year 1' ), + + /* It is not clear if we should generate a year 0 or not + * We are completely off RFC2822 requirement of year being + * 1900 or later. + */ + array( + '-62142076800', + TS_RFC2822, + 'Wed, 18 Oct 0000 00:00:00 GMT', + 'ISO 8601:2004 [[year 0]], also called [[1 BC]]' + ), + ); + } + + /** + * The Resource Loader uses wfTimestamp() to convert timestamps + * from If-Modified-Since header. Thus it must be able to parse all + * rfc2616 date formats + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1 + * @dataProvider provideHttpDates + */ + public function testHttpDate( $input, $output, $desc ) { + $this->assertEquals( $output, wfTimestamp( TS_MW, $input ), $desc ); + } + + public static function provideHttpDates() { + return array( + array( 'Sun, 06 Nov 1994 08:49:37 GMT', '19941106084937', 'RFC 822 date' ), + array( 'Sunday, 06-Nov-94 08:49:37 GMT', '19941106084937', 'RFC 850 date' ), + array( 'Sun Nov 6 08:49:37 1994', '19941106084937', "ANSI C's asctime() format" ), + // See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html and r77171 + array( + 'Mon, 22 Nov 2010 14:12:42 GMT; length=52626', + '20101122141242', + 'Netscape extension to HTTP/1.0' + ), + ); + } + + /** + * There are a number of assumptions in our codebase where wfTimestamp() + * should give the current date but it is not given a 0 there. See r71751 CR + */ + public function testTimestampParameter() { + $now = wfTimestamp( TS_UNIX ); + // We check that wfTimestamp doesn't return false (error) and use a LessThan assert + // for the cases where the test is run in a second boundary. + + $zero = wfTimestamp( TS_UNIX, 0 ); + $this->assertNotEquals( false, $zero ); + $this->assertLessThan( 5, $zero - $now ); + + $empty = wfTimestamp( TS_UNIX, '' ); + $this->assertNotEquals( false, $empty ); + $this->assertLessThan( 5, $empty - $now ); + + $null = wfTimestamp( TS_UNIX, null ); + $this->assertNotEquals( false, $null ); + $this->assertLessThan( 5, $null - $now ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php new file mode 100644 index 00000000..d11668b7 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php @@ -0,0 +1,124 @@ +verifyEncodingFor( 'Apache', $input, $expected ); + } + + /** + * @dataProvider provideURLS + */ + public function testEncodingUrlWithMicrosoftIis7( $input, $expected ) { + $this->verifyEncodingFor( 'Microsoft-IIS/7', $input, $expected ); + } + + #### HELPERS ############################################################# + + /** + * Internal helper that actually run the test. + * Called by the public methods testEncodingUrlWith...() + * + */ + private function verifyEncodingFor( $server, $input, $expectations ) { + $expected = $this->extractExpect( $server, $expectations ); + + // save up global + $old = isset( $_SERVER['SERVER_SOFTWARE'] ) + ? $_SERVER['SERVER_SOFTWARE'] + : null; + $_SERVER['SERVER_SOFTWARE'] = $server; + wfUrlencode( null ); + + // do the requested test + $this->assertEquals( + $expected, + wfUrlencode( $input ), + "Encoding '$input' for server '$server' should be '$expected'" + ); + + // restore global + if ( $old === null ) { + unset( $_SERVER['SERVER_SOFTWARE'] ); + } else { + $_SERVER['SERVER_SOFTWARE'] = $old; + } + wfUrlencode( null ); + } + + /** + * Interprets the provider array. Return expected value depending + * the HTTP server name. + */ + private function extractExpect( $server, $expectations ) { + if ( is_string( $expectations ) ) { + return $expectations; + } elseif ( is_array( $expectations ) ) { + if ( !array_key_exists( $server, $expectations ) ) { + throw new MWException( __METHOD__ . " expectation does not have any " + . "value for server name $server. Check the provider array.\n" ); + } else { + return $expectations[$server]; + } + } else { + throw new MWException( __METHOD__ . " given invalid expectation for " + . "'$server'. Should be a string or an array( => ).\n" ); + } + } + + #### PROVIDERS ########################################################### + + /** + * Format is either: + * array( 'input', 'expected' ); + * Or: + * array( 'input', + * array( 'Apache', 'expected' ), + * array( 'Microsoft-IIS/7', 'expected' ), + * ), + * If you want to add other HTTP server name, you will have to add a new + * testing method much like the testEncodingUrlWith() method above. + */ + public static function provideURLS() { + return array( + ### RFC 1738 chars + // + is not safe + array( '+', '%2B' ), + // & and = not safe in queries + array( '&', '%26' ), + array( '=', '%3D' ), + + array( ':', array( + 'Apache' => ':', + 'Microsoft-IIS/7' => '%3A', + ) ), + + // remaining chars do not need encoding + array( + ';@$-_.!*', + ';@$-_.!*', + ), + + ### Other tests + // slash remain unchanged. %2F seems to break things + array( '/', '/' ), + + // Other 'funnies' chars + array( '[]', '%5B%5D' ), + array( '<>', '%3C%3E' ), + + // Apostrophe is encoded + array( '\'', '%27' ), + ); + } +} diff --git a/tests/phpunit/includes/HooksTest.php b/tests/phpunit/includes/HooksTest.php new file mode 100644 index 00000000..74d4b091 --- /dev/null +++ b/tests/phpunit/includes/HooksTest.php @@ -0,0 +1,202 @@ +assertSame( $expectedFoo, $foo, $msg ); + $this->assertSame( $expectedBar, $bar, $msg ); + } + + /** + * @dataProvider provideHooks + * @covers Hooks::register + * @covers Hooks::run + */ + public function testNewStyleHooks( $msg, $hook, $expectedFoo, $expectedBar ) { + $foo = $bar = 'original'; + + Hooks::register( 'MediaWikiHooksTest001', $hook ); + Hooks::run( 'MediaWikiHooksTest001', array( &$foo, &$bar ) ); + + $this->assertSame( $expectedFoo, $foo, $msg ); + $this->assertSame( $expectedBar, $bar, $msg ); + } + + /** + * @covers Hooks::isRegistered + * @covers Hooks::register + * @covers Hooks::getHandlers + * @covers Hooks::run + */ + public function testNewStyleHookInteraction() { + global $wgHooks; + + $a = new NothingClass(); + $b = new NothingClass(); + + $wgHooks['MediaWikiHooksTest001'][] = $a; + $this->assertTrue( + Hooks::isRegistered( 'MediaWikiHooksTest001' ), + 'Hook registered via $wgHooks should be noticed by Hooks::isRegistered' + ); + + Hooks::register( 'MediaWikiHooksTest001', $b ); + $this->assertEquals( + 2, + count( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ), + 'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register' + ); + + $foo = 'quux'; + $bar = 'qaax'; + + Hooks::run( 'MediaWikiHooksTest001', array( &$foo, &$bar ) ); + $this->assertEquals( + 1, + $a->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + $this->assertEquals( + 1, + $b->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + } + + /** + * @expectedException MWException + * @covers Hooks::run + */ + public function testUncallableFunction() { + Hooks::register( 'MediaWikiHooksTest001', 'ThisFunctionDoesntExist' ); + Hooks::run( 'MediaWikiHooksTest001', array() ); + } + + /** + * @covers Hooks::run + */ + public function testFalseReturn() { + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + return false; + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + + return true; + } ); + $foo = 'original'; + Hooks::run( 'MediaWikiHooksTest001', array( &$foo ) ); + $this->assertSame( 'original', $foo, 'Hooks continued processing after a false return.' ); + } + + /** + * @expectedException FatalError + * @covers Hooks::run + */ + public function testFatalError() { + Hooks::register( 'MediaWikiHooksTest001', function () { + return 'test'; + } ); + Hooks::run( 'MediaWikiHooksTest001', array() ); + } +} + +function NothingFunction( &$foo, &$bar ) { + $foo = 'changed-func'; + + return true; +} + +function NothingFunctionData( $data, &$foo, &$bar ) { + $foo = $data; + + return true; +} + +class NothingClass { + public $calls = 0; + + public static function someStatic( &$foo, &$bar ) { + $foo = 'changed-static'; + + return true; + } + + public function someNonStatic( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-nonstatic'; + $bar = 'changed-nonstatic'; + + return true; + } + + public function onMediaWikiHooksTest001( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-onevent'; + + return true; + } + + public function someNonStaticWithData( $data, &$foo, &$bar ) { + $this->calls++; + $foo = $data; + + return true; + } +} diff --git a/tests/phpunit/includes/HtmlFormatterTest.php b/tests/phpunit/includes/HtmlFormatterTest.php new file mode 100644 index 00000000..9dbfa452 --- /dev/null +++ b/tests/phpunit/includes/HtmlFormatterTest.php @@ -0,0 +1,127 @@ +filterContent(); + $html = $formatter->getText(); + $removed = array(); + foreach ( $removedElements as $removedElement ) { + $removed[] = self::normalize( $formatter->getText( $removedElement ) ); + } + $expectedRemoved = array_map( 'self::normalize', $expectedRemoved ); + + $this->assertValidHtmlSnippet( $html ); + $this->assertEquals( self::normalize( $expectedText ), self::normalize( $html ) ); + $this->assertEquals( asort( $expectedRemoved ), asort( $removed ) ); + } + + private static function normalize( $s ) { + return str_replace( "\n", '', + str_replace( "\r", '', $s ) // "yay" to Windows! + ); + } + + public function getHtmlData() { + $removeImages = function ( HtmlFormatter $f ) { + $f->setRemoveMedia(); + }; + $removeTags = function ( HtmlFormatter $f ) { + $f->remove( array( 'table', '.foo', '#bar', 'div.baz' ) ); + }; + $flattenSomeStuff = function ( HtmlFormatter $f ) { + $f->flatten( array( 's', 'div' ) ); + }; + $flattenEverything = function ( HtmlFormatter $f ) { + $f->flattenAllTags(); + }; + return array( + // remove images if asked + array( + 'Blah', + '', + array( 'Blah' ), + $removeImages, + ), + // basic tag removal + array( + // @codingStandardsIgnoreStart Ignore long line warnings. + '
foo
foo
foo
bar +foobar
test
+baz', + // @codingStandardsIgnoreEnd + '
test
+baz', + array( + '
foo
', + '
foo
', + '
foo
', + 'bar', + 'foobar', + '
', + ), + $removeTags, + ), + // don't flatten tags that start like chosen ones + array( + '
foo bar
', + 'foo bar', + array(), + $flattenSomeStuff, + ), + // total flattening + array( + '
bar2
', + 'bar2', + array(), + $flattenEverything, + ), + // UTF-8 preservation and security + array( + '<Тест!> &<&&&&', + '<Тест!> &<&&&&', + array(), + $removeTags, // Have some rules to trigger a DOM parse + ), + // https://bugzilla.wikimedia.org/show_bug.cgi?id=53086 + array( + 'Foo[1]' + . ' Bar', + 'Foo[1]' + . ' Bar', + ), + ); + } + + public function testQuickProcessing() { + $f = new MockHtmlFormatter( 'foo' ); + $f->filterContent(); + $this->assertFalse( $f->hasDoc, 'HtmlFormatter should not needlessly parse HTML' ); + } +} + +class MockHtmlFormatter extends HtmlFormatter { + public $hasDoc = false; + + public function getDoc() { + $this->hasDoc = true; + return parent::getDoc(); + } +} diff --git a/tests/phpunit/includes/HtmlTest.php b/tests/phpunit/includes/HtmlTest.php new file mode 100644 index 00000000..a8829cd8 --- /dev/null +++ b/tests/phpunit/includes/HtmlTest.php @@ -0,0 +1,773 @@ +setNamespaces( array( + -2 => 'Media', + -1 => 'Special', + 0 => '', + 1 => 'Talk', + 2 => 'User', + 3 => 'User_talk', + 4 => 'MyWiki', + 5 => 'MyWiki_Talk', + 6 => 'File', + 7 => 'File_talk', + 8 => 'MediaWiki', + 9 => 'MediaWiki_talk', + 10 => 'Template', + 11 => 'Template_talk', + 14 => 'Category', + 15 => 'Category_talk', + 100 => 'Custom', + 101 => 'Custom_talk', + ) ); + + $this->setMwGlobals( array( + 'wgLanguageCode' => $langCode, + 'wgContLang' => $langObj, + 'wgLang' => $langObj, + 'wgWellFormedXml' => false, + ) ); + } + + /** + * @covers Html::element + */ + public function testElementBasics() { + $this->assertEquals( + '', + Html::element( 'img', null, '' ), + 'No close tag for short-tag elements' + ); + + $this->assertEquals( + '', + Html::element( 'element', null, null ), + 'Close tag for empty element (null, null)' + ); + + $this->assertEquals( + '', + Html::element( 'element', array(), '' ), + 'Close tag for empty element (array, string)' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + '', + Html::element( 'img', null, '' ), + 'Self-closing tag for short-tag elements (wgWellFormedXml = true)' + ); + } + + public function dataXmlMimeType() { + return array( + // ( $mimetype, $isXmlMimeType ) + # HTML is not an XML MimeType + array( 'text/html', false ), + # XML is an XML MimeType + array( 'text/xml', true ), + array( 'application/xml', true ), + # XHTML is an XML MimeType + array( 'application/xhtml+xml', true ), + # Make sure other +xml MimeTypes are supported + # SVG is another random MimeType even though we don't use it + array( 'image/svg+xml', true ), + # Complete random other MimeTypes are not XML + array( 'text/plain', false ), + ); + } + + /** + * @dataProvider dataXmlMimeType + * @covers Html::isXmlMimeType + */ + public function testXmlMimeType( $mimetype, $isXmlMimeType ) { + $this->assertEquals( $isXmlMimeType, Html::isXmlMimeType( $mimetype ) ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesSkipsNullAndFalse() { + + ### EMPTY ######## + $this->assertEmpty( + Html::expandAttributes( array( 'foo' => null ) ), + 'skip keys with null value' + ); + $this->assertEmpty( + Html::expandAttributes( array( 'foo' => false ) ), + 'skip keys with false value' + ); + $this->assertEquals( + ' foo=""', + Html::expandAttributes( array( 'foo' => '' ) ), + 'keep keys with an empty string' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForBooleans() { + $this->assertEquals( + '', + Html::expandAttributes( array( 'selected' => false ) ), + 'Boolean attributes do not generates output when value is false' + ); + $this->assertEquals( + '', + Html::expandAttributes( array( 'selected' => null ) ), + 'Boolean attributes do not generates output when value is null' + ); + + $this->assertEquals( + ' selected', + Html::expandAttributes( array( 'selected' => true ) ), + 'Boolean attributes have no value when value is true' + ); + $this->assertEquals( + ' selected', + Html::expandAttributes( array( 'selected' ) ), + 'Boolean attributes have no value when value is true (passed as numerical array)' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + ' selected=""', + Html::expandAttributes( array( 'selected' => true ) ), + 'Boolean attributes have empty string value when value is true (wgWellFormedXml)' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForNumbers() { + $this->assertEquals( + ' value=1', + Html::expandAttributes( array( 'value' => 1 ) ), + 'Integer value is cast to a string' + ); + $this->assertEquals( + ' value=1.1', + Html::expandAttributes( array( 'value' => 1.1 ) ), + 'Float value is cast to a string' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForObjects() { + $this->assertEquals( + ' value=stringValue', + Html::expandAttributes( array( 'value' => new HtmlTestValue() ) ), + 'Object value is converted to a string' + ); + } + + /** + * Test for Html::expandAttributes() + * Please note it output a string prefixed with a space! + * @covers Html::expandAttributes + */ + public function testExpandAttributesVariousExpansions() { + ### NOT EMPTY #### + $this->assertEquals( + ' empty_string=""', + Html::expandAttributes( array( 'empty_string' => '' ) ), + 'Empty string is always quoted' + ); + $this->assertEquals( + ' key=value', + Html::expandAttributes( array( 'key' => 'value' ) ), + 'Simple string value needs no quotes' + ); + $this->assertEquals( + ' one=1', + Html::expandAttributes( array( 'one' => 1 ) ), + 'Number 1 value needs no quotes' + ); + $this->assertEquals( + ' zero=0', + Html::expandAttributes( array( 'zero' => 0 ) ), + 'Number 0 value needs no quotes' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + ' empty_string=""', + Html::expandAttributes( array( 'empty_string' => '' ) ), + 'Attribute values are always quoted (wgWellFormedXml): Empty string' + ); + $this->assertEquals( + ' key="value"', + Html::expandAttributes( array( 'key' => 'value' ) ), + 'Attribute values are always quoted (wgWellFormedXml): Simple string' + ); + $this->assertEquals( + ' one="1"', + Html::expandAttributes( array( 'one' => 1 ) ), + 'Attribute values are always quoted (wgWellFormedXml): Number 1' + ); + $this->assertEquals( + ' zero="0"', + Html::expandAttributes( array( 'zero' => 0 ) ), + 'Attribute values are always quoted (wgWellFormedXml): Number 0' + ); + } + + /** + * Html::expandAttributes has special features for HTML + * attributes that use space separated lists and also + * allows arrays to be used as values. + * @covers Html::expandAttributes + */ + public function testExpandAttributesListValueAttributes() { + ### STRING VALUES + $this->assertEquals( + ' class="redundant spaces here"', + Html::expandAttributes( array( 'class' => ' redundant spaces here ' ) ), + 'Normalization should strip redundant spaces' + ); + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => 'foo bar foo bar bar' ) ), + 'Normalization should remove duplicates in string-lists' + ); + ### "EMPTY" ARRAY VALUES + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array() ) ), + 'Value with an empty array' + ); + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array( null, '', ' ', ' ' ) ) ), + 'Array with null, empty string and spaces' + ); + ### NON-EMPTY ARRAY VALUES + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => array( + 'foo', + 'bar', + 'foo', + 'bar', + 'bar', + ) ) ), + 'Normalization should remove duplicates in the array' + ); + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => array( + 'foo bar', + 'bar foo', + 'foo', + 'bar bar', + ) ) ), + 'Normalization should remove duplicates in string-lists in the array' + ); + } + + /** + * Test feature added by r96188, let pass attributes values as + * a PHP array. Restricted to class,rel, accesskey. + * @covers Html::expandAttributes + */ + public function testExpandAttributesSpaceSeparatedAttributesWithBoolean() { + $this->assertEquals( + ' class="booltrue one"', + Html::expandAttributes( array( 'class' => array( + 'booltrue' => true, + 'one' => 1, + + # Method use isset() internally, make sure we do discard + # attributes values which have been assigned well known values + 'emptystring' => '', + 'boolfalse' => false, + 'zero' => 0, + 'null' => null, + ) ) ) + ); + } + + /** + * How do we handle duplicate keys in HTML attributes expansion? + * We could pass a "class" the values: 'GREEN' and array( 'GREEN' => false ) + * The later will take precedence. + * + * Feature added by r96188 + * @covers Html::expandAttributes + */ + public function testValueIsAuthoritativeInSpaceSeparatedAttributesArrays() { + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array( + 'GREEN', + 'GREEN' => false, + 'GREEN', + ) ) ) + ); + } + + /** + * @covers Html::expandAttributes + * @expectedException MWException + */ + public function testExpandAttributes_ArrayOnNonListValueAttribute_ThrowsException() { + // Real-life test case found in the Popups extension (see Gerrit cf0fd64), + // when used with an outdated BetaFeatures extension (see Gerrit deda1e7) + Html::expandAttributes( array( + 'src' => array( + 'ltr' => 'ltr.svg', + 'rtl' => 'rtl.svg' + ) + ) ); + } + + /** + * @covers Html::namespaceSelector + */ + public function testNamespaceSelector() { + $this->assertEquals( + '', + Html::namespaceSelector(), + 'Basic namespace selector without custom options' + ); + + $this->assertEquals( + ' ' . + '', + Html::namespaceSelector( + array( 'selected' => '2', 'all' => 'all', 'label' => 'Select a namespace:' ), + array( 'name' => 'wpNamespace', 'id' => 'mw-test-namespace' ) + ), + 'Basic namespace selector with custom values' + ); + + $this->assertEquals( + ' ' . + '', + Html::namespaceSelector( + array( 'label' => 'Select a namespace:' ) + ), + 'Basic namespace selector with a custom label but no id attribtue for the ' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '' . "\n" . + '', + Html::namespaceSelector( + array( 'exclude' => array( 0, 1, 3, 100, 101 ) ) + ), + 'Namespace selector namespace filtering.' + ); + } + + public function testCanDisableANamespaces() { + $this->assertEquals( + '', + Html::namespaceSelector( array( + 'disable' => array( 0, 1, 2, 3, 4 ) + ) ), + 'Namespace selector namespace disabling' + ); + } + + /** + * @dataProvider provideHtml5InputTypes + * @covers Html::element + */ + public function testHtmlElementAcceptsNewHtml5TypesInHtml5Mode( $HTML5InputType ) { + $this->assertEquals( + '', + Html::element( 'input', array( 'type' => $HTML5InputType ) ), + 'In HTML5, HTML::element() should accept type="' . $HTML5InputType . '"' + ); + } + + /** + * List of input element types values introduced by HTML5 + * Full list at http://www.w3.org/TR/html-markup/input.html + */ + public static function provideHtml5InputTypes() { + $types = array( + 'datetime', + 'datetime-local', + 'date', + 'month', + 'time', + 'week', + 'number', + 'range', + 'email', + 'url', + 'search', + 'tel', + 'color', + ); + $cases = array(); + foreach ( $types as $type ) { + $cases[] = array( $type ); + } + + return $cases; + } + + /** + * Test out Html::element drops or enforces default value + * @covers Html::dropDefaults + * @dataProvider provideElementsWithAttributesHavingDefaultValues + */ + public function testDropDefaults( $expected, $element, $attribs, $message = '' ) { + $this->assertEquals( $expected, Html::element( $element, $attribs ), $message ); + } + + public static function provideElementsWithAttributesHavingDefaultValues() { + # Use cases in a concise format: + # , , [, ] + # Will be mapped to Html::element() + $cases = array(); + + ### Generic cases, match $attribDefault static array + $cases[] = array( '', + 'area', array( 'shape' => 'rect' ) + ); + + $cases[] = array( '', + 'button', array( 'formaction' => 'GET' ) + ); + $cases[] = array( '', + 'button', array( 'formenctype' => 'application/x-www-form-urlencoded' ) + ); + + $cases[] = array( '', + 'canvas', array( 'height' => '150' ) + ); + $cases[] = array( '', + 'canvas', array( 'width' => '300' ) + ); + # Also check with numeric values + $cases[] = array( '', + 'canvas', array( 'height' => 150 ) + ); + $cases[] = array( '', + 'canvas', array( 'width' => 300 ) + ); + + $cases[] = array( '', + 'command', array( 'type' => 'command' ) + ); + + $cases[] = array( '
', + 'form', array( 'action' => 'GET' ) + ); + $cases[] = array( '
', + 'form', array( 'autocomplete' => 'on' ) + ); + $cases[] = array( '
', + 'form', array( 'enctype' => 'application/x-www-form-urlencoded' ) + ); + + $cases[] = array( '', + 'input', array( 'formaction' => 'GET' ) + ); + $cases[] = array( '', + 'input', array( 'type' => 'text' ) + ); + + $cases[] = array( '', + 'keygen', array( 'keytype' => 'rsa' ) + ); + + $cases[] = array( '', + 'link', array( 'media' => 'all' ) + ); + + $cases[] = array( '', + 'menu', array( 'type' => 'list' ) + ); + + $cases[] = array( '', + 'script', array( 'type' => 'text/javascript' ) + ); + + $cases[] = array( '', + 'style', array( 'media' => 'all' ) + ); + $cases[] = array( '', + 'style', array( 'type' => 'text/css' ) + ); + + $cases[] = array( '', + 'textarea', array( 'wrap' => 'soft' ) + ); + + ### SPECIFIC CASES + + # + $cases[] = array( '', + 'link', array( 'type' => 'text/css' ) + ); + + # specific handling + $cases[] = array( '', + 'input', array( 'type' => 'checkbox', 'value' => 'on' ), + 'Default value "on" is stripped of checkboxes', + ); + $cases[] = array( '', + 'input', array( 'type' => 'radio', 'value' => 'on' ), + 'Default value "on" is stripped of radio buttons', + ); + $cases[] = array( '', + 'input', array( 'type' => 'submit', 'value' => 'Submit' ), + 'Default value "Submit" is kept on submit buttons (for possible l10n issues)', + ); + $cases[] = array( '', + 'input', array( 'type' => 'color', 'value' => '' ), + ); + $cases[] = array( '', + 'input', array( 'type' => 'range', 'value' => '' ), + ); + + # ', + 'button', array( 'type' => 'submit' ), + 'According to standard the default type is "submit". ' + . 'Depending on compatibility mode IE might use "button", instead.', + ); + + # ', + 'select', array( 'size' => '4', 'multiple' => true ), + ); + # .. with numeric value + $cases[] = array( '', + 'select', array( 'size' => 4, 'multiple' => true ), + ); + $cases[] = array( '', + 'select', array( 'size' => '1', 'multiple' => false ), + ); + # .. with numeric value + $cases[] = array( '', + 'select', array( 'size' => 1, 'multiple' => false ), + ); + + # Passing an array as value + $cases[] = array( '', + 'a', array( 'class' => array( 'css-class-one', 'css-class-two' ) ), + "dropDefaults accepts values given as an array" + ); + + # FIXME: doDropDefault should remove defaults given in an array + # Expected should be '' + $cases[] = array( '', + 'a', array( 'class' => array( '', '' ) ), + "dropDefaults accepts values given as an array" + ); + + # Craft the Html elements + $ret = array(); + foreach ( $cases as $case ) { + $ret[] = array( + $case[0], + $case[1], $case[2], + isset( $case[3] ) ? $case[3] : '' + ); + } + + return $ret; + } + + /** + * @covers Html::expandAttributes + */ + public function testFormValidationBlacklist() { + $this->assertEmpty( + Html::expandAttributes( array( + 'min' => 1, + 'max' => 100, + 'pattern' => 'abc', + 'required' => true, + 'step' => 2 + ) ), + 'Blacklist form validation attributes.' + ); + $this->assertEquals( + ' step=any', + Html::expandAttributes( + array( + 'min' => 1, + 'max' => 100, + 'pattern' => 'abc', + 'required' => true, + 'step' => 'any' + ), + 'Allow special case "step=any".' + ) + ); + } + + public function testWrapperInput() { + $this->assertEquals( + '', + Html::input( 'testname', 'testval', 'radio' ), + 'Input wrapper with type and value.' + ); + $this->assertEquals( + '', + Html::input( 'testname' ), + 'Input wrapper with all default values.' + ); + } + + public function testWrapperCheck() { + $this->assertEquals( + '', + Html::check( 'testname' ), + 'Checkbox wrapper unchecked.' + ); + $this->assertEquals( + '', + Html::check( 'testname', true ), + 'Checkbox wrapper checked.' + ); + $this->assertEquals( + '', + Html::check( 'testname', false, array( 'value' => 'testval' ) ), + 'Checkbox wrapper with a value override.' + ); + } + + public function testWrapperRadio() { + $this->assertEquals( + '', + Html::radio( 'testname' ), + 'Radio wrapper unchecked.' + ); + $this->assertEquals( + '', + Html::radio( 'testname', true ), + 'Radio wrapper checked.' + ); + $this->assertEquals( + '', + Html::radio( 'testname', false, array( 'value' => 'testval' ) ), + 'Radio wrapper with a value override.' + ); + } + + public function testWrapperLabel() { + $this->assertEquals( + '', + Html::label( 'testlabel', 'testid' ), + 'Label wrapper' + ); + } +} + +class HtmlTestValue { + function __toString() { + return 'stringValue'; + } +} diff --git a/tests/phpunit/includes/HttpTest.php b/tests/phpunit/includes/HttpTest.php new file mode 100644 index 00000000..9b53381e --- /dev/null +++ b/tests/phpunit/includes/HttpTest.php @@ -0,0 +1,216 @@ +assertEquals( $expected, $ok, $msg ); + } + + public static function cookieDomains() { + return array( + array( false, "org" ), + array( false, ".org" ), + array( true, "wikipedia.org" ), + array( true, ".wikipedia.org" ), + array( false, "co.uk" ), + array( false, ".co.uk" ), + array( false, "gov.uk" ), + array( false, ".gov.uk" ), + array( true, "supermarket.uk" ), + array( false, "uk" ), + array( false, ".uk" ), + array( false, "127.0.0." ), + array( false, "127." ), + array( false, "127.0.0.1." ), + array( true, "127.0.0.1" ), + array( false, "333.0.0.1" ), + array( true, "example.com" ), + array( false, "example.com." ), + array( true, ".example.com" ), + + array( true, ".example.com", "www.example.com" ), + array( false, "example.com", "www.example.com" ), + array( true, "127.0.0.1", "127.0.0.1" ), + array( false, "127.0.0.1", "localhost" ), + ); + } + + /** + * Test Http::isValidURI() + * @bug 27854 : Http::isValidURI is too lax + * @dataProvider provideURI + * @covers Http::isValidURI + */ + public function testIsValidUri( $expect, $URI, $message = '' ) { + $this->assertEquals( + $expect, + (bool)Http::isValidURI( $URI ), + $message + ); + } + + /** + * Feeds URI to test a long regular expression in Http::isValidURI + */ + public static function provideURI() { + /** Format: 'boolean expectation', 'URI to test', 'Optional message' */ + return array( + array( false, '¿non sens before!! http://a', 'Allow anything before URI' ), + + # (http|https) - only two schemes allowed + array( true, 'http://www.example.org/' ), + array( true, 'https://www.example.org/' ), + array( true, 'http://www.example.org', 'URI without directory' ), + array( true, 'http://a', 'Short name' ), + array( true, 'http://étoile', 'Allow UTF-8 in hostname' ), # 'étoile' is french for 'star' + array( false, '\\host\directory', 'CIFS share' ), + array( false, 'gopher://host/dir', 'Reject gopher scheme' ), + array( false, 'telnet://host', 'Reject telnet scheme' ), + + # :\/\/ - double slashes + array( false, 'http//example.org', 'Reject missing colon in protocol' ), + array( false, 'http:/example.org', 'Reject missing slash in protocol' ), + array( false, 'http:example.org', 'Must have two slashes' ), + # Following fail since hostname can be made of anything + array( false, 'http:///example.org', 'Must have exactly two slashes, not three' ), + + # (\w+:{0,1}\w*@)? - optional user:pass + array( true, 'http://user@host', 'Username provided' ), + array( true, 'http://user:@host', 'Username provided, no password' ), + array( true, 'http://user:pass@host', 'Username and password provided' ), + + # (\S+) - host part is made of anything not whitespaces + array( false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ), + array( false, 'http://exam:ple.org/', 'hostname can not use colons!' ), + + # (:[0-9]+)? - port number + array( true, 'http://example.org:80/' ), + array( true, 'https://example.org:80/' ), + array( true, 'http://example.org:443/' ), + array( true, 'https://example.org:443/' ), + + # Part after the hostname is / or / with something else + array( true, 'http://example/#' ), + array( true, 'http://example/!' ), + array( true, 'http://example/:' ), + array( true, 'http://example/.' ), + array( true, 'http://example/?' ), + array( true, 'http://example/+' ), + array( true, 'http://example/=' ), + array( true, 'http://example/&' ), + array( true, 'http://example/%' ), + array( true, 'http://example/@' ), + array( true, 'http://example/-' ), + array( true, 'http://example//' ), + array( true, 'http://example/&' ), + + # Fragment + array( true, 'http://exam#ple.org', ), # This one is valid, really! + array( true, 'http://example.org:80#anchor' ), + array( true, 'http://example.org/?id#anchor' ), + array( true, 'http://example.org/?#anchor' ), + + array( false, 'http://a ¿non !!sens after', 'Allow anything after URI' ), + ); + } + + /** + * Warning: + * + * These tests are for code that makes use of an artifact of how CURL + * handles header reporting on redirect pages, and will need to be + * rewritten when bug 29232 is taken care of (high-level handling of + * HTTP redirects). + */ + public function testRelativeRedirections() { + $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext' ); + + # Forge a Location header + $h->setRespHeaders( 'location', array( + 'http://newsite/file.ext', + '/newfile.ext', + ) + ); + # Verify we correctly fix the Location + $this->assertEquals( + 'http://newsite/newfile.ext', + $h->getFinalUrl(), + "Relative file path Location: interpreted as full URL" + ); + + $h->setRespHeaders( 'location', array( + 'https://oldsite/file.ext' + ) + ); + $this->assertEquals( + 'https://oldsite/file.ext', + $h->getFinalUrl(), + "Location to the HTTPS version of the site" + ); + + $h->setRespHeaders( 'location', array( + '/anotherfile.ext', + 'http://anotherfile/hoster.ext', + 'https://anotherfile/hoster.ext' + ) + ); + $this->assertEquals( + 'https://anotherfile/hoster.ext', + $h->getFinalUrl( "Relative file path Location: should keep the latest host and scheme!" ) + ); + } +} + +/** + * Class to let us overwrite MWHttpRequest respHeaders variable + */ +class MWHttpRequestTester extends MWHttpRequest { + // function derived from the MWHttpRequest factory function but + // returns appropriate tester class here + public static function factory( $url, $options = null ) { + if ( !Http::$httpEngine ) { + Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php'; + } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { + throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' . + 'Http::$httpEngine is set to "curl"' ); + } + + switch ( Http::$httpEngine ) { + case 'curl': + return new CurlHttpRequestTester( $url, $options ); + case 'php': + if ( !wfIniGetBool( 'allow_url_fopen' ) ) { + throw new MWException( __METHOD__ . + ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' + . 'If possible, curl should be used instead. See http://php.net/curl.' ); + } + + return new PhpHttpRequestTester( $url, $options ); + default: + } + } +} + +class CurlHttpRequestTester extends CurlHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} + +class PhpHttpRequestTester extends PhpHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} diff --git a/tests/phpunit/includes/ImagePage404Test.php b/tests/phpunit/includes/ImagePage404Test.php new file mode 100644 index 00000000..197a2b32 --- /dev/null +++ b/tests/phpunit/includes/ImagePage404Test.php @@ -0,0 +1,53 @@ + true ); + } + + function setUp() { + $this->setMwGlobals( 'wgImageLimits', array( + array( 320, 240 ), + array( 640, 480 ), + array( 800, 600 ), + array( 1024, 768 ), + array( 1280, 1024 ) + ) ); + parent::setUp(); + } + + function getImagePage( $filename ) { + $title = Title::makeTitleSafe( NS_FILE, $filename ); + $file = $this->dataFile( $filename ); + $iPage = new ImagePage( $title ); + $iPage->setFile( $file ); + return $iPage; + } + + /** + * @dataProvider providerGetThumbSizes + * @param string $filename + * @param int $expectedNumberThumbs How many thumbnails to show + */ + function testGetThumbSizes( $filename, $expectedNumberThumbs ) { + $iPage = $this->getImagePage( $filename ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getThumbSizes' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, 545, 700 ); + $this->assertEquals( count( $actual ), $expectedNumberThumbs ); + } + + function providerGetThumbSizes() { + return array( + array( 'animated.gif', 6 ), + array( 'Toll_Texas_1.svg', 6 ), + array( '80x60-Greyscale.xcf', 6 ), + array( 'jpeg-comment-binary.jpg', 6 ), + ); + } +} diff --git a/tests/phpunit/includes/ImagePageTest.php b/tests/phpunit/includes/ImagePageTest.php new file mode 100644 index 00000000..3c255b5f --- /dev/null +++ b/tests/phpunit/includes/ImagePageTest.php @@ -0,0 +1,90 @@ +setMwGlobals( 'wgImageLimits', array( + array( 320, 240 ), + array( 640, 480 ), + array( 800, 600 ), + array( 1024, 768 ), + array( 1280, 1024 ) + ) ); + parent::setUp(); + } + + function getImagePage( $filename ) { + $title = Title::makeTitleSafe( NS_FILE, $filename ); + $file = $this->dataFile( $filename ); + $iPage = new ImagePage( $title ); + $iPage->setFile( $file ); + return $iPage; + } + + /** + * @dataProvider providerGetDisplayWidthHeight + * @param array $dim Array [maxWidth, maxHeight, width, height] + * @param array $expected Array [width, height] The width and height we expect to display at + */ + function testGetDisplayWidthHeight( $dim, $expected ) { + $iPage = $this->getImagePage( 'animated.gif' ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getDisplayWidthHeight' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, $dim[0], $dim[1], $dim[2], $dim[3] ); + $this->assertEquals( $actual, $expected ); + } + + function providerGetDisplayWidthHeight() { + return array( + array( + array( 1024.0, 768.0, 600.0, 600.0 ), + array( 600.0, 600.0 ) + ), + array( + array( 1024.0, 768.0, 1600.0, 600.0 ), + array( 1024.0, 384.0 ) + ), + array( + array( 1024.0, 768.0, 1024.0, 768.0 ), + array( 1024.0, 768.0 ) + ), + array( + array( 1024.0, 768.0, 800.0, 1000.0 ), + array( 614.0, 768.0 ) + ), + array( + array( 1024.0, 768.0, 0, 1000 ), + array( 0, 0 ) + ), + array( + array( 1024.0, 768.0, 2000, 0 ), + array( 0, 0 ) + ), + ); + } + + /** + * @dataProvider providerGetThumbSizes + * @param string $filename + * @param int $expectedNumberThumbs How many thumbnails to show + */ + function testGetThumbSizes( $filename, $expectedNumberThumbs ) { + $iPage = $this->getImagePage( $filename ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getThumbSizes' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, 545, 700 ); + $this->assertEquals( count( $actual ), $expectedNumberThumbs ); + } + + function providerGetThumbSizes() { + return array( + array( 'animated.gif', 2 ), + array( 'Toll_Texas_1.svg', 1 ), + array( '80x60-Greyscale.xcf', 1 ), + array( 'jpeg-comment-binary.jpg', 2 ), + ); + } +} diff --git a/tests/phpunit/includes/ImportTest.php b/tests/phpunit/includes/ImportTest.php new file mode 100644 index 00000000..2fce6bfb --- /dev/null +++ b/tests/phpunit/includes/ImportTest.php @@ -0,0 +1,101 @@ + + */ +class ImportTest extends MediaWikiLangTestCase { + + private function getInputStreamSource( $xml ) { + $file = 'data:application/xml,' . $xml; + $status = ImportStreamSource::newFromFile( $file ); + if ( !$status->isGood() ) { + throw new MWException( "Cannot create InputStreamSource." ); + } + return $status->value; + } + + /** + * @covers WikiImporter::handlePage + * @dataProvider getRedirectXML + * @param string $xml + * @param string|null $redirectTitle + */ + public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { + $source = $this->getInputStreamSource( $xml ); + + $redirect = null; + $callback = function ( $title, $origTitle, $revCount, $sRevCount, $pageInfo ) use ( &$redirect ) { + if ( array_key_exists( 'redirect', $pageInfo ) ) { + $redirect = $pageInfo['redirect']; + } + }; + + $importer = new WikiImporter( $source ); + $importer->setPageOutCallback( $callback ); + $importer->doImport(); + + $this->assertEquals( $redirectTitle, $redirect ); + } + + public function getRedirectXML() { + return array( + array( + <<< EOF + + + Test + 0 + 21 + + + 20 + 2014-05-27T10:00:00Z + + Admin + 10 + + Admin moved page [[Test]] to [[Test22]] + #REDIRECT [[Test22]] + tq456o9x3abm7r9ozi6km8yrbbc56o6 + wikitext + text/x-wiki + + + +EOF + , + 'Test22' + ), + array( + <<< EOF + + + Test + 0 + 42 + + 421 + 2014-05-27T11:00:00Z + + Admin + 10 + + Abcd + n7uomjq96szt60fy5w3x7ahf7q8m8rh + wikitext + text/x-wiki + + + +EOF + , + null + ), + ); + } + +} diff --git a/tests/phpunit/includes/LanguageConverterTest.php b/tests/phpunit/includes/LanguageConverterTest.php new file mode 100644 index 00000000..d4ccca99 --- /dev/null +++ b/tests/phpunit/includes/LanguageConverterTest.php @@ -0,0 +1,187 @@ +setMwGlobals( array( + 'wgContLang' => Language::factory( 'tg' ), + 'wgLanguageCode' => 'tg', + 'wgDefaultLanguageVariant' => false, + 'wgMemc' => new EmptyBagOStuff, + 'wgRequest' => new FauxRequest( array() ), + 'wgUser' => new User, + ) ); + + $this->lang = new LanguageToTest(); + $this->lc = new TestConverter( + $this->lang, 'tg', + array( 'tg', 'tg-latn' ) + ); + } + + protected function tearDown() { + unset( $this->lc ); + unset( $this->lang ); + + parent::tearDown(); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaults() { + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaders() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderWeight() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg;q=1' ); + + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderWeight2() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg-latn;q=1' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderMulti() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'en, tg-latn;q=1' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantUserOption() { + global $wgUser; + + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + */ + public function testGetPreferredVariantUserOptionForForeignLanguage() { + global $wgContLang, $wgUser; + + $wgContLang = Language::factory( 'en' ); + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant-tg', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantHeaderUserVsUrl() { + global $wgContLang, $wgRequest, $wgUser; + + $wgContLang = Language::factory( 'tg-latn' ); + $wgRequest->setVal( 'variant', 'tg' ); + $wgUser = User::newFromId( "admin" ); + $wgUser->setId( 1 ); + $wgUser->mFrom = 'defaults'; + $wgUser->mOptionsLoaded = true; + // The user's data is ignored because the variant is set in the URL. + $wgUser->setOption( 'variant', 'tg-latn' ); + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaultLanguageVariant() { + global $wgDefaultLanguageVariant; + + $wgDefaultLanguageVariant = 'tg-latn'; + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantDefaultLanguageVsUrlVariant() { + global $wgDefaultLanguageVariant, $wgRequest, $wgContLang; + + $wgContLang = Language::factory( 'tg-latn' ); + $wgDefaultLanguageVariant = 'tg'; + $wgRequest->setVal( 'variant', null ); + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } +} + +/** + * Test converter (from Tajiki to latin orthography) + */ +class TestConverter extends LanguageConverter { + private $table = array( + 'б' => 'b', + 'в' => 'v', + 'г' => 'g', + ); + + function loadDefaultTables() { + $this->mTables = array( + 'tg-latn' => new ReplacementArray( $this->table ), + 'tg' => new ReplacementArray() + ); + } +} + +class LanguageToTest extends Language { + function __construct() { + parent::__construct(); + $variants = array( 'tg', 'tg-latn' ); + $this->mConverter = new TestConverter( $this, 'tg', $variants ); + } +} diff --git a/tests/phpunit/includes/LicensesTest.php b/tests/phpunit/includes/LicensesTest.php new file mode 100644 index 00000000..63b2c395 --- /dev/null +++ b/tests/phpunit/includes/LicensesTest.php @@ -0,0 +1,25 @@ + 'FooField', + 'type' => 'select', + 'section' => 'description', + 'id' => 'wpLicense', + 'label' => 'A label text', # Note can't test label-message because $wgOut is not defined + 'name' => 'AnotherName', + 'licenses' => $str, + ) ); + $this->assertThat( $lc, $this->isInstanceOf( 'Licenses' ) ); + } +} diff --git a/tests/phpunit/includes/LinkFilterTest.php b/tests/phpunit/includes/LinkFilterTest.php new file mode 100644 index 00000000..f2c9cb43 --- /dev/null +++ b/tests/phpunit/includes/LinkFilterTest.php @@ -0,0 +1,274 @@ +setMwGlobals( 'wgUrlProtocols', array( + 'http://', + 'https://', + 'ftp://', + 'irc://', + 'ircs://', + 'gopher://', + 'telnet://', + 'nntp://', + 'worldwind://', + 'mailto:', + 'news:', + 'svn://', + 'git://', + 'mms://', + '//', + ) ); + + } + + /** + * createRegexFromLike($like) + * + * Takes an array as created by LinkFilter::makeLikeArray() and creates a regex from it + * + * @param array $like Array as created by LinkFilter::makeLikeArray() + * @return string Regex + */ + function createRegexFromLIKE( $like ) { + + $regex = '!^'; + + foreach ( $like as $item ) { + + if ( $item instanceof LikeMatch ) { + if ( $item->toString() == '%' ) { + $regex .= '.*'; + } elseif ( $item->toString() == '_' ) { + $regex .= '.'; + } + } else { + $regex .= preg_quote( $item, '!' ); + } + + } + + $regex .= '$!'; + + return $regex; + + } + + /** + * provideValidPatterns() + * + * @return array + */ + public static function provideValidPatterns() { + + return array( + // Protocol, Search pattern, URL which matches the pattern + array( 'http://', '*.test.com', 'http://www.test.com' ), + array( 'http://', 'test.com:8080/dir/file', 'http://name:pass@test.com:8080/dir/file' ), + array( 'https://', '*.com', 'https://s.s.test..com:88/dir/file?a=1&b=2' ), + array( 'https://', '*.com', 'https://name:pass@secure.com/index.html' ), + array( 'http://', 'name:pass@test.com', 'http://test.com' ), + array( 'http://', 'test.com', 'http://name:pass@test.com' ), + array( 'http://', '*.test.com', 'http://a.b.c.test.com/dir/dir/file?a=6'), + array( null, 'http://*.test.com', 'http://www.test.com' ), + array( 'mailto:', 'name@mail.test123.com', 'mailto:name@mail.test123.com' ), + array( '', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' + ), + array( '', 'http://name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( '', 'http://name:wrongpass@*.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( 'http://', 'name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( '', 'http://name:pass@www.test.com:12345', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( 'ftp://', 'user:pass@ftp.test.com:1233/home/user/file;type=efw', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://otheruser:otherpass@ftp.test.com:1233/home/user/file;type=', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://@ftp.test.com:1233/home/user/file;type=', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://ftp.test.com/', + 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ), + array( null, 'ftp://ftp.test.com/', + 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ), + array( null, 'ftp://*.test.com:222/', + 'ftp://user:pass@ftp.test.com:222/home' ), + array( 'irc://', '*.myserver:6667/', 'irc://test.myserver:6667/' ), + array( 'irc://', 'name:pass@*.myserver/', 'irc://test.myserver:6667/' ), + array( 'irc://', 'name:pass@*.myserver/', 'irc://other:@test.myserver:6667/' ), + array( '', 'irc://test/name,string,abc?msg=t', 'irc://test/name,string,abc?msg=test' ), + array( '', 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ), + array( '', 'https://gerrit.wikimedia.org', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ), + array( 'mailto:', '*.test.com', 'mailto:name@pop3.test.com' ), + array( 'mailto:', 'test.com', 'mailto:name@test.com' ), + array( 'news:', 'test.1234afc@news.test.com', 'news:test.1234afc@news.test.com' ), + array( 'news:', '*.test.com', 'news:test.1234afc@news.test.com' ), + array( '', 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com', + 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ), + array( '', 'news:*.aol.com', + 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ), + array( '', 'git://github.com/prwef/abc-def.git', 'git://github.com/prwef/abc-def.git' ), + array( 'git://', 'github.com/', 'git://github.com/prwef/abc-def.git' ), + array( 'git://', '*.github.com/', 'git://a.b.c.d.e.f.github.com/prwef/abc-def.git' ), + array( '', 'gopher://*.test.com/', 'gopher://gopher.test.com/0/v2/vstat'), + array( 'telnet://', '*.test.com', 'telnet://shell.test.com/~home/'), + + // + // The following only work in PHP >= 5.3.7, due to a bug in parse_url which eats + // the path from the url (https://bugs.php.net/bug.php?id=54180) + // + // array( '', 'http://test.com', 'http://test.com/index?arg=1' ), + // array( 'http://', '*.test.com', 'http://www.test.com/index?arg=1' ), + // array( '' , + // 'http://xx23124:__ffdfdef__@www.test.com:12345/dir' , + // 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' + // ), + // + + // + // Tests for false positives + // + array( 'http://', 'test.com', 'http://www.test.com', false ), + array( 'http://', 'www1.test.com', 'http://www.test.com', false ), + array( 'http://', '*.test.com', 'http://www.test.t.com', false ), + array( '', 'http://test.com:8080', 'http://www.test.com:8080', false ), + array( '', 'https://test.com', 'http://test.com', false ), + array( '', 'http://test.com', 'https://test.com', false ), + array( 'http://', 'http://test.com', 'http://test.com', false ), + array( null, 'http://www.test.com', 'http://www.test.com:80', false ), + array( null, 'http://www.test.com:80', 'http://www.test.com', false ), + array( null, 'http://*.test.com:80', 'http://www.test.com', false ), + array( '', 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', false ), + array( '', 'https://*.wikimedia.org/r/#/q/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', false ), + array( 'mailto:', '@test.com', '@abc.test.com', false ), + array( 'mailto:', 'mail@test.com', 'mail2@test.com', false ), + array( '', 'mailto:mail@test.com', 'mail2@test.com', false ), + array( '', 'mailto:@test.com', '@abc.test.com', false ), + array( 'ftp://', '*.co', 'ftp://www.co.uk', false ), + array( 'ftp://', '*.co', 'ftp://www.co.m', false ), + array( 'ftp://', '*.co/dir/', 'ftp://www.co/dir2/', false ), + array( 'ftp://', 'www.co/dir/', 'ftp://www.co/dir2/', false ), + array( 'ftp://', 'test.com/dir/', 'ftp://test.com/', false ), + array( '', 'http://test.com:8080/dir/', 'http://test.com:808/dir/', false ), + array( '', 'http://test.com/dir/index.html', 'http://test.com/dir/index.php', false ), + + // + // These are false positives too and ideally shouldn't match, but that + // would require using regexes and RLIKE instead of LIKE + // + // array( null, 'http://*.test.com', 'http://www.test.com:80', false ), + // array( '', 'https://*.wikimedia.org/r/#/q/status:open,n,z', + // 'https://gerrit.wikimedia.org/XXX/r/#/q/status:open,n,z', false ), + ); + + } + + /** + * testMakeLikeArrayWithValidPatterns() + * + * Tests whether the LIKE clause produced by LinkFilter::makeLikeArray($pattern, $protocol) + * will find one of the URL indexes produced by wfMakeUrlIndexes($url) + * + * @dataProvider provideValidPatterns + * + * @param string $protocol Protocol, e.g. 'http://' or 'mailto:' + * @param string $pattern Search pattern to feed to LinkFilter::makeLikeArray + * @param string $url URL to feed to wfMakeUrlIndexes + * @param bool $shouldBeFound Should the URL be found? (defaults true) + */ + function testMakeLikeArrayWithValidPatterns( $protocol, $pattern, $url, $shouldBeFound = true ) { + + $indexes = wfMakeUrlIndexes( $url ); + $likeArray = LinkFilter::makeLikeArray( $pattern, $protocol ); + + $this->assertTrue( $likeArray !== false, + "LinkFilter::makeLikeArray('$pattern', '$protocol') returned false on a valid pattern" + ); + + $regex = $this->createRegexFromLIKE( $likeArray ); + $debugmsg = "Regex: '" . $regex . "'\n"; + $debugmsg .= count( $indexes ) . " index(es) created by wfMakeUrlIndexes():\n"; + + $matches = 0; + + foreach ( $indexes as $index ) { + $matches += preg_match( $regex, $index ); + $debugmsg .= "\t'$index'\n"; + } + + if ( $shouldBeFound ) { + $this->assertTrue( + $matches > 0, + "Search pattern '$protocol$pattern' does not find url '$url' \n$debugmsg" + ); + } else { + $this->assertFalse( + $matches > 0, + "Search pattern '$protocol$pattern' should not find url '$url' \n$debugmsg" + ); + } + + } + + /** + * provideInvalidPatterns() + * + * @return array + */ + public static function provideInvalidPatterns() { + + return array( + array( '' ), + array( '*' ), + array( 'http://*' ), + array( 'http://*/' ), + array( 'http://*/dir/file' ), + array( 'test.*.com' ), + array( 'http://test.*.com' ), + array( 'test.*.com' ), + array( 'http://*.test.*' ), + array( 'http://*test.com' ), + array( 'https://*' ), + array( '*://test.com'), + array( 'mailto:name:pass@t*est.com' ), + array( 'http://*:888/'), + array( '*http://'), + array( 'test.com/*/index' ), + array( 'test.com/dir/index?arg=*' ), + ); + + } + + /** + * testMakeLikeArrayWithInvalidPatterns() + * + * Tests whether LinkFilter::makeLikeArray($pattern) will reject invalid search patterns + * + * @dataProvider provideInvalidPatterns + * + * @param string $pattern Invalid search pattern + */ + function testMakeLikeArrayWithInvalidPatterns( $pattern ) { + + $this->assertFalse( + LinkFilter::makeLikeArray( $pattern ), + "'$pattern' is not a valid pattern and should be rejected" + ); + + } + +} diff --git a/tests/phpunit/includes/LinkerTest.php b/tests/phpunit/includes/LinkerTest.php new file mode 100644 index 00000000..7b84107e --- /dev/null +++ b/tests/phpunit/includes/LinkerTest.php @@ -0,0 +1,192 @@ +setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1', + 'wgWellFormedXml' => true, + ) ); + + $this->assertEquals( $expected, + Linker::userLink( $userId, $userName, $altUserName, $msg ) + ); + } + + public static function provideCasesForUserLink() { + # Format: + # - expected + # - userid + # - username + # - optional altUserName + # - optional message + return array( + + ### ANONYMOUS USER ######################################## + array( + 'JohnDoe', + 0, 'JohnDoe', false, + ), + array( + '::1', + 0, '::1', false, + 'Anonymous with pretty IPv6' + ), + array( + '::1', + 0, '0:0:0:0:0:0:0:1', false, + 'Anonymous with almost pretty IPv6' + ), + array( + '::1', + 0, '0000:0000:0000:0000:0000:0000:0000:0001', false, + 'Anonymous with full IPv6' + ), + array( + 'AlternativeUsername', + 0, '::1', 'AlternativeUsername', + 'Anonymous with pretty IPv6 and an alternative username' + ), + + # IPV4 + array( + '127.0.0.1', + 0, '127.0.0.1', false, + 'Anonymous with IPv4' + ), + array( + 'AlternativeUsername', + 0, '127.0.0.1', 'AlternativeUsername', + 'Anonymous with IPv4 and an alternative username' + ), + + ### Regular user ########################################## + # TODO! + ); + } + + /** + * @dataProvider provideCasesForFormatComment + * @covers Linker::formatComment + * @covers Linker::formatAutocomments + * @covers Linker::formatLinksInComment + */ + public function testFormatComment( $expected, $comment, $title = false, $local = false ) { + $this->setMwGlobals( array( + 'wgScript' => '/wiki/index.php', + 'wgArticlePath' => '/wiki/$1', + 'wgWellFormedXml' => true, + 'wgCapitalLinks' => true, + ) ); + + if ( $title === false ) { + // We need a page title that exists + $title = Title::newFromText( 'Special:BlankPage' ); + } + + $this->assertEquals( + $expected, + Linker::formatComment( $comment, $title, $local ) + ); + } + + public static function provideCasesForFormatComment() { + return array( + // Linker::formatComment + array( + 'a<script>b', + 'a +' + ), + array( + // Don't condition wrap raw modules (like the startup module) + array( 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ), + ' +' + ), + // Load module styles only + // This also tests the order the modules are put into the url + array( + array( array( 'test.baz', 'test.foo', 'test.bar' ), ResourceLoaderModule::TYPE_STYLES ), + ' +' + ), + // Load private module (only=scripts) + array( + array( 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ), + ' +' + ), + // Load private module (combined) + array( + array( 'test.quux', ResourceLoaderModule::TYPE_COMBINED ), + ' +' + ), + // Load module script with with ESI + array( + array( 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS, true ), + ' +' + ), + // Load module styles with with ESI + array( + array( 'test.foo', ResourceLoaderModule::TYPE_STYLES, true ), + ' +', + ), + // Load no modules + array( + array( array(), ResourceLoaderModule::TYPE_COMBINED ), + '', + ), + // noscript group + array( + array( 'test.noscript', ResourceLoaderModule::TYPE_STYLES ), + ' +' + ), + // Load two modules in separate groups + array( + array( array( 'test.group.foo', 'test.group.bar' ), ResourceLoaderModule::TYPE_COMBINED ), + ' + +', + ), + ); + } + + /** + * @dataProvider provideMakeResourceLoaderLink + * @covers OutputPage::makeResourceLoaderLink + */ + public function testMakeResourceLoaderLink( $args, $expectedHtml ) { + $this->setMwGlobals( array( + 'wgResourceLoaderDebug' => false, + 'wgResourceLoaderUseESI' => true, + 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php', + // Affects whether CDATA is inserted + 'wgWellFormedXml' => false, + ) ); + $class = new ReflectionClass( 'OutputPage' ); + $method = $class->getMethod( 'makeResourceLoaderLink' ); + $method->setAccessible( true ); + $ctx = new RequestContext(); + $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) ); + $ctx->setLanguage( 'en' ); + $out = new OutputPage( $ctx ); + $rl = $out->getResourceLoader(); + $rl->register( array( + 'test.foo' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.foo( { a: true } );', + 'styles' => '.mw-test-foo { content: "style"; }', + )), + 'test.bar' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.bar( { a: true } );', + 'styles' => '.mw-test-bar { content: "style"; }', + )), + 'test.baz' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { a: true } );', + 'styles' => '.mw-test-baz { content: "style"; }', + )), + 'test.quux' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { token: 123 } );', + 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }', + 'group' => 'private', + )), + 'test.raw' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { token: 123 } );', + 'isRaw' => true, + )), + 'test.noscript' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-test-noscript { content: "style"; }', + 'group' => 'noscript', + )), + 'test.group.bar' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-group-bar { content: "style"; }', + 'group' => 'bar', + )), + 'test.group.foo' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-group-foo { content: "style"; }', + 'group' => 'foo', + )), + ) ); + $links = $method->invokeArgs( $out, $args ); + // Strip comments to avoid variation due to wgDBname in WikiID and cache key + $actualHtml = preg_replace( '#/\*[^*]+\*/#', '', $links['html'] ); + $this->assertEquals( $expectedHtml, $actualHtml ); + } +} diff --git a/tests/phpunit/includes/PasswordTest.php b/tests/phpunit/includes/PasswordTest.php new file mode 100644 index 00000000..ceb794b5 --- /dev/null +++ b/tests/phpunit/includes/PasswordTest.php @@ -0,0 +1,33 @@ +newFromCiphertext( null ); + $invalid2 = User::getPasswordFactory()->newFromCiphertext( null ); + + $this->assertFalse( $invalid1->equals( $invalid2 ) ); + } +} diff --git a/tests/phpunit/includes/PathRouterTest.php b/tests/phpunit/includes/PathRouterTest.php new file mode 100644 index 00000000..0d782687 --- /dev/null +++ b/tests/phpunit/includes/PathRouterTest.php @@ -0,0 +1,264 @@ +add( "/wiki/$1" ); + $this->basicRouter = $router; + } + + /** + * Test basic path parsing + */ + public function testBasic() { + $matches = $this->basicRouter->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test loose path auto-$1 + */ + public function testLoose() { + $router = new PathRouter; + $router->add( "/" ); # Should be the same as "/$1" + $matches = $router->parse( "/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/wiki" ); # Should be the same as /wiki/$1 + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/wiki/" ); # Should be the same as /wiki/$1 + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test to ensure that path is based on specifity, not order + */ + public function testOrder() { + $router = new PathRouter; + $router->add( "/$1" ); + $router->add( "/a/$1" ); + $router->add( "/b/$1" ); + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/b/$1" ); + $router->add( "/a/$1" ); + $router->add( "/$1" ); + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test the handling of key based arrays with a url parameter + */ + public function testKeyParameter() { + $router = new PathRouter; + $router->add( array( 'edit' => "/edit/$1" ), array( 'action' => '$key' ) ); + $matches = $router->parse( "/edit/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'action' => 'edit' ) ); + } + + /** + * Test the handling of $2 inside paths + */ + public function testAdditionalParameter() { + // Basic $2 + $router = new PathRouter; + $router->add( '/$2/$1', array( 'test' => '$2' ) ); + $matches = $router->parse( "/asdf/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test' => 'asdf' ) ); + } + + /** + * Test additional restricted value parameter + */ + public function testRestrictedValue() { + $router = new PathRouter; + $router->add( '/$2/$1', + array( 'test' => '$2' ), + array( '$2' => array( 'a', 'b' ) ) + ); + $router->add( '/$2/$1', + array( 'test2' => '$2' ), + array( '$2' => 'c' ) + ); + $router->add( '/$1' ); + + $matches = $router->parse( "/asdf/Foo" ); + $this->assertEquals( $matches, array( 'title' => "asdf/Foo" ) ); + + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test' => 'a' ) ); + + $matches = $router->parse( "/c/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test2' => 'c' ) ); + } + + public function callbackForTest( &$matches, $data ) { + $matches['x'] = $data['$1']; + $matches['foo'] = $data['foo']; + } + + public function testCallback() { + $router = new PathRouter; + $router->add( "/$1", + array( 'a' => 'b', 'data:foo' => 'bar' ), + array( 'callback' => array( $this, 'callbackForTest' ) ) + ); + $matches = $router->parse( '/Foo' ); + $this->assertEquals( $matches, array( + 'title' => "Foo", + 'x' => 'Foo', + 'a' => 'b', + 'foo' => 'bar' + ) ); + } + + /** + * Test to ensure that matches are not made if a parameter expects nonexistent input + */ + public function testFail() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => "$1$2" ) ); + $matches = $router->parse( "/wiki/A" ); + $this->assertEquals( array(), $matches ); + } + + /** + * Test to ensure weight of paths is handled correctly + */ + public function testWeight() { + $router = new PathRouter; + $router->addStrict( "/Bar", array( 'ping' => 'pong' ) ); + $router->add( "/asdf-$1", array( 'title' => 'qwerty-$1' ) ); + $router->add( "/$1" ); + $router->add( "/qwerty-$1", array( 'title' => 'asdf-$1' ) ); + $router->addStrict( "/Baz", array( 'marco' => 'polo' ) ); + $router->add( "/a/$1" ); + $router->add( "/asdf/$1" ); + $router->add( "/$2/$1", array( 'unrestricted' => '$2' ) ); + $router->add( array( 'qwerty' => "/qwerty/$1" ), array( 'qwerty' => '$key' ) ); + $router->add( "/$2/$1", array( 'restricted-to-y' => '$2' ), array( '$2' => 'y' ) ); + + foreach ( + array( + '/Foo' => array( 'title' => 'Foo' ), + '/Bar' => array( 'ping' => 'pong' ), + '/Baz' => array( 'marco' => 'polo' ), + '/asdf-foo' => array( 'title' => 'qwerty-foo' ), + '/qwerty-bar' => array( 'title' => 'asdf-bar' ), + '/a/Foo' => array( 'title' => 'Foo' ), + '/asdf/Foo' => array( 'title' => 'Foo' ), + '/qwerty/Foo' => array( 'title' => 'Foo', 'qwerty' => 'qwerty' ), + '/baz/Foo' => array( 'title' => 'Foo', 'unrestricted' => 'baz' ), + '/y/Foo' => array( 'title' => 'Foo', 'restricted-to-y' => 'y' ), + ) as $path => $result + ) { + $this->assertEquals( $router->parse( $path ), $result ); + } + } + + /** + * Make sure the router handles titles like Special:Recentchanges correctly + */ + public function testSpecial() { + $matches = $this->basicRouter->parse( "/wiki/Special:Recentchanges" ); + $this->assertEquals( $matches, array( 'title' => "Special:Recentchanges" ) ); + } + + /** + * Make sure the router decodes urlencoding properly + */ + public function testUrlencoding() { + $matches = $this->basicRouter->parse( "/wiki/Title_With%20Space" ); + $this->assertEquals( $matches, array( 'title' => "Title_With Space" ) ); + } + + public static function provideRegexpChars() { + return array( + array( "$" ), + array( "$1" ), + array( "\\" ), + array( "\\$1" ), + ); + } + + /** + * Make sure the router doesn't break on special characters like $ used in regexp replacements + * @dataProvider provideRegexpChars + */ + public function testRegexpChars( $char ) { + $matches = $this->basicRouter->parse( "/wiki/$char" ); + $this->assertEquals( $matches, array( 'title' => "$char" ) ); + } + + /** + * Make sure the router handles characters like +&() properly + */ + public function testCharacters() { + $matches = $this->basicRouter->parse( "/wiki/Plus+And&Dollar\\Stuff();[]{}*" ); + $this->assertEquals( $matches, array( 'title' => "Plus+And&Dollar\\Stuff();[]{}*" ) ); + } + + /** + * Make sure the router handles unicode characters correctly + * @depends testSpecial + * @depends testUrlencoding + * @depends testCharacters + */ + public function testUnicode() { + $matches = $this->basicRouter->parse( "/wiki/Spécial:Modifications_récentes" ); + $this->assertEquals( $matches, array( 'title' => "Spécial:Modifications_récentes" ) ); + + $matches = $this->basicRouter->parse( "/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes" ); + $this->assertEquals( $matches, array( 'title' => "Spécial:Modifications_récentes" ) ); + } + + /** + * Ensure the router doesn't choke on long paths. + */ + public function testLength() { + // @codingStandardsIgnoreStart Ignore long line warnings + $matches = $this->basicRouter->parse( "/wiki/Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum." ); + $this->assertEquals( $matches, array( 'title' => "Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum." ) ); + // @codingStandardsIgnoreEnd + } + + /** + * Ensure that the php passed site of parameter values are not urldecoded + */ + public function testPatternUrlencoding() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => '%20:$1' ) ); + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => '%20:Foo' ) ); + } + + /** + * Ensure that raw parameter values do not have any variable replacements or urldecoding + */ + public function testRawParamValue() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => array( 'value' => 'bar%20$1' ) ) ); + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => 'bar%20$1' ) ); + } +} diff --git a/tests/phpunit/includes/PreferencesTest.php b/tests/phpunit/includes/PreferencesTest.php new file mode 100644 index 00000000..5841bb6f --- /dev/null +++ b/tests/phpunit/includes/PreferencesTest.php @@ -0,0 +1,91 @@ +prefUsers['noemail'] = new User; + + $this->prefUsers['notauth'] = new User; + $this->prefUsers['notauth'] + ->setEmail( 'noauth@example.org' ); + + $this->prefUsers['auth'] = new User; + $this->prefUsers['auth'] + ->setEmail( 'noauth@example.org' ); + $this->prefUsers['auth'] + ->setEmailAuthenticationTimestamp( 1330946623 ); + + $this->context = new RequestContext; + $this->context->setTitle( Title::newFromText( 'PreferencesTest' ) ); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableEmail' => true, + 'wgEmailAuthentication' => true, + ) ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserHasNoEmail() { + $prefs = $this->prefsFor( 'noemail' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-none', $prefs['emailaddress']['cssclass'] ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserEmailNotAuthenticated() { + $prefs = $this->prefsFor( 'notauth' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-not-authenticated', $prefs['emailaddress']['cssclass'] ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserEmailIsAuthenticated() { + $prefs = $this->prefsFor( 'auth' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-authenticated', $prefs['emailaddress']['cssclass'] ); + } + + /** Helper */ + protected function prefsFor( $user_key ) { + $preferences = array(); + Preferences::profilePreferences( + $this->prefUsers[$user_key], + $this->context, + $preferences + ); + + return $preferences; + } +} diff --git a/tests/phpunit/includes/RequestContextTest.php b/tests/phpunit/includes/RequestContextTest.php new file mode 100644 index 00000000..cae0e52e --- /dev/null +++ b/tests/phpunit/includes/RequestContextTest.php @@ -0,0 +1,96 @@ +setTitle( $curTitle ); + $this->assertTrue( $curTitle->equals( $context->getWikiPage()->getTitle() ), + "When a title is first set WikiPage should be created on-demand for that title." ); + + $curTitle = Title::newFromText( "B" ); + $context->setWikiPage( WikiPage::factory( $curTitle ) ); + $this->assertTrue( $curTitle->equals( $context->getTitle() ), + "Title must be updated when a new WikiPage is provided." ); + + $curTitle = Title::newFromText( "C" ); + $context->setTitle( $curTitle ); + $this->assertTrue( + $curTitle->equals( $context->getWikiPage()->getTitle() ), + "When a title is updated the WikiPage should be purged " + . "and recreated on-demand with the new title." + ); + } + + /** + * @covers RequestContext::importScopedSession + */ + public function testImportScopedSession() { + $context = RequestContext::getMain(); + + $oInfo = $context->exportSession(); + $this->assertEquals( '127.0.0.1', $oInfo['ip'], "Correct initial IP address." ); + $this->assertEquals( 0, $oInfo['userId'], "Correct initial user ID." ); + + $user = User::newFromName( 'UnitTestContextUser' ); + $user->addToDatabase(); + + $sinfo = array( + 'sessionId' => 'd612ee607c87e749ef14da4983a702cd', + 'userId' => $user->getId(), + 'ip' => '192.0.2.0', + 'headers' => array( + 'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0' + ) + ); + // importScopedSession() sets these variables + $this->setMwGlobals( array( + 'wgUser' => new User, + 'wgRequest' => new FauxRequest, + ) ); + $sc = RequestContext::importScopedSession( $sinfo ); // load new context + + $info = $context->exportSession(); + $this->assertEquals( $sinfo['ip'], $info['ip'], "Correct IP address." ); + $this->assertEquals( $sinfo['headers'], $info['headers'], "Correct headers." ); + $this->assertEquals( $sinfo['sessionId'], $info['sessionId'], "Correct session ID." ); + $this->assertEquals( $sinfo['userId'], $info['userId'], "Correct user ID." ); + $this->assertEquals( + $sinfo['ip'], + $context->getRequest()->getIP(), + "Correct context IP address." + ); + $this->assertEquals( + $sinfo['headers'], + $context->getRequest()->getAllHeaders(), + "Correct context headers." + ); + $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." ); + $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." ); + $this->assertEquals( + 'UnitTestContextUser', + $context->getUser()->getName(), + "Correct context user name." + ); + + unset( $sc ); // restore previous context + + $info = $context->exportSession(); + $this->assertEquals( $oInfo['ip'], $info['ip'], "Correct initial IP address." ); + $this->assertEquals( $oInfo['headers'], $info['headers'], "Correct initial headers." ); + $this->assertEquals( $oInfo['sessionId'], $info['sessionId'], "Correct initial session ID." ); + $this->assertEquals( $oInfo['userId'], $info['userId'], "Correct initial user ID." ); + } +} diff --git a/tests/phpunit/includes/RevisionStorageTest.php b/tests/phpunit/includes/RevisionStorageTest.php new file mode 100644 index 00000000..9a429bcb --- /dev/null +++ b/tests/phpunit/includes/RevisionStorageTest.php @@ -0,0 +1,574 @@ +tablesUsed = array_merge( $this->tablesUsed, + array( 'page', + 'revision', + 'text', + + 'recentchanges', + 'logging', + + 'page_props', + 'pagelinks', + 'categorylinks', + 'langlinks', + 'externallinks', + 'imagelinks', + 'templatelinks', + 'iwlinks' ) ); + } + + protected function setUp() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::setUp(); + + $wgExtraNamespaces[12312] = 'Dummy'; + $wgExtraNamespaces[12313] = 'Dummy_talk'; + + $wgNamespaceContentModels[12312] = 'DUMMY'; + $wgContentHandlers['DUMMY'] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + if ( !$this->the_page ) { + $this->the_page = $this->createPage( + 'RevisionStorageTest_the_page', + "just a dummy page", + CONTENT_MODEL_WIKITEXT + ); + } + } + + protected function tearDown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::tearDown(); + + unset( $wgExtraNamespaces[12312] ); + unset( $wgExtraNamespaces[12313] ); + + unset( $wgNamespaceContentModels[12312] ); + unset( $wgContentHandlers['DUMMY'] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function makeRevision( $props = null ) { + if ( $props === null ) { + $props = array(); + } + + if ( !isset( $props['content'] ) && !isset( $props['text'] ) ) { + $props['text'] = 'Lorem Ipsum'; + } + + if ( !isset( $props['comment'] ) ) { + $props['comment'] = 'just a test'; + } + + if ( !isset( $props['page'] ) ) { + $props['page'] = $this->the_page->getId(); + } + + $rev = new Revision( $props ); + + $dbw = wfgetDB( DB_MASTER ); + $rev->insertOn( $dbw ); + + return $rev; + } + + protected function createPage( $page, $text, $model = null ) { + if ( is_string( $page ) ) { + if ( !preg_match( '/:/', $page ) && + ( $model === null || $model === CONTENT_MODEL_WIKITEXT ) + ) { + $ns = $this->getDefaultWikitextNS(); + $page = MWNamespace::getCanonicalName( $ns ) . ':' . $page; + } + + $page = Title::newFromText( $page ); + } + + if ( $page instanceof Title ) { + $page = new WikiPage( $page ); + } + + if ( $page->exists() ) { + $page->doDeleteArticle( "done" ); + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); + + return $page; + } + + protected function assertRevEquals( Revision $orig, Revision $rev = null ) { + $this->assertNotNull( $rev, 'missing revision' ); + + $this->assertEquals( $orig->getId(), $rev->getId() ); + $this->assertEquals( $orig->getPage(), $rev->getPage() ); + $this->assertEquals( $orig->getTimestamp(), $rev->getTimestamp() ); + $this->assertEquals( $orig->getUser(), $rev->getUser() ); + $this->assertEquals( $orig->getContentModel(), $rev->getContentModel() ); + $this->assertEquals( $orig->getContentFormat(), $rev->getContentFormat() ); + $this->assertEquals( $orig->getSha1(), $rev->getSha1() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructFromRow() { + $orig = $this->makeRevision(); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'revision', '*', array( 'rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = new Revision( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromRow + */ + public function testNewFromRow() { + $orig = $this->makeRevision(); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'revision', '*', array( 'rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = Revision::newFromRow( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromArchiveRow + */ + public function testNewFromArchiveRow() { + $page = $this->createPage( + 'RevisionStorageTest_testNewFromArchiveRow', + 'Lorem Ipsum', + CONTENT_MODEL_WIKITEXT + ); + $orig = $page->getRevision(); + $page->doDeleteArticle( 'test Revision::newFromArchiveRow' ); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'archive', '*', array( 'ar_rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = Revision::newFromArchiveRow( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromId + */ + public function testNewFromId() { + $orig = $this->makeRevision(); + + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::fetchRevision + */ + public function testFetchRevision() { + $page = $this->createPage( + 'RevisionStorageTest_testFetchRevision', + 'one', + CONTENT_MODEL_WIKITEXT + ); + + // Hidden process cache assertion below + $page->getRevision()->getId(); + + $page->doEditContent( new WikitextContent( 'two' ), 'second rev' ); + $id = $page->getRevision()->getId(); + + $res = Revision::fetchRevision( $page->getTitle() ); + + #note: order is unspecified + $rows = array(); + while ( ( $row = $res->fetchObject() ) ) { + $rows[$row->rev_id] = $row; + } + + $this->assertEquals( 1, count( $rows ), 'expected exactly one revision' ); + $this->assertArrayHasKey( $id, $rows, 'missing revision with id ' . $id ); + } + + /** + * @covers Revision::selectFields + */ + public function testSelectFields() { + global $wgContentHandlerUseDB; + + $fields = Revision::selectFields(); + + $this->assertTrue( in_array( 'rev_id', $fields ), 'missing rev_id in list of fields' ); + $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields' ); + $this->assertTrue( + in_array( 'rev_timestamp', $fields ), + 'missing rev_timestamp in list of fields' + ); + $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields' ); + + if ( $wgContentHandlerUseDB ) { + $this->assertTrue( in_array( 'rev_content_model', $fields ), + 'missing rev_content_model in list of fields' ); + $this->assertTrue( in_array( 'rev_content_format', $fields ), + 'missing rev_content_format in list of fields' ); + } + } + + /** + * @covers Revision::getPage + */ + public function testGetPage() { + $page = $this->the_page; + + $orig = $this->makeRevision( array( 'page' => $page->getId() ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( $page->getId(), $rev->getPage() ); + } + + /** + * @covers Revision::getText + */ + public function testGetText() { + $this->hideDeprecated( 'Revision::getText' ); + + $orig = $this->makeRevision( array( 'text' => 'hello hello.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello.', $rev->getText() ); + } + + /** + * @covers Revision::getContent + */ + public function testGetContent_failure() { + $rev = new Revision( array( + 'page' => $this->the_page->getId(), + 'content_model' => $this->the_page->getContentModel(), + 'text_id' => 123456789, // not in the test DB + ) ); + + $this->assertNull( $rev->getContent(), + "getContent() should return null if the revision's text blob could not be loaded." ); + + //NOTE: check this twice, once for lazy initialization, and once with the cached value. + $this->assertNull( $rev->getContent(), + "getContent() should return null if the revision's text blob could not be loaded." ); + } + + /** + * @covers Revision::getContent + */ + public function testGetContent() { + $orig = $this->makeRevision( array( 'text' => 'hello hello.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello.', $rev->getContent()->getNativeData() ); + } + + /** + * @covers Revision::getRawText + */ + public function testGetRawText() { + $this->hideDeprecated( 'Revision::getRawText' ); + + $orig = $this->makeRevision( array( 'text' => 'hello hello raw.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello raw.', $rev->getRawText() ); + } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $orig = $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $orig = $this->makeRevision( array( + 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT, + 'content_format' => CONTENT_FORMAT_JAVASCRIPT + ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_FORMAT_JAVASCRIPT, $rev->getContentFormat() ); + } + + /** + * @covers Revision::isCurrent + */ + public function testIsCurrent() { + $page = $this->createPage( + 'RevisionStorageTest_testIsCurrent', + 'Lorem Ipsum', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + # @todo find out if this should be true + # $this->assertTrue( $rev1->isCurrent() ); + + $rev1x = Revision::newFromId( $rev1->getId() ); + $this->assertTrue( $rev1x->isCurrent() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev' + ); + $rev2 = $page->getRevision(); + + # @todo find out if this should be true + # $this->assertTrue( $rev2->isCurrent() ); + + $rev1x = Revision::newFromId( $rev1->getId() ); + $this->assertFalse( $rev1x->isCurrent() ); + + $rev2x = Revision::newFromId( $rev2->getId() ); + $this->assertTrue( $rev2x->isCurrent() ); + } + + /** + * @covers Revision::getPrevious + */ + public function testGetPrevious() { + $page = $this->createPage( + 'RevisionStorageTest_testGetPrevious', + 'Lorem Ipsum testGetPrevious', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + $this->assertNull( $rev1->getPrevious() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev testGetPrevious' ); + $rev2 = $page->getRevision(); + + $this->assertNotNull( $rev2->getPrevious() ); + $this->assertEquals( $rev1->getId(), $rev2->getPrevious()->getId() ); + } + + /** + * @covers Revision::getNext + */ + public function testGetNext() { + $page = $this->createPage( + 'RevisionStorageTest_testGetNext', + 'Lorem Ipsum testGetNext', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + $this->assertNull( $rev1->getNext() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev testGetNext' + ); + $rev2 = $page->getRevision(); + + $this->assertNotNull( $rev1->getNext() ); + $this->assertEquals( $rev2->getId(), $rev1->getNext()->getId() ); + } + + /** + * @covers Revision::newNullRevision + */ + public function testNewNullRevision() { + $page = $this->createPage( + 'RevisionStorageTest_testNewNullRevision', + 'some testing text', + CONTENT_MODEL_WIKITEXT + ); + $orig = $page->getRevision(); + + $dbw = wfGetDB( DB_MASTER ); + $rev = Revision::newNullRevision( $dbw, $page->getId(), 'a null revision', false ); + + $this->assertNotEquals( $orig->getId(), $rev->getId(), + 'new null revision shold have a different id from the original revision' ); + $this->assertEquals( $orig->getTextId(), $rev->getTextId(), + 'new null revision shold have the same text id as the original revision' ); + $this->assertEquals( 'some testing text', $rev->getContent()->getNativeData() ); + } + + public static function provideUserWasLastToEdit() { + return array( + array( #0 + 3, true, # actually the last edit + ), + array( #1 + 2, true, # not the current edit, but still by this user + ), + array( #2 + 1, false, # edit by another user + ), + array( #3 + 0, false, # first edit, by this user, but another user edited in the mean time + ), + ); + } + + /** + * @dataProvider provideUserWasLastToEdit + */ + public function testUserWasLastToEdit( $sinceIdx, $expectedLast ) { + $userA = User::newFromName( "RevisionStorageTest_userA" ); + $userB = User::newFromName( "RevisionStorageTest_userB" ); + + if ( $userA->getId() === 0 ) { + $userA = User::createNew( $userA->getName() ); + } + + if ( $userB->getId() === 0 ) { + $userB = User::createNew( $userB->getName() ); + } + + $ns = $this->getDefaultWikitextNS(); + + $dbw = wfGetDB( DB_MASTER ); + $revisions = array(); + + // create revisions ----------------------------- + $page = WikiPage::factory( Title::newFromText( + 'RevisionStorageTest_testUserWasLastToEdit', $ns ) ); + $page->insertOn( $dbw ); + + # zero + $revisions[0] = new Revision( array( + 'page' => $page->getId(), + // we need the title to determine the page's default content model + 'title' => $page->getTitle(), + 'timestamp' => '20120101000000', + 'user' => $userA->getId(), + 'text' => 'zero', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit zero' + ) ); + $revisions[0]->insertOn( $dbw ); + + # one + $revisions[1] = new Revision( array( + 'page' => $page->getId(), + // still need the title, because $page->getId() is 0 (there's no entry in the page table) + 'title' => $page->getTitle(), + 'timestamp' => '20120101000100', + 'user' => $userA->getId(), + 'text' => 'one', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit one' + ) ); + $revisions[1]->insertOn( $dbw ); + + # two + $revisions[2] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000200', + 'user' => $userB->getId(), + 'text' => 'two', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit two' + ) ); + $revisions[2]->insertOn( $dbw ); + + # three + $revisions[3] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000300', + 'user' => $userA->getId(), + 'text' => 'three', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit three' + ) ); + $revisions[3]->insertOn( $dbw ); + + # four + $revisions[4] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000200', + 'user' => $userA->getId(), + 'text' => 'zero', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit four' + ) ); + $revisions[4]->insertOn( $dbw ); + + // test it --------------------------------- + $since = $revisions[$sinceIdx]->getTimestamp(); + + $wasLast = Revision::userWasLastToEdit( $dbw, $page->getId(), $userA->getId(), $since ); + + $this->assertEquals( $expectedLast, $wasLast ); + } +} diff --git a/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php b/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php new file mode 100644 index 00000000..d5e47c82 --- /dev/null +++ b/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php @@ -0,0 +1,89 @@ +setMwGlobals( 'wgContentHandlerUseDB', false ); + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + + parent::setUp(); + } + + /** + * @covers Revision::selectFields + */ + public function testSelectFields() { + $fields = Revision::selectFields(); + + $this->assertTrue( in_array( 'rev_id', $fields ), 'missing rev_id in list of fields' ); + $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields' ); + $this->assertTrue( + in_array( 'rev_timestamp', $fields ), + 'missing rev_timestamp in list of fields' + ); + $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields' ); + + $this->assertFalse( + in_array( 'rev_content_model', $fields ), + 'missing rev_content_model in list of fields' + ); + $this->assertFalse( + in_array( 'rev_content_format', $fields ), + 'missing rev_content_format in list of fields' + ); + } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() { + try { + $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + + $this->fail( "Creating JavaScript content on a wikitext page should fail with " + . "\$wgContentHandlerUseDB disabled" ); + } catch ( MWException $ex ) { + $this->assertTrue( true ); // ok + } + } + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() { + try { + // @todo change this to test failure on using a non-standard (but supported) format + // for a content model supported in the given location. As of 1.21, there are + // no alternative formats for any of the standard content models that could be + // used for this though. + + $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT, + 'content_format' => 'text/javascript' ) ); + + $this->fail( "Creating JavaScript content on a wikitext page should fail with " + . "\$wgContentHandlerUseDB disabled" ); + } catch ( MWException $ex ) { + $this->assertTrue( true ); // ok + } + } +} diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php new file mode 100644 index 00000000..4623b383 --- /dev/null +++ b/tests/phpunit/includes/RevisionTest.php @@ -0,0 +1,506 @@ +setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + 'wgLanguageCode' => 'en', + 'wgLegacyEncoding' => false, + 'wgCompressRevisions' => false, + + 'wgContentHandlerTextFallback' => 'ignore', + ) ); + + $this->mergeMwGlobalArrayValue( + 'wgExtraNamespaces', + array( + 12312 => 'Dummy', + 12313 => 'Dummy_talk', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgNamespaceContentModels', + array( + 12312 => 'testing', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgContentHandlers', + array( + 'testing' => 'DummyContentHandlerForTesting', + 'RevisionTestModifyableContent' => 'RevisionTestModifyableContentHandler', + ) + ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + function tearDown() { + global $wgContLang; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + + parent::tearDown(); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionText() { + $row = new stdClass; + $row->old_flags = ''; + $row->old_text = 'This is a bunch of revision text.'; + $this->assertEquals( + 'This is a bunch of revision text.', + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip'; + $row->old_text = gzdeflate( 'This is a bunch of revision text.' ); + $this->assertEquals( + 'This is a bunch of revision text.', + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8Native() { + $row = new stdClass; + $row->old_flags = 'utf-8'; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8Legacy() { + $row = new stdClass; + $row->old_flags = ''; + $row->old_text = "Wiki est l'\xe9cole superieur !"; + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8NativeGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip,utf-8'; + $row->old_text = gzdeflate( "Wiki est l'\xc3\xa9cole superieur !" ); + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8LegacyGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip'; + $row->old_text = gzdeflate( "Wiki est l'\xe9cole superieur !" ); + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::compressRevisionText + */ + public function testCompressRevisionTextUtf8() { + $row = new stdClass; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $row->old_flags = Revision::compressRevisionText( $row->old_text ); + $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ), + "Flags should contain 'utf-8'" ); + $this->assertFalse( false !== strpos( $row->old_flags, 'gzip' ), + "Flags should not contain 'gzip'" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + $row->old_text, "Direct check" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ), "getRevisionText" ); + } + + /** + * @covers Revision::compressRevisionText + */ + public function testCompressRevisionTextUtf8Gzip() { + $this->checkPHPExtension( 'zlib' ); + $this->setMwGlobals( 'wgCompressRevisions', true ); + + $row = new stdClass; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $row->old_flags = Revision::compressRevisionText( $row->old_text ); + $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ), + "Flags should contain 'utf-8'" ); + $this->assertTrue( false !== strpos( $row->old_flags, 'gzip' ), + "Flags should contain 'gzip'" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + gzinflate( $row->old_text ), "Direct check" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ), "getRevisionText" ); + } + + # ========================================================================= + + /** + * @param string $text + * @param string $title + * @param string $model + * @param string $format + * + * @return Revision + */ + function newTestRevision( $text, $title = "Test", + $model = CONTENT_MODEL_WIKITEXT, $format = null + ) { + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + + $content = ContentHandler::makeContent( $text, $title, $model, $format ); + + $rev = new Revision( + array( + 'id' => 42, + 'page' => 23, + 'title' => $title, + + 'content' => $content, + 'length' => $content->getSize(), + 'comment' => "testing", + 'minor_edit' => false, + + 'content_format' => $format, + ) + ); + + return $rev; + } + + function dataGetContentModel() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, CONTENT_MODEL_WIKITEXT ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_MODEL_CSS ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, "testing" ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentModel + * @covers Revision::getContentModel + */ + public function testGetContentModel( $text, $title, $model, $format, $expectedModel ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedModel, $rev->getContentModel() ); + } + + function dataGetContentFormat() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, CONTENT_FORMAT_WIKITEXT ), + array( 'hello world', 'Help:Hello', CONTENT_MODEL_CSS, null, CONTENT_FORMAT_CSS ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_FORMAT_CSS ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, "testing" ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentFormat + * @covers Revision::getContentFormat + */ + public function testGetContentFormat( $text, $title, $model, $format, $expectedFormat ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedFormat, $rev->getContentFormat() ); + } + + function dataGetContentHandler() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, 'WikitextContentHandler' ), + array( 'hello world', 'User:hello/there.css', null, null, 'CssContentHandler' ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, 'DummyContentHandlerForTesting' ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentHandler + * @covers Revision::getContentHandler + */ + public function testGetContentHandler( $text, $title, $model, $format, $expectedClass ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedClass, get_class( $rev->getContentHandler() ) ); + } + + function dataGetContent() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( + serialize( 'hello world' ), + 'Hello', + "testing", + null, + Revision::FOR_PUBLIC, + serialize( 'hello world' ) + ), + array( + serialize( 'hello world' ), + 'Dummy:Hello', + null, + null, + Revision::FOR_PUBLIC, + serialize( 'hello world' ) + ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContent + * @covers Revision::getContent + */ + public function testGetContent( $text, $title, $model, $format, + $audience, $expectedSerialization + ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + $content = $rev->getContent( $audience ); + + $this->assertEquals( + $expectedSerialization, + is_null( $content ) ? null : $content->serialize( $format ) + ); + } + + function dataGetText() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( serialize( 'hello world' ), 'Hello', "testing", null, Revision::FOR_PUBLIC, null ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, Revision::FOR_PUBLIC, null ), + ); + } + + /** + * @group Database + * @dataProvider dataGetText + * @covers Revision::getText + */ + public function testGetText( $text, $title, $model, $format, $audience, $expectedText ) { + $this->hideDeprecated( 'Revision::getText' ); + + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getText( $audience ) ); + } + + /** + * @group Database + * @dataProvider dataGetText + * @covers Revision::getRawText + */ + public function testGetRawText( $text, $title, $model, $format, $audience, $expectedText ) { + $this->hideDeprecated( 'Revision::getRawText' ); + + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getRawText( $audience ) ); + } + + public function dataGetSize() { + return array( + array( "hello world.", CONTENT_MODEL_WIKITEXT, 12 ), + array( serialize( "hello world." ), "testing", 12 ), + ); + } + + /** + * @covers Revision::getSize + * @group Database + * @dataProvider dataGetSize + */ + public function testGetSize( $text, $model, $expected_size ) { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSize', $model ); + $this->assertEquals( $expected_size, $rev->getSize() ); + } + + public function dataGetSha1() { + return array( + array( "hello world.", CONTENT_MODEL_WIKITEXT, Revision::base36Sha1( "hello world." ) ), + array( + serialize( "hello world." ), + "testing", + Revision::base36Sha1( serialize( "hello world." ) ) + ), + ); + } + + /** + * @covers Revision::getSha1 + * @group Database + * @dataProvider dataGetSha1 + */ + public function testGetSha1( $text, $model, $expected_hash ) { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSha1', $model ); + $this->assertEquals( $expected_hash, $rev->getSha1() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructWithText() { + $this->hideDeprecated( "Revision::getText" ); + + $rev = new Revision( array( + 'text' => 'hello world.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT + ) ); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructWithContent() { + $this->hideDeprecated( "Revision::getText" ); + + $title = Title::newFromText( 'RevisionTest_testConstructWithContent' ); + + $rev = new Revision( array( + 'content' => ContentHandler::makeContent( 'hello world.', $title, CONTENT_MODEL_JAVASCRIPT ), + ) ); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * Tests whether $rev->getContent() returns a clone when needed. + * + * @group Database + * @covers Revision::getContent + */ + public function testGetContentClone() { + $content = new RevisionTestModifyableContent( "foo" ); + + $rev = new Revision( + array( + 'id' => 42, + 'page' => 23, + 'title' => Title::newFromText( "testGetContentClone_dummy" ), + + 'content' => $content, + 'length' => $content->getSize(), + 'comment' => "testing", + 'minor_edit' => false, + ) + ); + + $content = $rev->getContent( Revision::RAW ); + $content->setText( "bar" ); + + $content2 = $rev->getContent( Revision::RAW ); + // content is mutable, expect clone + $this->assertNotSame( $content, $content2, "expected a clone" ); + // clone should contain the original text + $this->assertEquals( "foo", $content2->getText() ); + + $content2->setText( "bla bla" ); + $this->assertEquals( "bar", $content->getText() ); // clones should be independent + } + + /** + * Tests whether $rev->getContent() returns the same object repeatedly if appropriate. + * + * @group Database + * @covers Revision::getContent + */ + public function testGetContentUncloned() { + $rev = $this->newTestRevision( "hello", "testGetContentUncloned_dummy", CONTENT_MODEL_WIKITEXT ); + $content = $rev->getContent( Revision::RAW ); + $content2 = $rev->getContent( Revision::RAW ); + + // for immutable content like wikitext, this should be the same object + $this->assertSame( $content, $content2 ); + } +} + +class RevisionTestModifyableContent extends TextContent { + public function __construct( $text ) { + parent::__construct( $text, "RevisionTestModifyableContent" ); + } + + public function copy() { + return new RevisionTestModifyableContent( $this->mText ); + } + + public function getText() { + return $this->mText; + } + + public function setText( $text ) { + $this->mText = $text; + } +} + +class RevisionTestModifyableContentHandler extends TextContentHandler { + + public function __construct() { + parent::__construct( "RevisionTestModifyableContent", array( CONTENT_FORMAT_TEXT ) ); + } + + public function unserializeContent( $text, $format = null ) { + $this->checkFormat( $format ); + + return new RevisionTestModifyableContent( $text ); + } + + public function makeEmptyContent() { + return new RevisionTestModifyableContent( '' ); + } +} diff --git a/tests/phpunit/includes/SampleTest.php b/tests/phpunit/includes/SampleTest.php new file mode 100644 index 00000000..25858110 --- /dev/null +++ b/tests/phpunit/includes/SampleTest.php @@ -0,0 +1,108 @@ +setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + 'wgLanguageCode' => 'en', + 'wgCapitalLinks' => true, + ) ); + } + + /** + * Anything cleanup you need to do should go here. + */ + protected function tearDown() { + parent::tearDown(); + } + + /** + * Name tests so that PHPUnit can turn them into sentences when + * they run. While MediaWiki isn't strictly an Agile Programming + * project, you are encouraged to use the naming described under + * "Agile Documentation" at + * http://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html + */ + public function testTitleObjectStringConversion() { + $title = Title::newFromText( "text" ); + $this->assertInstanceOf( 'Title', $title, "Title creation" ); + $this->assertEquals( "Text", $title, "Automatic string conversion" ); + + $title = Title::newFromText( "text", NS_MEDIA ); + $this->assertEquals( "Media:Text", $title, "Title creation with namespace" ); + } + + /** + * If you want to run a the same test with a variety of data, use a data provider. + * see: http://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html + */ + public static function provideTitles() { + return array( + array( 'Text', NS_MEDIA, 'Media:Text' ), + array( 'Text', null, 'Text' ), + array( 'text', null, 'Text' ), + array( 'Text', NS_USER, 'User:Text' ), + array( 'Photo.jpg', NS_FILE, 'File:Photo.jpg' ) + ); + } + + /** + * @dataProvider provideTitles + * @codingStandardsIgnoreStart Ignore long line warning + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider + * @codingStandardsIgnoreEnd + */ + public function testCreateBasicListOfTitles( $titleName, $ns, $text ) { + $title = Title::newFromText( $titleName, $ns ); + $this->assertEquals( $text, "$title", "see if '$titleName' matches '$text'" ); + } + + public function testSetUpMainPageTitleForNextTest() { + $title = Title::newMainPage(); + $this->assertEquals( "Main Page", "$title", "Test initial creation of a title" ); + + return $title; + } + + /** + * Instead of putting a bunch of tests in a single test method, + * you should put only one or two tests in each test method. This + * way, the test method names can remain descriptive. + * + * If you want to make tests depend on data created in another + * method, you can create dependencies feed whatever you return + * from the dependant method (e.g. testInitialCreation in this + * example) as arguments to the next method (e.g. $title in + * testTitleDepends is whatever testInitialCreatiion returned.) + */ + + /** + * @depends testSetUpMainPageTitleForNextTest + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends + */ + public function testCheckMainPageTitleIsConsideredLocal( $title ) { + $this->assertTrue( $title->isLocal() ); + } + + // @codingStandardsIgnoreStart Ignore long line warning + /** + * @expectedException MWException object + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException + */ + // @codingStandardsIgnoreEnd + public function testTitleObjectFromObject() { + $title = Title::newFromText( Title::newFromText( "test" ) ); + $this->assertEquals( "Test", $title->isLocal() ); + } +} diff --git a/tests/phpunit/includes/SanitizerTest.php b/tests/phpunit/includes/SanitizerTest.php new file mode 100644 index 00000000..50c1e509 --- /dev/null +++ b/tests/phpunit/includes/SanitizerTest.php @@ -0,0 +1,349 @@ +assertEquals( + "\xc3\xa9cole", + Sanitizer::decodeCharReferences( 'école' ), + 'decode named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeNumericEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode numeric entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode mixed numeric/named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedComplexEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas Ĉio dans l'école)", + Sanitizer::decodeCharReferences( + "Ĉio bonas dans l'école! (mais pas &#x108;io dans l'&eacute;cole)" + ), + 'decode mixed complex entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidAmpersand() { + $this->assertEquals( + 'a & b', + Sanitizer::decodeCharReferences( 'a & b' ), + 'Invalid ampersand' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidEntities() { + $this->assertEquals( + '&foo;', + Sanitizer::decodeCharReferences( '&foo;' ), + 'Invalid named entity' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidNumberedEntities() { + $this->assertEquals( + UTF8_REPLACEMENT, + Sanitizer::decodeCharReferences( "�" ), + 'Invalid numbered entity' + ); + } + + /** + * @covers Sanitizer::removeHTMLtags + * @dataProvider provideHtml5Tags + * + * @param string $tag Name of an HTML5 element (ie: 'video') + * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '<video>') + */ + public function testRemovehtmltagsOnHtml5Tags( $tag, $escaped ) { + $this->setMwGlobals( array( + 'wgUseTidy' => false + ) ); + + if ( $escaped ) { + $this->assertEquals( "<$tag>", + Sanitizer::removeHTMLtags( "<$tag>" ) + ); + } else { + $this->assertEquals( "<$tag>\n", + Sanitizer::removeHTMLtags( "<$tag>" ) + ); + } + } + + /** + * Provide HTML5 tags + */ + public static function provideHtml5Tags() { + $ESCAPED = true; # We want tag to be escaped + $VERBATIM = false; # We want to keep the tag + return array( + array( 'data', $VERBATIM ), + array( 'mark', $VERBATIM ), + array( 'time', $VERBATIM ), + array( 'video', $ESCAPED ), + ); + } + + function dataRemoveHTMLtags() { + return array( + // former testSelfClosingTag + array( + '
Hello world
', + '
Hello world
', + 'Self-closing closing div' + ), + // Make sure special nested HTML5 semantics are not broken + // http://www.whatwg.org/html/text-level-semantics.html#the-kbd-element + array( + 'Shift+F3', + 'Shift+F3', + 'Nested .' + ), + // http://www.whatwg.org/html/text-level-semantics.html#the-sub-and-sup-elements + array( + 'xi, yi', + 'xi, yi', + 'Nested .' + ), + // http://www.whatwg.org/html/text-level-semantics.html#the-dfn-element + array( + 'GDO', + 'GDO', + ' inside ', + ), + ); + } + + /** + * @dataProvider dataRemoveHTMLtags + * @covers Sanitizer::removeHTMLtags + */ + public function testRemoveHTMLtags( $input, $output, $msg = null ) { + $GLOBALS['wgUseTidy'] = false; + $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg ); + } + + /** + * @dataProvider provideTagAttributesToDecode + * @covers Sanitizer::decodeTagAttributes + */ + public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::decodeTagAttributes( $attributes ), + $message + ); + } + + public static function provideTagAttributesToDecode() { + return array( + array( array( 'foo' => 'bar' ), 'foo=bar', 'Unquoted attribute' ), + array( array( 'foo' => 'bar' ), ' foo = bar ', 'Spaced attribute' ), + array( array( 'foo' => 'bar' ), 'foo="bar"', 'Double-quoted attribute' ), + array( array( 'foo' => 'bar' ), 'foo=\'bar\'', 'Single-quoted attribute' ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( array( ':foo' => 'bar' ), ':foo=\'bar\'', 'Leading :' ), + array( array( '_foo' => 'bar' ), '_foo=\'bar\'', 'Leading _' ), + array( array( 'foo' => 'bar' ), 'Foo=\'bar\'', 'Leading capital' ), + array( array( 'foo' => 'BAR' ), 'FOO=BAR', 'Attribute keys are normalized to lowercase' ), + + # Invalid beginning + array( array(), '-foo=bar', 'Leading - is forbidden' ), + array( array(), '.foo=bar', 'Leading . is forbidden' ), + array( array( 'foo-bar' => 'bar' ), 'foo-bar=bar', 'A - is allowed inside the attribute' ), + array( array( 'foo-' => 'bar' ), 'foo-=bar', 'A - is allowed inside the attribute' ), + array( array( 'foo.bar' => 'baz' ), 'foo.bar=baz', 'A . is allowed inside the attribute' ), + array( array( 'foo.' => 'baz' ), 'foo.=baz', 'A . is allowed as last character' ), + array( array( 'foo6' => 'baz' ), 'foo6=baz', 'Numbers are allowed' ), + + # This bit is more relaxed than XML rules, but some extensions use + # it, like ProofreadPage (see bug 27539) + array( array( '1foo' => 'baz' ), '1foo=baz', 'Leading numbers are allowed' ), + array( array(), 'foo$=baz', 'Symbols are not allowed' ), + array( array(), 'foo@=baz', 'Symbols are not allowed' ), + array( array(), 'foo~=baz', 'Symbols are not allowed' ), + array( + array( 'foo' => '1[#^`*%w/(' ), + 'foo=1[#^`*%w/(', + 'All kind of characters are allowed as values' + ), + array( + array( 'foo' => '1[#^`*%\'w/(' ), + 'foo="1[#^`*%\'w/("', + 'Double quotes are allowed if quoted by single quotes' + ), + array( + array( 'foo' => '1[#^`*%"w/(' ), + 'foo=\'1[#^`*%"w/(\'', + 'Single quotes are allowed if quoted by double quotes' + ), + array( array( 'foo' => '&"' ), 'foo=&"', 'Special chars can be provided as entities' ), + array( array( 'foo' => '&foobar;' ), 'foo=&foobar;', 'Entity-like items are accepted' ), + ); + } + + /** + * @dataProvider provideDeprecatedAttributes + * @covers Sanitizer::fixTagAttributes + */ + public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) { + $this->assertEquals( " $inputAttr", + Sanitizer::fixTagAttributes( $inputAttr, $inputEl ), + $message + ); + } + + public static function provideDeprecatedAttributes() { + /** array( , , [message] ) */ + return array( + array( 'clear="left"', 'br' ), + array( 'clear="all"', 'br' ), + array( 'width="100"', 'td' ), + array( 'nowrap="true"', 'td' ), + array( 'nowrap=""', 'td' ), + array( 'align="right"', 'td' ), + array( 'align="center"', 'table' ), + array( 'align="left"', 'tr' ), + array( 'align="center"', 'div' ), + array( 'align="left"', 'h1' ), + array( 'align="left"', 'p' ), + ); + } + + /** + * @dataProvider provideCssCommentsFixtures + * @covers Sanitizer::checkCss + */ + public function testCssCommentsChecking( $expected, $css, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::checkCss( $css ), + $message + ); + } + + public static function provideCssCommentsFixtures() { + /** array( , , [message] ) */ + return array( + // Valid comments spanning entire input + array( '/**/', '/**/' ), + array( '/* comment */', '/* comment */' ), + // Weird stuff + array( ' ', '/****/' ), + array( ' ', '/* /* */' ), + array( 'display: block;', "display:/* foo */block;" ), + array( 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;", + 'Backslash-escaped comments must be stripped (bug 28450)' ), + array( '', '/* unfinished comment structure', + 'Remove anything after a comment-start token' ), + array( '', "\\2f\\2a unifinished comment'", + 'Remove anything after a backslash-escaped comment-start token' ), + array( + '/* insecure input */', + 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\');' + ), + array( + '/* insecure input */', + '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\')";' + ), + array( '/* insecure input */', 'width: expression(1+1);' ), + array( '/* insecure input */', 'background-image: image(asdf.png);' ), + array( '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ), + array( '/* insecure input */', 'background-image: -moz-image(asdf.png);' ), + array( '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ), + array( + '/* insecure input */', + 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);' + ), + array( + '/* insecure input */', + 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' + ), + ); + } + + /** + * Test for support or lack of support for specific attributes in the attribute whitelist. + */ + public static function provideAttributeSupport() { + /** array( , , ) */ + return array( + array( + 'div', + ' role="presentation"', + ' role="presentation"', + 'Support for WAI-ARIA\'s role="presentation".' + ), + array( 'div', ' role="main"', '', "Other WAI-ARIA roles are currently not supported." ), + ); + } + + /** + * @dataProvider provideAttributeSupport + * @covers Sanitizer::fixTagAttributes + */ + public function testAttributeSupport( $tag, $attributes, $expected, $message ) { + $this->assertEquals( $expected, + Sanitizer::fixTagAttributes( $attributes, $tag ), + $message + ); + } +} diff --git a/tests/phpunit/includes/SanitizerValidateEmailTest.php b/tests/phpunit/includes/SanitizerValidateEmailTest.php new file mode 100644 index 00000000..14911f04 --- /dev/null +++ b/tests/phpunit/includes/SanitizerValidateEmailTest.php @@ -0,0 +1,103 @@ +assertEquals( + $expected, + Sanitizer::validateEmail( $addr ), + $msg + ); + } + + private function valid( $addr, $msg = '' ) { + $this->checkEmail( $addr, true, $msg ); + } + + private function invalid( $addr, $msg = '' ) { + $this->checkEmail( $addr, false, $msg ); + } + + public function testEmailWellKnownUserAtHostDotTldAreValid() { + $this->valid( 'user@example.com' ); + $this->valid( 'user@example.museum' ); + } + + public function testEmailWithUpperCaseCharactersAreValid() { + $this->valid( 'USER@example.com' ); + $this->valid( 'user@EXAMPLE.COM' ); + $this->valid( 'user@Example.com' ); + $this->valid( 'USER@eXAMPLE.com' ); + } + + public function testEmailWithAPlusInUserName() { + $this->valid( 'user+sub@example.com' ); + $this->valid( 'user+@example.com' ); + } + + public function testEmailDoesNotNeedATopLevelDomain() { + $this->valid( "user@localhost" ); + $this->valid( "FooBar@localdomain" ); + $this->valid( "nobody@mycompany" ); + } + + public function testEmailWithWhiteSpacesBeforeOrAfterAreInvalids() { + $this->invalid( " user@host.com" ); + $this->invalid( "user@host.com " ); + $this->invalid( "\tuser@host.com" ); + $this->invalid( "user@host.com\t" ); + } + + public function testEmailWithWhiteSpacesAreInvalids() { + $this->invalid( "User user@host" ); + $this->invalid( "first last@mycompany" ); + $this->invalid( "firstlast@my company" ); + } + + /** + * bug 26948 : comma were matched by an incorrect regexp range + */ + public function testEmailWithCommasAreInvalids() { + $this->invalid( "user,foo@example.org" ); + $this->invalid( "userfoo@ex,ample.org" ); + } + + public function testEmailWithHyphens() { + $this->valid( "user-foo@example.org" ); + $this->valid( "userfoo@ex-ample.org" ); + } + + public function testEmailDomainCanNotBeginWithDot() { + $this->invalid( "user@." ); + $this->invalid( "user@.localdomain" ); + $this->invalid( "user@localdomain." ); + $this->valid( "user.@localdomain" ); + $this->valid( ".@localdomain" ); + $this->invalid( ".@a............" ); + } + + public function testEmailWithFunnyCharacters() { + $this->valid( "\$user!ex{this}@123.com" ); + } + + public function testEmailTopLevelDomainCanBeNumerical() { + $this->valid( "user@example.1234" ); + } + + public function testEmailWithoutAtSignIsInvalid() { + $this->invalid( 'useràexample.com' ); + } + + public function testEmailWithOneCharacterDomainIsValid() { + $this->valid( 'user@a' ); + } +} diff --git a/tests/phpunit/includes/SiteConfigurationTest.php b/tests/phpunit/includes/SiteConfigurationTest.php new file mode 100644 index 00000000..6547c873 --- /dev/null +++ b/tests/phpunit/includes/SiteConfigurationTest.php @@ -0,0 +1,363 @@ +mConf = new SiteConfiguration; + + $this->mConf->suffixes = array( 'wikipedia' => 'wiki' ); + $this->mConf->wikis = array( 'enwiki', 'dewiki', 'frwiki' ); + $this->mConf->settings = array( + 'simple' => array( + 'wiki' => 'wiki', + 'tag' => 'tag', + 'enwiki' => 'enwiki', + 'dewiki' => 'dewiki', + 'frwiki' => 'frwiki', + ), + + 'fallback' => array( + 'default' => 'default', + 'wiki' => 'wiki', + 'tag' => 'tag', + ), + + 'params' => array( + 'default' => '$lang $site $wiki', + ), + + '+global' => array( + 'wiki' => array( + 'wiki' => 'wiki', + ), + 'tag' => array( + 'tag' => 'tag', + ), + 'enwiki' => array( + 'enwiki' => 'enwiki', + ), + 'dewiki' => array( + 'dewiki' => 'dewiki', + ), + 'frwiki' => array( + 'frwiki' => 'frwiki', + ), + ), + + 'merge' => array( + '+wiki' => array( + 'wiki' => 'wiki', + ), + '+tag' => array( + 'tag' => 'tag', + ), + 'default' => array( + 'default' => 'default', + ), + '+enwiki' => array( + 'enwiki' => 'enwiki', + ), + '+dewiki' => array( + 'dewiki' => 'dewiki', + ), + '+frwiki' => array( + 'frwiki' => 'frwiki', + ), + ), + ); + + $GLOBALS['global'] = array( 'global' => 'global' ); + } + + /** + * This function is used as a callback within the tests below + */ + public static function getSiteParamsCallback( $conf, $wiki ) { + $site = null; + $lang = null; + foreach ( $conf->suffixes as $suffix ) { + if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { + $site = $suffix; + $lang = substr( $wiki, 0, -strlen( $suffix ) ); + break; + } + } + + return array( + 'suffix' => $site, + 'lang' => $lang, + 'params' => array( + 'lang' => $lang, + 'site' => $site, + 'wiki' => $wiki, + ), + 'tags' => array( 'tag' ), + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDb() { + $this->assertEquals( + array( 'wikipedia', 'en' ), + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB()' + ); + $this->assertEquals( + array( 'wikipedia', '' ), + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() on a suffix' + ); + $this->assertEquals( + array( null, null ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki' + ); + + $this->mConf->suffixes = array( 'wiki', '' ); + $this->assertEquals( + array( '', 'wikien' ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki (2)' + ); + } + + /** + * @covers SiteConfiguration::getLocalDatabases + */ + public function testGetLocalDatabases() { + $this->assertEquals( + array( 'enwiki', 'dewiki', 'frwiki' ), + $this->mConf->getLocalDatabases(), + 'getLocalDatabases()' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testGetConfVariables() { + $this->assertEquals( + 'enwiki', + $this->mConf->get( 'simple', 'enwiki', 'wiki' ), + 'get(): simple setting on an existing wiki' + ); + $this->assertEquals( + 'dewiki', + $this->mConf->get( 'simple', 'dewiki', 'wiki' ), + 'get(): simple setting on an existing wiki (2)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'simple', 'frwiki', 'wiki' ), + 'get(): simple setting on an existing wiki (3)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'simple', 'wiki', 'wiki' ), + 'get(): simple setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'simple', 'eswiki', 'wiki' ), + 'get(): simple setting on an non-existing wiki' + ); + + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'enwiki', 'wiki' ), + 'get(): fallback setting on an existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'fallback', 'dewiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an existing wiki (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'wiki', 'wiki' ), + 'get(): fallback setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'wiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an suffix (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'eswiki', 'wiki' ), + 'get(): fallback setting on an non-existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'fallback', 'eswiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an non-existing wiki (with wiki tag)' + ); + + $common = array( 'wiki' => 'wiki', 'default' => 'default' ); + $commonTag = array( 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ); + $this->assertEquals( + array( 'enwiki' => 'enwiki' ) + $common, + $this->mConf->get( 'merge', 'enwiki', 'wiki' ), + 'get(): merging setting on an existing wiki' + ); + $this->assertEquals( + array( 'enwiki' => 'enwiki' ) + $commonTag, + $this->mConf->get( 'merge', 'enwiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (with tag)' + ); + $this->assertEquals( + array( 'dewiki' => 'dewiki' ) + $common, + $this->mConf->get( 'merge', 'dewiki', 'wiki' ), + 'get(): merging setting on an existing wiki (2)' + ); + $this->assertEquals( + array( 'dewiki' => 'dewiki' ) + $commonTag, + $this->mConf->get( 'merge', 'dewiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (2) (with tag)' + ); + $this->assertEquals( + array( 'frwiki' => 'frwiki' ) + $common, + $this->mConf->get( 'merge', 'frwiki', 'wiki' ), + 'get(): merging setting on an existing wiki (3)' + ); + $this->assertEquals( + array( 'frwiki' => 'frwiki' ) + $commonTag, + $this->mConf->get( 'merge', 'frwiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (3) (with tag)' + ); + $this->assertEquals( + array( 'wiki' => 'wiki' ) + $common, + $this->mConf->get( 'merge', 'wiki', 'wiki' ), + 'get(): merging setting on an suffix' + ); + $this->assertEquals( + array( 'wiki' => 'wiki' ) + $commonTag, + $this->mConf->get( 'merge', 'wiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an suffix (with tag)' + ); + $this->assertEquals( + $common, + $this->mConf->get( 'merge', 'eswiki', 'wiki' ), + 'get(): merging setting on an non-existing wiki' + ); + $this->assertEquals( + $commonTag, + $this->mConf->get( 'merge', 'eswiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an non-existing wiki (with tag)' + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDbWithCallback() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + array( 'wiki', 'en' ), + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB() with callback' + ); + $this->assertEquals( + array( 'wiki', '' ), + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() with callback on a suffix' + ); + $this->assertEquals( + array( null, null ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() with callback on a non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testParameterReplacement() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + 'en wiki enwiki', + $this->mConf->get( 'params', 'enwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki' + ); + $this->assertEquals( + 'de wiki dewiki', + $this->mConf->get( 'params', 'dewiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (2)' + ); + $this->assertEquals( + 'fr wiki frwiki', + $this->mConf->get( 'params', 'frwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (3)' + ); + $this->assertEquals( + ' wiki wiki', + $this->mConf->get( 'params', 'wiki', 'wiki' ), + 'get(): parameter replacement on an suffix' + ); + $this->assertEquals( + 'es wiki eswiki', + $this->mConf->get( 'params', 'eswiki', 'wiki' ), + 'get(): parameter replacement on an non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::getAll + */ + public function testGetAllGlobals() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $getall = array( + 'simple' => 'enwiki', + 'fallback' => 'tag', + 'params' => 'en wiki enwiki', + 'global' => array( 'enwiki' => 'enwiki' ) + $GLOBALS['global'], + 'merge' => array( + 'enwiki' => 'enwiki', + 'tag' => 'tag', + 'wiki' => 'wiki', + 'default' => 'default' + ), + ); + $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); + + $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); + + $this->assertEquals( + $getall['simple'], + $GLOBALS['simple'], + 'extractAllGlobals(): simple setting' + ); + $this->assertEquals( + $getall['fallback'], + $GLOBALS['fallback'], + 'extractAllGlobals(): fallback setting' + ); + $this->assertEquals( + $getall['params'], + $GLOBALS['params'], + 'extractAllGlobals(): parameter replacement' + ); + $this->assertEquals( + $getall['global'], + $GLOBALS['global'], + 'extractAllGlobals(): merging with global' + ); + $this->assertEquals( + $getall['merge'], + $GLOBALS['merge'], + 'extractAllGlobals(): merging setting' + ); + } +} diff --git a/tests/phpunit/includes/SpecialPageTest.php b/tests/phpunit/includes/SpecialPageTest.php new file mode 100644 index 00000000..245cdffd --- /dev/null +++ b/tests/phpunit/includes/SpecialPageTest.php @@ -0,0 +1,105 @@ + + */ +class SpecialPageTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgScript' => '/index.php', + 'wgContLang' => Language::factory( 'en' ) + ) ); + } + + /** + * @dataProvider getTitleForProvider + */ + public function testGetTitleFor( $expectedName, $name ) { + $title = SpecialPage::getTitleFor( $name ); + $expected = Title::makeTitle( NS_SPECIAL, $expectedName ); + $this->assertEquals( $expected, $title ); + } + + public function getTitleForProvider() { + return array( + array( 'UserLogin', 'Userlogin' ) + ); + } + + /** + * @expectedException PHPUnit_Framework_Error_Notice + */ + public function testInvalidGetTitleFor() { + $title = SpecialPage::getTitleFor( 'cat' ); + $expected = Title::makeTitle( NS_SPECIAL, 'Cat' ); + $this->assertEquals( $expected, $title ); + } + + /** + * @expectedException PHPUnit_Framework_Error_Notice + * @dataProvider getTitleForWithWarningProvider + */ + public function testGetTitleForWithWarning( $expected, $name ) { + $title = SpecialPage::getTitleFor( $name ); + $this->assertEquals( $expected, $title ); + } + + public function getTitleForWithWarningProvider() { + return array( + array( Title::makeTitle( NS_SPECIAL, 'UserLogin' ), 'UserLogin' ) + ); + } + + /** + * @dataProvider requireLoginAnonProvider + */ + public function testRequireLoginAnon( $expected, $reason, $title ) { + $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' ); + + $user = User::newFromId( 0 ); + $specialPage->getContext()->setUser( $user ); + $specialPage->getContext()->setLanguage( Language::factory( 'en' ) ); + + $this->setExpectedException( 'UserNotLoggedIn', $expected ); + + // $specialPage->requireLogin( [ $reason [, $title ] ] ) + call_user_func_array( + array( $specialPage, 'requireLogin' ), + array_filter( array( $reason, $title ) ) + ); + } + + public function requireLoginAnonProvider() { + $lang = 'en'; + + $expected1 = wfMessage( 'exception-nologin-text' )->inLanguage( $lang )->text(); + $expected2 = wfMessage( 'about' )->inLanguage( $lang )->text(); + + return array( + array( $expected1, null, null ), + array( $expected2, 'about', null ), + array( $expected2, 'about', 'about' ), + ); + } + + public function testRequireLoginNotAnon() { + $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' ); + + $user = User::newFromName( "UTSysop" ); + $specialPage->getContext()->setUser( $user ); + + $specialPage->requireLogin(); + + // no exception thrown, logged in use can access special page + $this->assertTrue( true ); + } + +} diff --git a/tests/phpunit/includes/StatusTest.php b/tests/phpunit/includes/StatusTest.php new file mode 100644 index 00000000..628c59b6 --- /dev/null +++ b/tests/phpunit/includes/StatusTest.php @@ -0,0 +1,573 @@ +assertTrue( true ); + } + + /** + * @dataProvider provideValues + * @covers Status::newGood + */ + public function testNewGood( $value = null ) { + $status = Status::newGood( $value ); + $this->assertTrue( $status->isGood() ); + $this->assertTrue( $status->isOK() ); + $this->assertEquals( $value, $status->getValue() ); + } + + public static function provideValues() { + return array( + array(), + array( 'foo' ), + array( array( 'foo' => 'bar' ) ), + array( new Exception() ), + array( 1234 ), + ); + } + + /** + * @covers Status::newFatal + */ + public function testNewFatalWithMessage() { + $message = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + + $status = Status::newFatal( $message ); + $this->assertFalse( $status->isGood() ); + $this->assertFalse( $status->isOK() ); + $this->assertEquals( $message, $status->getMessage() ); + } + + /** + * @covers Status::newFatal + */ + public function testNewFatalWithString() { + $message = 'foo'; + $status = Status::newFatal( $message ); + $this->assertFalse( $status->isGood() ); + $this->assertFalse( $status->isOK() ); + $this->assertEquals( $message, $status->getMessage()->getKey() ); + } + + /** + * @dataProvider provideSetResult + * @covers Status::setResult + */ + public function testSetResult( $ok, $value = null ) { + $status = new Status(); + $status->setResult( $ok, $value ); + $this->assertEquals( $ok, $status->isOK() ); + $this->assertEquals( $value, $status->getValue() ); + } + + public static function provideSetResult() { + return array( + array( true ), + array( false ), + array( true, 'value' ), + array( false, 'value' ), + ); + } + + /** + * @dataProvider provideIsOk + * @covers Status::isOk + */ + public function testIsOk( $ok ) { + $status = new Status(); + $status->ok = $ok; + $this->assertEquals( $ok, $status->isOK() ); + } + + public static function provideIsOk() { + return array( + array( true ), + array( false ), + ); + } + + /** + * @covers Status::getValue + */ + public function testGetValue() { + $status = new Status(); + $status->value = 'foobar'; + $this->assertEquals( 'foobar', $status->getValue() ); + } + + /** + * @dataProvider provideIsGood + * @covers Status::isGood + */ + public function testIsGood( $ok, $errors, $expected ) { + $status = new Status(); + $status->ok = $ok; + $status->errors = $errors; + $this->assertEquals( $expected, $status->isGood() ); + } + + public static function provideIsGood() { + return array( + array( true, array(), true ), + array( true, array( 'foo' ), false ), + array( false, array(), false ), + array( false, array( 'foo' ), false ), + ); + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::warning + * @covers Status::getWarningsArray + * @covers Status::getStatusArray + */ + public function testWarningWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->warning( $message ); + } + $warnings = $status->getWarningsArray(); + + $this->assertEquals( count( $messages ), count( $warnings ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $warnings[$key], $expectedArray ); + } + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::error + * @covers Status::getErrorsArray + * @covers Status::getStatusArray + */ + public function testErrorWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->error( $message ); + } + $errors = $status->getErrorsArray(); + + $this->assertEquals( count( $messages ), count( $errors ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $errors[$key], $expectedArray ); + } + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::fatal + * @covers Status::getErrorsArray + * @covers Status::getStatusArray + */ + public function testFatalWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->fatal( $message ); + } + $errors = $status->getErrorsArray(); + + $this->assertEquals( count( $messages ), count( $errors ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $errors[$key], $expectedArray ); + } + $this->assertFalse( $status->isOK() ); + } + + protected function getMockMessage( $key = 'key', $params = array() ) { + $message = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + $message->expects( $this->atLeastOnce() ) + ->method( 'getKey' ) + ->will( $this->returnValue( $key ) ); + $message->expects( $this->atLeastOnce() ) + ->method( 'getParams' ) + ->will( $this->returnValue( $params ) ); + return $message; + } + + /** + * @param array $messageDetails E.g. array( 'KEY' => array(/PARAMS/) ) + * @return Message[] + */ + protected function getMockMessages( $messageDetails ) { + $messages = array(); + foreach ( $messageDetails as $key => $paramsArray ) { + $messages[] = $this->getMockMessage( $key, $paramsArray ); + } + return $messages; + } + + public static function provideMockMessageDetails() { + return array( + array( array( 'key1' => array( 'foo' => 'bar' ) ) ), + array( array( 'key1' => array( 'foo' => 'bar' ), 'key2' => array( 'foo2' => 'bar2' ) ) ), + ); + } + + /** + * @covers Status::merge + */ + public function testMerge() { + $status1 = new Status(); + $status2 = new Status(); + $message1 = $this->getMockMessage( 'warn1' ); + $message2 = $this->getMockMessage( 'error2' ); + $status1->warning( $message1 ); + $status2->error( $message2 ); + + $status1->merge( $status2 ); + $this->assertEquals( + 2, + count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() ) + ); + } + + /** + * @covers Status::merge + */ + public function testMergeWithOverwriteValue() { + $status1 = new Status(); + $status2 = new Status(); + $message1 = $this->getMockMessage( 'warn1' ); + $message2 = $this->getMockMessage( 'error2' ); + $status1->warning( $message1 ); + $status2->error( $message2 ); + $status2->value = 'FooValue'; + + $status1->merge( $status2, true ); + $this->assertEquals( + 2, + count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() ) + ); + $this->assertEquals( 'FooValue', $status1->getValue() ); + } + + /** + * @covers Status::hasMessage + */ + public function testHasMessage() { + $status = new Status(); + $status->fatal( 'bad' ); + $status->fatal( wfMessage( 'bad-msg' ) ); + $this->assertTrue( $status->hasMessage( 'bad' ) ); + $this->assertTrue( $status->hasMessage( 'bad-msg' ) ); + $this->assertTrue( $status->hasMessage( wfMessage( 'bad-msg' ) ) ); + $this->assertFalse( $status->hasMessage( 'good' ) ); + } + + /** + * @dataProvider provideCleanParams + * @covers Status::cleanParams + */ + public function testCleanParams( $cleanCallback, $params, $expected ) { + $method = new ReflectionMethod( 'Status', 'cleanParams' ); + $method->setAccessible( true ); + $status = new Status(); + $status->cleanCallback = $cleanCallback; + + $this->assertEquals( $expected, $method->invoke( $status, $params ) ); + } + + public static function provideCleanParams() { + $cleanCallback = function ( $value ) { + return '-' . $value . '-'; + }; + + return array( + array( false, array( 'foo' => 'bar' ), array( 'foo' => 'bar' ) ), + array( $cleanCallback, array( 'foo' => 'bar' ), array( 'foo' => '-bar-' ) ), + ); + } + + /** + * @dataProvider provideGetWikiTextAndHtml + * @covers Status::getWikiText + * @todo test long and short context messages generated through this method + * this can not really be done now due to use of wfMessage()->plain() + * It is possible to mock such methods but only if namespaces are used + */ + public function testGetWikiText( Status $status, $wikitext, $html ) { + $this->assertEquals( $wikitext, $status->getWikiText() ); + } + + /** + * @dataProvider provideGetWikiTextAndHtml + * @covers Status::getHtml + * @todo test long and short context messages generated through this method + * this can not really be done now due to use of $this->getWikiText using + * wfMessage()->plain(). It is possible to mock such methods but only if + * namespaces are used. + */ + public function testGetHtml( Status $status, $wikitext, $html ) { + $this->assertEquals( $html, $status->getHTML() ); + } + + /** + * @return array Array of arrays with values; + * 0 => status object + * 1 => expected string (with no context) + */ + public static function provideGetWikiTextAndHtml() { + $testCases = array(); + + $testCases['GoodStatus'] = array( + new Status(), + "Internal error: Status::getWikiText called for a good result, this is incorrect\n", + "

Internal error: Status::getWikiText called for a good result, this is incorrect\n

", + ); + + $status = new Status(); + $status->ok = false; + $testCases['GoodButNoError'] = array( + $status, + "Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n", + "

Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n

", + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $testCases['1StringWarning'] = array( + $status, + "", + "

<fooBar!>\n

", + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $status->warning( 'fooBar2!' ); + $testCases['2StringWarnings'] = array( + $status, + "* \n* \n", + "
  • <fooBar!>
  • \n
  • <fooBar2!>
\n", + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $testCases['1MessageWarning'] = array( + $status, + "", + "

<fooBar!>\n

", + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $status->warning( new Message( 'fooBar2!' ) ); + $testCases['2MessageWarnings'] = array( + $status, + "* \n* \n", + "
  • <fooBar!>
  • \n
  • <fooBar2!>
\n", + ); + + return $testCases; + } + + /** + * @dataProvider provideGetMessage + * @covers Status::getMessage + * @todo test long and short context messages generated through this method + */ + public function testGetMessage( Status $status, $expectedParams = array(), $expectedKey ) { + $message = $status->getMessage(); + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $expectedParams, $message->getParams(), 'Message::getParams' ); + $this->assertEquals( $expectedKey, $message->getKey(), 'Message::getKey' ); + } + + /** + * @return array Array of arrays with values; + * 0 => status object + * 1 => expected Message parameters (with no context) + * 2 => expected Message key + */ + public static function provideGetMessage() { + $testCases = array(); + + $testCases['GoodStatus'] = array( + new Status(), + array( "Status::getMessage called for a good result, this is incorrect\n" ), + 'internalerror_info' + ); + + $status = new Status(); + $status->ok = false; + $testCases['GoodButNoError'] = array( + $status, + array( "Status::getMessage: Invalid result object: no error text but not OK\n" ), + 'internalerror_info' + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $testCases['1StringWarning'] = array( + $status, + array(), + 'fooBar!' + ); + + // FIXME: Assertion tries to compare a StubUserLang with a Language object, because + // "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." + // http://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html +// $status = new Status(); +// $status->warning( 'fooBar!' ); +// $status->warning( 'fooBar2!' ); +// $testCases[ '2StringWarnings' ] = array( +// $status, +// array( new Message( 'fooBar!' ), new Message( 'fooBar2!' ) ), +// "* \$1\n* \$2" +// ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $testCases['1MessageWarning'] = array( + $status, + array( 'foo', 'bar' ), + 'fooBar!' + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $status->warning( new Message( 'fooBar2!' ) ); + $testCases['2MessageWarnings'] = array( + $status, + array( new Message( 'fooBar!', array( 'foo', 'bar' ) ), new Message( 'fooBar2!' ) ), + "* \$1\n* \$2" + ); + + return $testCases; + } + + /** + * @covers Status::replaceMessage + */ + public function testReplaceMessage() { + $status = new Status(); + $message = new Message( 'key1', array( 'foo1', 'bar1' ) ); + $status->error( $message ); + $newMessage = new Message( 'key2', array( 'foo2', 'bar2' ) ); + + $status->replaceMessage( $message, $newMessage ); + + $this->assertEquals( $newMessage, $status->errors[0]['message'] ); + } + + /** + * @covers Status::getErrorMessage + */ + public function testGetErrorMessage() { + $method = new ReflectionMethod( 'Status', 'getErrorMessage' ); + $method->setAccessible( true ); + $status = new Status(); + $key = 'foo'; + $params = array( 'bar' ); + + /** @var Message $message */ + $message = $method->invoke( $status, array_merge( array( $key ), $params ) ); + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $key, $message->getKey() ); + $this->assertEquals( $params, $message->getParams() ); + } + + /** + * @covers Status::getErrorMessageArray + */ + public function testGetErrorMessageArray() { + $method = new ReflectionMethod( 'Status', 'getErrorMessageArray' ); + $method->setAccessible( true ); + $status = new Status(); + $key = 'foo'; + $params = array( 'bar' ); + + /** @var Message[] $messageArray */ + $messageArray = $method->invoke( + $status, + array( + array_merge( array( $key ), $params ), + array_merge( array( $key ), $params ) + ) + ); + + $this->assertInternalType( 'array', $messageArray ); + $this->assertCount( 2, $messageArray ); + foreach ( $messageArray as $message ) { + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $key, $message->getKey() ); + $this->assertEquals( $params, $message->getParams() ); + } + } + + /** + * @covers Status::getErrorsByType + */ + public function testGetErrorsByType() { + $status = new Status(); + $warning = new Message( 'warning111' ); + $error = new Message( 'error111' ); + $status->warning( $warning ); + $status->error( $error ); + + $warnings = $status->getErrorsByType( 'warning' ); + $errors = $status->getErrorsByType( 'error' ); + + $this->assertCount( 1, $warnings ); + $this->assertCount( 1, $errors ); + $this->assertEquals( $warning, $warnings[0]['message'] ); + $this->assertEquals( $error, $errors[0]['message'] ); + } + + /** + * @covers Status::__wakeup + */ + public function testWakeUpSanitizesCallback() { + $status = new Status(); + $status->cleanCallback = function ( $value ) { + return '-' . $value . '-'; + }; + $status->__wakeup(); + $this->assertEquals( false, $status->cleanCallback ); + } + + /** + * @dataProvider provideNonObjectMessages + * @covers Status::getStatusArray + */ + public function testGetStatusArrayWithNonObjectMessages( $nonObjMsg ) { + $status = new Status(); + if ( !array_key_exists( 1, $nonObjMsg ) ) { + $status->warning( $nonObjMsg[0] ); + } else { + $status->warning( $nonObjMsg[0], $nonObjMsg[1] ); + } + + $array = $status->getWarningsArray(); // We use getWarningsArray to access getStatusArray + + $this->assertEquals( 1, count( $array ) ); + $this->assertEquals( $nonObjMsg, $array[0] ); + } + + public static function provideNonObjectMessages() { + return array( + array( array( 'ImaString', array( 'param1' => 'value1' ) ) ), + array( array( 'ImaString' ) ), + ); + } + +} diff --git a/tests/phpunit/includes/TemplateCategoriesTest.php b/tests/phpunit/includes/TemplateCategoriesTest.php new file mode 100644 index 00000000..b0d17267 --- /dev/null +++ b/tests/phpunit/includes/TemplateCategoriesTest.php @@ -0,0 +1,96 @@ +mRights = array( 'createpage', 'edit', 'purge', 'delete' ); + + $title = Title::newFromText( "Categorized from template" ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + new WikitextContent( '{{Categorising template}}' ), + 'Create a page with a template', + 0, + false, + $user + ); + + $this->assertEquals( + array(), + $title->getParentCategories(), + 'Verify that the category doesn\'t contain the page before the template is created' + ); + + // Create template + $template = WikiPage::factory( Title::newFromText( 'Template:Categorising template' ) ); + $template->doEditContent( + new WikitextContent( '[[Category:Solved bugs]]' ), + 'Add a category through a template', + 0, + false, + $user + ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure page is in the category + $this->assertEquals( + array( 'Category:Solved_bugs' => $title->getPrefixedText() ), + $title->getParentCategories(), + 'Verify that the page is in the category after the template is created' + ); + + // Edit the template + $template->doEditContent( + new WikitextContent( '[[Category:Solved bugs 2]]' ), + 'Change the category added by the template', + 0, + false, + $user + ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure page is in the right category + $this->assertEquals( + array( 'Category:Solved_bugs_2' => $title->getPrefixedText() ), + $title->getParentCategories(), + 'Verify that the page is in the right category after the template is edited' + ); + + // Now delete the template + $error = ''; + $template->doDeleteArticleReal( 'Delete the template', false, 0, true, $error, $user ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure the page is no longer in the category + $this->assertEquals( + array(), + $title->getParentCategories(), + 'Verify that the page is no longer in the category after template deletion' + ); + + } +} diff --git a/tests/phpunit/includes/TestUser.php b/tests/phpunit/includes/TestUser.php new file mode 100644 index 00000000..610a6acd --- /dev/null +++ b/tests/phpunit/includes/TestUser.php @@ -0,0 +1,62 @@ +username = $username; + $this->realname = $realname; + $this->email = $email; + $this->groups = $groups; + + // don't allow user to hardcode or select passwords -- people sometimes run tests + // on live wikis. Sometimes we create sysop users in these tests. A sysop user with + // a known password would be a Bad Thing. + $this->password = User::randomPassword(); + + $this->user = User::newFromName( $this->username ); + $this->user->load(); + + // In an ideal world we'd have a new wiki (or mock data store) for every single test. + // But for now, we just need to create or update the user with the desired properties. + // we particularly need the new password, since we just generated it randomly. + // In core MediaWiki, there is no functionality to delete users, so this is the best we can do. + if ( !$this->user->getID() ) { + // create the user + $this->user = User::createNew( + $this->username, array( + "email" => $this->email, + "real_name" => $this->realname + ) + ); + if ( !$this->user ) { + throw new Exception( "error creating user" ); + } + } + + // update the user to use the new random password and other details + $this->user->setPassword( $this->password ); + $this->user->setEmail( $this->email ); + $this->user->setRealName( $this->realname ); + + // Adjust groups by adding any missing ones and removing any extras + $currentGroups = $this->user->getGroups(); + foreach ( array_diff( $this->groups, $currentGroups ) as $group ) { + $this->user->addGroup( $group ); + } + foreach ( array_diff( $currentGroups, $this->groups ) as $group ) { + $this->user->removeGroup( $group ); + } + $this->user->saveSettings(); + } +} diff --git a/tests/phpunit/includes/TimeAdjustTest.php b/tests/phpunit/includes/TimeAdjustTest.php new file mode 100644 index 00000000..ae82bc40 --- /dev/null +++ b/tests/phpunit/includes/TimeAdjustTest.php @@ -0,0 +1,39 @@ +setMwGlobals( 'wgLocalTZoffset', $localTZoffset ); + + $this->assertEquals( + $expected, + strval( $wgContLang->userAdjust( $date, '' ) ), + "User adjust {$date} by {$localTZoffset} minutes should give {$expected}" + ); + } + + public static function dataUserAdjust() { + return array( + array( '20061231235959', 0, '20061231235959' ), + array( '20061231235959', 5, '20070101000459' ), + array( '20061231235959', 15, '20070101001459' ), + array( '20061231235959', 60, '20070101005959' ), + array( '20061231235959', 90, '20070101012959' ), + array( '20061231235959', 120, '20070101015959' ), + array( '20061231235959', 540, '20070101085959' ), + array( '20061231235959', -5, '20061231235459' ), + array( '20061231235959', -30, '20061231232959' ), + array( '20061231235959', -60, '20061231225959' ), + ); + } +} diff --git a/tests/phpunit/includes/TitleArrayFromResultTest.php b/tests/phpunit/includes/TitleArrayFromResultTest.php new file mode 100644 index 00000000..0f7069ae --- /dev/null +++ b/tests/phpunit/includes/TitleArrayFromResultTest.php @@ -0,0 +1,119 @@ +getMockBuilder( 'ResultWrapper' ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithTitle( $namespace = 3, $title = 'foo' ) { + $row = new stdClass(); + $row->page_namespace = $namespace; + $row->page_title = $title; + return $row; + } + + private function getTitleArrayFromResult( $resultWrapper ) { + return new TitleArrayFromResult( $resultWrapper ); + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getTitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getTitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( 'Title', $object->current ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public static function provideNumberOfRows() { + return array( + array( 0 ), + array( 1 ), + array( 122 ), + ); + } + + /** + * @dataProvider provideNumberOfRows + * @covers TitleArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithTitle(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers TitleArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $row ) ); + $this->assertInstanceOf( 'Title', $object->current() ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public function provideTestValid() { + return array( + array( $this->getRowWithTitle(), true ), + array( false, false ), + ); + } + + /** + * @dataProvider provideTestValid + * @covers TitleArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + //@todo unit test for key() + //@todo unit test for next() + //@todo unit test for rewind() +} diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php new file mode 100644 index 00000000..5904facd --- /dev/null +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -0,0 +1,300 @@ +mergeMwGlobalArrayValue( + 'wgExtraNamespaces', + array( + 12302 => 'TEST-JS', + 12303 => 'TEST-JS_TALK', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgNamespaceContentModels', + array( + 12302 => CONTENT_MODEL_JAVASCRIPT, + ) + ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function tearDown() { + global $wgContLang; + + parent::tearDown(); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + public static function provideEquals() { + return array( + array( 'Main Page', 'Main Page', true ), + array( 'Main Page', 'Not The Main Page', false ), + array( 'Main Page', 'Project:Main Page', false ), + array( 'File:Example.png', 'Image:Example.png', true ), + array( 'Special:Version', 'Special:Version', true ), + array( 'Special:Version', 'Special:Recentchanges', false ), + array( 'Special:Version', 'Main Page', false ), + ); + } + + /** + * @dataProvider provideEquals + * @covers Title::equals + */ + public function testEquals( $titleA, $titleB, $expectedBool ) { + $titleA = Title::newFromText( $titleA ); + $titleB = Title::newFromText( $titleB ); + + $this->assertEquals( $expectedBool, $titleA->equals( $titleB ) ); + $this->assertEquals( $expectedBool, $titleB->equals( $titleA ) ); + } + + public static function provideInNamespace() { + return array( + array( 'Main Page', NS_MAIN, true ), + array( 'Main Page', NS_TALK, false ), + array( 'Main Page', NS_USER, false ), + array( 'User:Foo', NS_USER, true ), + array( 'User:Foo', NS_USER_TALK, false ), + array( 'User:Foo', NS_TEMPLATE, false ), + array( 'User_talk:Foo', NS_USER_TALK, true ), + array( 'User_talk:Foo', NS_USER, false ), + ); + } + + /** + * @dataProvider provideInNamespace + * @covers Title::inNamespace + */ + public function testInNamespace( $title, $ns, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->inNamespace( $ns ) ); + } + + /** + * @covers Title::inNamespaces + */ + public function testInNamespaces() { + $mainpage = Title::newFromText( 'Main Page' ); + $this->assertTrue( $mainpage->inNamespaces( NS_MAIN, NS_USER ) ); + $this->assertTrue( $mainpage->inNamespaces( array( NS_MAIN, NS_USER ) ) ); + $this->assertTrue( $mainpage->inNamespaces( array( NS_USER, NS_MAIN ) ) ); + $this->assertFalse( $mainpage->inNamespaces( array( NS_PROJECT, NS_TEMPLATE ) ) ); + } + + public static function provideHasSubjectNamespace() { + return array( + array( 'Main Page', NS_MAIN, true ), + array( 'Main Page', NS_TALK, true ), + array( 'Main Page', NS_USER, false ), + array( 'User:Foo', NS_USER, true ), + array( 'User:Foo', NS_USER_TALK, true ), + array( 'User:Foo', NS_TEMPLATE, false ), + array( 'User_talk:Foo', NS_USER_TALK, true ), + array( 'User_talk:Foo', NS_USER, true ), + ); + } + + /** + * @dataProvider provideHasSubjectNamespace + * @covers Title::hasSubjectNamespace + */ + public function testHasSubjectNamespace( $title, $ns, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->hasSubjectNamespace( $ns ) ); + } + + public function dataGetContentModel() { + return array( + array( 'Help:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'TEST-JS:Foo', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS_TALK:Foo.js', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetContentModel + * @covers Title::getContentModel + */ + public function testGetContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, $title->getContentModel() ); + } + + /** + * @dataProvider dataGetContentModel + * @covers Title::hasContentModel + */ + public function testHasContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertTrue( $title->hasContentModel( $expectedModelId ) ); + } + + public static function provideIsCssOrJsPage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.js', false ), + array( 'Help:Foo/bar.js', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', false ), + array( 'User talk:Foo/bar.css', false ), + array( 'User:Foo/bar.js.xxx', false ), + array( 'User:Foo/bar.xxx', false ), + array( 'MediaWiki:Foo.js', true ), + array( 'MediaWiki:Foo.css', true ), + array( 'MediaWiki:Foo.JS', false ), + array( 'MediaWiki:Foo.CSS', false ), + array( 'MediaWiki:Foo.css.xxx', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + ); + } + + /** + * @dataProvider provideIsCssOrJsPage + * @covers Title::isCssOrJsPage + */ + public function testIsCssOrJsPage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssOrJsPage() ); + } + + public static function provideIsCssJsSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.js', false ), + array( 'Help:Foo/bar.js', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo/bar.js', true ), + array( 'User:Foo/bar.css', true ), + array( 'User talk:Foo/bar.css', false ), + array( 'User:Foo/bar.js.xxx', false ), + array( 'User:Foo/bar.xxx', false ), + array( 'MediaWiki:Foo.js', false ), + array( 'User:Foo/bar.JS', false ), + array( 'User:Foo/bar.CSS', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + ); + } + + /** + * @dataProvider provideIsCssJsSubpage + * @covers Title::isCssJsSubpage + */ + public function testIsCssJsSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssJsSubpage() ); + } + + public static function provideIsCssSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.css', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo.css', false ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', true ), + ); + } + + /** + * @dataProvider provideIsCssSubpage + * @covers Title::isCssSubpage + */ + public function testIsCssSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssSubpage() ); + } + + public static function provideIsJsSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.css', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo.css', false ), + array( 'User:Foo/bar.js', true ), + array( 'User:Foo/bar.css', false ), + ); + } + + /** + * @dataProvider provideIsJsSubpage + * @covers Title::isJsSubpage + */ + public function testIsJsSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isJsSubpage() ); + } + + public static function provideIsWikitextPage() { + return array( + array( 'Help:Foo', true ), + array( 'Help:Foo.js', true ), + array( 'Help:Foo/bar.js', true ), + array( 'User:Foo', true ), + array( 'User:Foo.js', true ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', false ), + array( 'User talk:Foo/bar.css', true ), + array( 'User:Foo/bar.js.xxx', true ), + array( 'User:Foo/bar.xxx', true ), + array( 'MediaWiki:Foo.js', false ), + array( 'MediaWiki:Foo.css', false ), + array( 'MediaWiki:Foo/bar.css', false ), + array( 'User:Foo/bar.JS', true ), + array( 'User:Foo/bar.CSS', true ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + array( 'TEST-JS_TALK:Foo.js', true ), + ); + } + + /** + * @dataProvider provideIsWikitextPage + * @covers Title::isWikitextPage + */ + public function testIsWikitextPage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isWikitextPage() ); + } +} diff --git a/tests/phpunit/includes/TitlePermissionTest.php b/tests/phpunit/includes/TitlePermissionTest.php new file mode 100644 index 00000000..d2400b3f --- /dev/null +++ b/tests/phpunit/includes/TitlePermissionTest.php @@ -0,0 +1,770 @@ +setMwGlobals( array( + 'wgMemc' => new EmptyBagOStuff, + 'wgContLang' => $langObj, + 'wgLanguageCode' => 'en', + 'wgLang' => $langObj, + 'wgLocaltimezone' => $localZone, + 'wgLocalTZoffset' => $localOffset, + 'wgNamespaceProtection' => array( + NS_MEDIAWIKI => 'editinterface', + ), + ) ); + // Without this testUserBlock will use a non-English context on non-English MediaWiki + // installations (because of how Title::checkUserBlock is implemented) and fail. + RequestContext::resetMain(); + + $this->userName = 'Useruser'; + $this->altUserName = 'Altuseruser'; + date_default_timezone_set( $localZone ); + + $this->title = Title::makeTitle( NS_MAIN, "Main Page" ); + if ( !isset( $this->userUser ) || !( $this->userUser instanceof User ) ) { + $this->userUser = User::newFromName( $this->userName ); + + if ( !$this->userUser->getID() ) { + $this->userUser = User::createNew( $this->userName, array( + "email" => "test@example.com", + "real_name" => "Test User" ) ); + $this->userUser->load(); + } + + $this->altUser = User::newFromName( $this->altUserName ); + if ( !$this->altUser->getID() ) { + $this->altUser = User::createNew( $this->altUserName, array( + "email" => "alttest@example.com", + "real_name" => "Test User Alt" ) ); + $this->altUser->load(); + } + + $this->anonUser = User::newFromId( 0 ); + + $this->user = $this->userUser; + } + } + + protected function setUserPerm( $perm ) { + // Setting member variables is evil!!! + + if ( is_array( $perm ) ) { + $this->user->mRights = $perm; + } else { + $this->user->mRights = array( $perm ); + } + } + + protected function setTitle( $ns, $title = "Main_Page" ) { + $this->title = Title::makeTitle( $ns, $title ); + } + + protected function setUser( $userName = null ) { + if ( $userName === 'anon' ) { + $this->user = $this->anonUser; + } elseif ( $userName === null || $userName === $this->userName ) { + $this->user = $this->userUser; + } else { + $this->user = $this->altUser; + } + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testQuickPermissions() { + global $wgContLang; + $prefix = $wgContLang->getFormattedNsText( NS_PROJECT ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( "nocreatetext" ) ), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreatetext' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreatetext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'cant-move-user-page' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'cant-move-user-page' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ), $res ); + + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "movefile" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowed' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowedfile' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "movefile" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowedfile' ) ) ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( + 'move', + array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ) + ); + + $this->setUser( 'anon' ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowedfile' ) ) ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( + 'move', + array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ), + array( array( 'movenotallowedfile' ), array( 'movenologintext' ) ) + ); + + if ( $this->isWikitextNS( NS_MAIN ) ) { + //NOTE: some content models don't allow moving + // @todo find a Wikitext namespace for testing + + $this->setTitle( NS_MAIN ); + $this->setUser( 'anon' ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array() ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowed' ) ), + array( array( 'movenologintext' ) ) ); + + $this->setUser( $this->userName ); + $this->setUserPerm( "" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowed' ) ) ); + + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array() ); + + $this->setUser( 'anon' ); + $this->setUserPerm( 'move' ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( '' ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array( array( 'movenotallowed' ) ), $res ); + } + + $this->setTitle( NS_USER ); + $this->setUser( $this->userName ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( "move" ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array( array( 'cant-move-to-user-page' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_USER, "User/subpage" ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( "move" ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUser( 'anon' ); + $check = array( + 'edit' => array( + array( array( 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ) ), + array( array( 'badaccess-group0' ) ), + array(), + true + ), + 'protect' => array( + array( array( + 'badaccess-groups', + "[[$prefix:Administrators|Administrators]]", 1 ), + array( 'protect-cantedit' + ) ), + array( array( 'badaccess-group0' ), array( 'protect-cantedit' ) ), + array( array( 'protect-cantedit' ) ), + false + ), + '' => array( array(), array(), array(), true ) + ); + + foreach ( array( "edit", "protect", "" ) as $action ) { + $this->setUserPerm( null ); + $this->assertEquals( $check[$action][0], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + + global $wgGroupPermissions; + $old = $wgGroupPermissions; + $wgGroupPermissions = array(); + + $this->assertEquals( $check[$action][1], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + $wgGroupPermissions = $old; + + $this->setUserPerm( $action ); + $this->assertEquals( $check[$action][2], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + + $this->setUserPerm( $action ); + $this->assertEquals( $check[$action][3], + $this->title->userCan( $action, $this->user, true ) ); + $this->assertEquals( $check[$action][3], + $this->title->quickUserCan( $action, $this->user ) ); + # count( User::getGroupsWithPermissions( $action ) ) < 1 + } + } + + protected function runGroupPermissions( $action, $result, $result2 = null ) { + global $wgGroupPermissions; + + if ( $result2 === null ) { + $result2 = $result; + } + + $wgGroupPermissions['autoconfirmed']['move'] = false; + $wgGroupPermissions['user']['move'] = false; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = true; + $wgGroupPermissions['user']['move'] = false; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = true; + $wgGroupPermissions['user']['move'] = true; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = false; + $wgGroupPermissions['user']['move'] = true; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testSpecialsAndNSPermissions() { + global $wgNamespaceProtection; + $this->setUser( $this->userName ); + + $this->setTitle( NS_SPECIAL ); + + $this->assertEquals( array( array( 'badaccess-group0' ), array( 'ns-specialprotected' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $wgNamespaceProtection[NS_USER] = array( 'bogus' ); + + $this->setTitle( NS_USER ); + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ), array( 'namespaceprotected', 'User', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MEDIAWIKI ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array( array( 'protectedinterface', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MEDIAWIKI ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array( array( 'protectedinterface', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $wgNamespaceProtection = null; + + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'bogus', $this->user ) ); + + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'bogus', $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testCssAndJavascriptPermissions() { + $this->setUser( $this->userName ); + + $this->setTitle( NS_USER, $this->userName . '/test.js' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ) + ); + + $this->setTitle( NS_USER, $this->userName . '/test.css' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/test.js' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/test.css' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/tempo' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ) + ); + } + + protected function runCSSandJSPermissions( $result0, $result1, $result2, $result3, $result4 ) { + $this->setUserPerm( '' ); + $this->assertEquals( $result0, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editmyusercss' ); + $this->assertEquals( $result1, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editmyuserjs' ); + $this->assertEquals( $result2, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editusercss' ); + $this->assertEquals( $result3, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'edituserjs' ); + $this->assertEquals( $result4, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editusercssjs' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( array( 'edituserjs', 'editusercss' ) ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testPageRestrictions() { + global $wgContLang; + + $prefix = $wgContLang->getFormattedNsText( NS_PROJECT ); + + $this->setTitle( NS_MAIN ); + $this->title->mRestrictionsLoaded = true; + $this->setUserPerm( "edit" ); + $this->title->mRestrictions = array( "bogus" => array( 'bogus', "sysop", "protect", "" ) ); + + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->assertEquals( true, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->title->mRestrictions = array( "edit" => array( 'bogus', "sysop", "protect", "" ), + "bogus" => array( 'bogus', "sysop", "protect", "" ) ); + + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + $this->setUserPerm( "" ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ), + array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + $this->setUserPerm( array( "edit", "editprotected" ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( + array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->title->mCascadeRestriction = true; + $this->setUserPerm( "edit" ); + $this->assertEquals( false, + $this->title->quickUserCan( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->setUserPerm( array( "edit", "editprotected" ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + } + + public function testCascadingSourcesRestrictions() { + $this->setTitle( NS_MAIN, "test page" ); + $this->setUserPerm( array( "edit", "bogus" ) ); + + $this->title->mCascadeSources = array( + Title::makeTitle( NS_MAIN, "Bogus" ), + Title::makeTitle( NS_MAIN, "UnBogus" ) + ); + $this->title->mCascadingRestrictions = array( + "bogus" => array( 'bogus', "sysop", "protect", "" ) + ); + + $this->assertEquals( false, + $this->title->userCan( 'bogus', $this->user ) ); + $this->assertEquals( array( array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ), + array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ), + array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->assertEquals( true, + $this->title->userCan( 'edit', $this->user ) ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testActionPermissions() { + $this->setUserPerm( array( "createpage" ) ); + $this->setTitle( NS_MAIN, "test page" ); + $this->title->mTitleProtection['pt_create_perm'] = ''; + $this->title->mTitleProtection['pt_user'] = $this->user->getID(); + $this->title->mTitleProtection['pt_expiry'] = wfGetDB( DB_SLAVE )->getInfinity(); + $this->title->mTitleProtection['pt_reason'] = 'test'; + $this->title->mCascadeRestriction = false; + + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->title->mTitleProtection['pt_create_perm'] = 'sysop'; + $this->setUserPerm( array( 'createpage', 'protect' ) ); + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->setUserPerm( array( 'createpage', 'editprotected' ) ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'create', $this->user ) ); + + $this->setUserPerm( array( 'createpage' ) ); + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->setTitle( NS_MEDIA, "test page" ); + $this->setUserPerm( array( "move" ) ); + $this->assertEquals( false, + $this->title->userCan( 'move', $this->user ) ); + $this->assertEquals( array( array( 'immobile-source-namespace', 'Media' ) ), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + + $this->setTitle( NS_HELP, "test page" ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'move', $this->user ) ); + + $this->title->mInterwiki = "no"; + $this->assertEquals( array( array( 'immobile-source-page' ) ), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'move', $this->user ) ); + + $this->setTitle( NS_MEDIA, "test page" ); + $this->assertEquals( false, + $this->title->userCan( 'move-target', $this->user ) ); + $this->assertEquals( array( array( 'immobile-target-namespace', 'Media' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + + $this->setTitle( NS_HELP, "test page" ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'move-target', $this->user ) ); + + $this->title->mInterwiki = "no"; + $this->assertEquals( array( array( 'immobile-target-page' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'move-target', $this->user ) ); + } + + public function testUserBlock() { + global $wgEmailConfirmToEdit, $wgEmailAuthentication; + $wgEmailConfirmToEdit = true; + $wgEmailAuthentication = true; + + $this->setUserPerm( array( "createpage", "move" ) ); + $this->setTitle( NS_HELP, "test page" ); + + # $short + $this->assertEquals( array( array( 'confirmedittext' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $wgEmailConfirmToEdit = false; + $this->assertEquals( true, $this->title->userCan( 'move-target', $this->user ) ); + + # $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount' + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move-target', + $this->user ) ); + + global $wgLang; + $prev = time(); + $now = time() + 120; + $this->user->mBlockedby = $this->user->getId(); + $this->user->mBlock = new Block( '127.0.8.1', 0, $this->user->getId(), + 'no reason given', $prev + 3600, 1, 0 ); + $this->user->mBlock->mTimestamp = 0; + $this->assertEquals( array( array( 'autoblockedtext', + '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', + 'Useruser', null, 'infinite', '127.0.8.1', + $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ) ), + $this->title->getUserPermissionsErrors( 'move-target', + $this->user ) ); + + $this->assertEquals( false, $this->title->userCan( 'move-target', $this->user ) ); + // quickUserCan should ignore user blocks + $this->assertEquals( true, $this->title->quickUserCan( 'move-target', $this->user ) ); + + global $wgLocalTZoffset; + $wgLocalTZoffset = -60; + $this->user->mBlockedby = $this->user->getName(); + $this->user->mBlock = new Block( '127.0.8.1', 0, $this->user->getId(), + 'no reason given', $now, 0, 10 ); + $this->assertEquals( array( array( 'blockedtext', + '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', + 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) + # $user->blockedFor() == '' + # $user->mBlock->mExpiry == 'infinity' + } +} diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php new file mode 100644 index 00000000..fb58381f --- /dev/null +++ b/tests/phpunit/includes/TitleTest.php @@ -0,0 +1,650 @@ +setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ), + // User language + 'wgLang' => Language::factory( 'en' ), + 'wgAllowUserJs' => false, + 'wgDefaultLanguageVariant' => false, + ) ); + } + + /** + * @covers Title::legalChars + */ + public function testLegalChars() { + $titlechars = Title::legalChars(); + + foreach ( range( 1, 255 ) as $num ) { + $chr = chr( $num ); + if ( strpos( "#[]{}<>|", $chr ) !== false || preg_match( "/[\\x00-\\x1f\\x7f]/", $chr ) ) { + $this->assertFalse( + (bool)preg_match( "/[$titlechars]/", $chr ), + "chr($num) = $chr is not a valid titlechar" + ); + } else { + $this->assertTrue( + (bool)preg_match( "/[$titlechars]/", $chr ), + "chr($num) = $chr is a valid titlechar" + ); + } + } + } + + public static function provideValidSecureAndSplit() { + return array( + array( 'Sandbox' ), + array( 'A "B"' ), + array( 'A \'B\'' ), + array( '.com' ), + array( '~' ), + array( '#' ), + array( '"' ), + array( '\'' ), + array( 'Talk:Sandbox' ), + array( 'Talk:Foo:Sandbox' ), + array( 'File:Example.svg' ), + array( 'File_talk:Example.svg' ), + array( 'Foo/.../Sandbox' ), + array( 'Sandbox/...' ), + array( 'A~~' ), + array( ':A' ), + // Length is 256 total, but only title part matters + array( 'Category:' . str_repeat( 'x', 248 ) ), + array( str_repeat( 'x', 252 ) ), + // interwiki prefix + array( 'localtestiw: #anchor' ), + array( 'localtestiw:' ), + array( 'localtestiw:foo' ), + array( 'localtestiw: foo # anchor' ), + array( 'localtestiw: Talk: Sandbox # anchor' ), + array( 'remotetestiw:' ), + array( 'remotetestiw: Talk: # anchor' ), + array( 'remotetestiw: #bar' ), + array( 'remotetestiw: Talk:' ), + array( 'remotetestiw: Talk: Foo' ), + array( 'localtestiw:remotetestiw:' ), + array( 'localtestiw:remotetestiw:foo' ) + ); + } + + public static function provideInvalidSecureAndSplit() { + return array( + array( '' ), + array( ':' ), + array( '__ __' ), + array( ' __ ' ), + // Bad characters forbidden regardless of wgLegalTitleChars + array( 'A [ B' ), + array( 'A ] B' ), + array( 'A { B' ), + array( 'A } B' ), + array( 'A < B' ), + array( 'A > B' ), + array( 'A | B' ), + // URL encoding + array( 'A%20B' ), + array( 'A%23B' ), + array( 'A%2523B' ), + // XML/HTML character entity references + // Note: Commented out because they are not marked invalid by the PHP test as + // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first. + //'A é B', + //'A é B', + //'A é B', + // Subject of NS_TALK does not roundtrip to NS_MAIN + array( 'Talk:File:Example.svg' ), + // Directory navigation + array( '.' ), + array( '..' ), + array( './Sandbox' ), + array( '../Sandbox' ), + array( 'Foo/./Sandbox' ), + array( 'Foo/../Sandbox' ), + array( 'Sandbox/.' ), + array( 'Sandbox/..' ), + // Tilde + array( 'A ~~~ Name' ), + array( 'A ~~~~ Signature' ), + array( 'A ~~~~~ Timestamp' ), + array( str_repeat( 'x', 256 ) ), + // Namespace prefix without actual title + array( 'Talk:' ), + array( 'Talk:#' ), + array( 'Category: ' ), + array( 'Category: #bar' ), + // interwiki prefix + array( 'localtestiw: Talk: # anchor' ), + array( 'localtestiw: Talk:' ) + ); + } + + private function secureAndSplitGlobals() { + $this->setMwGlobals( array( + 'wgLocalInterwikis' => array( 'localtestiw' ), + 'wgHooks' => array( + 'InterwikiLoadPrefix' => array( + function ( $prefix, &$data ) { + if ( $prefix === 'localtestiw' ) { + $data = array( 'iw_url' => 'localtestiw' ); + } elseif ( $prefix === 'remotetestiw' ) { + $data = array( 'iw_url' => 'remotetestiw' ); + } + return false; + } + ) + ) + )); + } + + /** + * See also mediawiki.Title.test.js + * @covers Title::secureAndSplit + * @dataProvider provideValidSecureAndSplit + * @note This mainly tests MediaWikiTitleCodec::parseTitle(). + */ + public function testSecureAndSplitValid( $text ) { + $this->secureAndSplitGlobals(); + $this->assertInstanceOf( 'Title', Title::newFromText( $text ), "Valid: $text" ); + } + + /** + * See also mediawiki.Title.test.js + * @covers Title::secureAndSplit + * @dataProvider provideInvalidSecureAndSplit + * @note This mainly tests MediaWikiTitleCodec::parseTitle(). + */ + public function testSecureAndSplitInvalid( $text ) { + $this->secureAndSplitGlobals(); + $this->assertNull( Title::newFromText( $text ), "Invalid: $text" ); + } + + public static function provideConvertByteClassToUnicodeClass() { + return array( + array( + ' %!"$&\'()*,\\-.\\/0-9:;=?@A-Z\\\\^_`a-z~\\x80-\\xFF+', + ' %!"$&\'()*,\\-./0-9:;=?@A-Z\\\\\\^_`a-z~+\\u0080-\\uFFFF', + ), + array( + 'QWERTYf-\\xFF+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\xFD+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTYf-y+', + 'QWERTYf-y+', + ), + array( + 'QWERTYf-\\x80+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\x80+\\x23', + 'QWERTYf-\\x7F+#\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\x80+\\xD3', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + '\\\\\\x99', + '\\\\\\u0080-\\uFFFF', + ), + array( + '-\\x99', + '\\-\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\-\\x99', + 'QWERTY\\-\\u0080-\\uFFFF', + ), + array( + '\\\\x99', + '\\\\x99', + ), + array( + 'A-\\x9F', + 'A-\\x7F\\u0080-\\uFFFF', + ), + array( + '\\x66-\\x77QWERTY\\x88-\\x91FXZ', + 'f-wQWERTYFXZ\\u0080-\\uFFFF', + ), + array( + '\\x66-\\x99QWERTY\\xAA-\\xEEFXZ', + 'f-\\x7FQWERTYFXZ\\u0080-\\uFFFF', + ), + ); + } + + /** + * @dataProvider provideConvertByteClassToUnicodeClass + * @covers Title::convertByteClassToUnicodeClass + */ + public function testConvertByteClassToUnicodeClass( $byteClass, $unicodeClass ) { + $this->assertEquals( $unicodeClass, Title::convertByteClassToUnicodeClass( $byteClass ) ); + } + + /** + * @dataProvider provideSpecialNamesWithAndWithoutParameter + * @covers Title::fixSpecialName + */ + public function testFixSpecialNameRetainsParameter( $text, $expectedParam ) { + $title = Title::newFromText( $text ); + $fixed = $title->fixSpecialName(); + $stuff = explode( '/', $fixed->getDBkey(), 2 ); + if ( count( $stuff ) == 2 ) { + $par = $stuff[1]; + } else { + $par = null; + } + $this->assertEquals( + $expectedParam, + $par, + "Bug 31100 regression check: Title->fixSpecialName() should preserve parameter" + ); + } + + public static function provideSpecialNamesWithAndWithoutParameter() { + return array( + array( 'Special:Version', null ), + array( 'Special:Version/', '' ), + array( 'Special:Version/param', 'param' ), + ); + } + + /** + * Auth-less test of Title::isValidMoveOperation + * + * @group Database + * @param string $source + * @param string $target + * @param array|string|bool $expected Required error + * @dataProvider provideTestIsValidMoveOperation + * @covers Title::isValidMoveOperation + * @covers Title::validateFileMoveOperation + */ + public function testIsValidMoveOperation( $source, $target, $expected ) { + $this->setMwGlobals( 'wgContentHandlerUseDB', false ); + $title = Title::newFromText( $source ); + $nt = Title::newFromText( $target ); + $errors = $title->isValidMoveOperation( $nt, false ); + if ( $expected === true ) { + $this->assertTrue( $errors ); + } else { + $errors = $this->flattenErrorsArray( $errors ); + foreach ( (array)$expected as $error ) { + $this->assertContains( $error, $errors ); + } + } + } + + public static function provideTestIsValidMoveOperation() { + return array( + // for Title::isValidMoveOperation + array( 'Some page', '', 'badtitletext' ), + array( 'Test', 'Test', 'selfmove' ), + array( 'Special:FooBar', 'Test', 'immobile-source-namespace' ), + array( 'Test', 'Special:FooBar', 'immobile-target-namespace' ), + array( 'MediaWiki:Common.js', 'Help:Some wikitext page', 'bad-target-model' ), + array( 'Page', 'File:Test.jpg', 'nonfile-cannot-move-to-file' ), + // for Title::validateFileMoveOperation + array( 'File:Test.jpg', 'Page', 'imagenocrossnamespace' ), + ); + } + + /** + * Auth-less test of Title::userCan + * + * @param array $whitelistRegexp + * @param string $source + * @param string $action + * @param array|string|bool $expected Required error + * + * @covers Title::checkReadPermissions + * @dataProvider dataWgWhitelistReadRegexp + */ + public function testWgWhitelistReadRegexp( $whitelistRegexp, $source, $action, $expected ) { + // $wgWhitelistReadRegexp must be an array. Since the provided test cases + // usually have only one regex, it is more concise to write the lonely regex + // as a string. Thus we cast to an array() to honor $wgWhitelistReadRegexp + // type requisite. + if ( is_string( $whitelistRegexp ) ) { + $whitelistRegexp = array( $whitelistRegexp ); + } + + $title = Title::newFromDBkey( $source ); + + global $wgGroupPermissions; + $oldPermissions = $wgGroupPermissions; + // Disallow all so we can ensure our regex works + $wgGroupPermissions = array(); + $wgGroupPermissions['*']['read'] = false; + + global $wgWhitelistRead; + $oldWhitelist = $wgWhitelistRead; + // Undo any LocalSettings explicite whitelists so they won't cause a + // failing test to succeed. Set it to some random non sense just + // to make sure we properly test Title::checkReadPermissions() + $wgWhitelistRead = array( 'some random non sense title' ); + + global $wgWhitelistReadRegexp; + $oldWhitelistRegexp = $wgWhitelistReadRegexp; + $wgWhitelistReadRegexp = $whitelistRegexp; + + // Just use $wgUser which in test is a user object for '127.0.0.1' + global $wgUser; + // Invalidate user rights cache to take in account $wgGroupPermissions + // change above. + $wgUser->clearInstanceCache(); + $errors = $title->userCan( $action, $wgUser ); + + // Restore globals + $wgGroupPermissions = $oldPermissions; + $wgWhitelistRead = $oldWhitelist; + $wgWhitelistReadRegexp = $oldWhitelistRegexp; + + if ( is_bool( $expected ) ) { + # Forge the assertion message depending on the assertion expectation + $allowableness = $expected + ? " should be allowed" + : " should NOT be allowed"; + $this->assertEquals( + $expected, + $errors, + "User action '$action' on [[$source]] $allowableness." + ); + } else { + $errors = $this->flattenErrorsArray( $errors ); + foreach ( (array)$expected as $error ) { + $this->assertContains( $error, $errors ); + } + } + } + + /** + * Provides test parameter values for testWgWhitelistReadRegexp() + */ + public function dataWgWhitelistReadRegexp() { + $ALLOWED = true; + $DISALLOWED = false; + + return array( + // Everything, if this doesn't work, we're really in trouble + array( '/.*/', 'Main_Page', 'read', $ALLOWED ), + array( '/.*/', 'Main_Page', 'edit', $DISALLOWED ), + + // We validate against the title name, not the db key + array( '/^Main_Page$/', 'Main_Page', 'read', $DISALLOWED ), + // Main page + array( '/^Main/', 'Main_Page', 'read', $ALLOWED ), + array( '/^Main.*/', 'Main_Page', 'read', $ALLOWED ), + // With spaces + array( '/Mic\sCheck/', 'Mic Check', 'read', $ALLOWED ), + // Unicode multibyte + // ...without unicode modifier + array( '/Unicode Test . Yes/', 'Unicode Test Ñ Yes', 'read', $DISALLOWED ), + // ...with unicode modifier + array( '/Unicode Test . Yes/u', 'Unicode Test Ñ Yes', 'read', $ALLOWED ), + // Case insensitive + array( '/MiC ChEcK/', 'mic check', 'read', $DISALLOWED ), + array( '/MiC ChEcK/i', 'mic check', 'read', $ALLOWED ), + + // From DefaultSettings.php: + array( "@^UsEr.*@i", 'User is banned', 'read', $ALLOWED ), + array( "@^UsEr.*@i", 'User:John Doe', 'read', $ALLOWED ), + + // With namespaces: + array( '/^Special:NewPages$/', 'Special:NewPages', 'read', $ALLOWED ), + array( null, 'Special:Newpages', 'read', $DISALLOWED ), + + ); + } + + public function flattenErrorsArray( $errors ) { + $result = array(); + foreach ( $errors as $error ) { + $result[] = $error[0]; + } + + return $result; + } + + /** + * @dataProvider provideGetPageViewLanguage + * @covers Title::getPageViewLanguage + */ + public function testGetPageViewLanguage( $expected, $titleText, $contLang, + $lang, $variant, $msg = '' + ) { + global $wgLanguageCode, $wgContLang, $wgLang, $wgDefaultLanguageVariant, $wgAllowUserJs; + + // Setup environnement for this test + $wgLanguageCode = $contLang; + $wgContLang = Language::factory( $contLang ); + $wgLang = Language::factory( $lang ); + $wgDefaultLanguageVariant = $variant; + $wgAllowUserJs = true; + + $title = Title::newFromText( $titleText ); + $this->assertInstanceOf( 'Title', $title, + "Test must be passed a valid title text, you gave '$titleText'" + ); + $this->assertEquals( $expected, + $title->getPageViewLanguage()->getCode(), + $msg + ); + } + + public static function provideGetPageViewLanguage() { + # Format: + # - expected + # - Title name + # - wgContLang (expected in most case) + # - wgLang (on some specific pages) + # - wgDefaultLanguageVariant + # - Optional message + return array( + array( 'fr', 'Help:I_need_somebody', 'fr', 'fr', false ), + array( 'es', 'Help:I_need_somebody', 'es', 'zh-tw', false ), + array( 'zh', 'Help:I_need_somebody', 'zh', 'zh-tw', false ), + + array( 'es', 'Help:I_need_somebody', 'es', 'zh-tw', 'zh-cn' ), + array( 'es', 'MediaWiki:About', 'es', 'zh-tw', 'zh-cn' ), + array( 'es', 'MediaWiki:About/', 'es', 'zh-tw', 'zh-cn' ), + array( 'de', 'MediaWiki:About/de', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.js', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.css', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Common.js', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Monobook.css', 'es', 'zh-tw', 'zh-cn' ), + + array( 'zh-cn', 'Help:I_need_somebody', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh', 'MediaWiki:About', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh', 'MediaWiki:About/', 'zh', 'zh-tw', 'zh-cn' ), + array( 'de', 'MediaWiki:About/de', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh-cn', 'MediaWiki:About/zh-cn', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh-tw', 'MediaWiki:About/zh-tw', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.js', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.css', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Common.js', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Monobook.css', 'zh', 'zh-tw', 'zh-cn' ), + + array( 'zh-tw', 'Special:NewPages', 'es', 'zh-tw', 'zh-cn' ), + array( 'zh-tw', 'Special:NewPages', 'zh', 'zh-tw', 'zh-cn' ), + + ); + } + + /** + * @dataProvider provideBaseTitleCases + * @covers Title::getBaseText + */ + public function testGetBaseText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getBaseText(), + $msg + ); + } + + public static function provideBaseTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ), + array( 'User:Foo/Bar/Baz', 'Foo/Bar' ), + ); + } + + /** + * @dataProvider provideRootTitleCases + * @covers Title::getRootText + */ + public function testGetRootText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getRootText(), + $msg + ); + } + + public static function provideRootTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'John Doe' ), + array( 'User:Foo/Bar/Baz', 'Foo' ), + ); + } + + /** + * @todo Handle $wgNamespacesWithSubpages cases + * @dataProvider provideSubpageTitleCases + * @covers Title::getSubpageText + */ + public function testGetSubpageText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getSubpageText(), + $msg + ); + } + + public static function provideSubpageTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'subTwo' ), + array( 'User:John_Doe/subOne', 'subOne' ), + ); + } + + public static function provideNewFromTitleValue() { + return array( + array( new TitleValue( NS_MAIN, 'Foo' ) ), + array( new TitleValue( NS_MAIN, 'Foo', 'bar' ) ), + array( new TitleValue( NS_USER, 'Hansi_Maier' ) ), + ); + } + + /** + * @dataProvider provideNewFromTitleValue + */ + public function testNewFromTitleValue( TitleValue $value ) { + $title = Title::newFromTitleValue( $value ); + + $dbkey = str_replace( ' ', '_', $value->getText() ); + $this->assertEquals( $dbkey, $title->getDBkey() ); + $this->assertEquals( $value->getNamespace(), $title->getNamespace() ); + $this->assertEquals( $value->getFragment(), $title->getFragment() ); + } + + public static function provideGetTitleValue() { + return array( + array( 'Foo' ), + array( 'Foo#bar' ), + array( 'User:Hansi_Maier' ), + ); + } + + /** + * @dataProvider provideGetTitleValue + */ + public function testGetTitleValue( $text ) { + $title = Title::newFromText( $text ); + $value = $title->getTitleValue(); + + $dbkey = str_replace( ' ', '_', $value->getText() ); + $this->assertEquals( $title->getDBkey(), $dbkey ); + $this->assertEquals( $title->getNamespace(), $value->getNamespace() ); + $this->assertEquals( $title->getFragment(), $value->getFragment() ); + } + + public static function provideGetFragment() { + return array( + array( 'Foo', '' ), + array( 'Foo#bar', 'bar' ), + array( 'Foo#bär', 'bär' ), + + // Inner whitespace is normalized + array( 'Foo#bar_bar', 'bar bar' ), + array( 'Foo#bar bar', 'bar bar' ), + array( 'Foo#bar bar', 'bar bar' ), + + // Leading whitespace is kept, trailing whitespace is trimmed. + // XXX: Is this really want we want? + array( 'Foo#_bar_bar_', ' bar bar' ), + array( 'Foo# bar bar ', ' bar bar' ), + ); + } + + /** + * @dataProvider provideGetFragment + * + * @param string $full + * @param string $fragment + */ + public function testGetFragment( $full, $fragment ) { + $title = Title::newFromText( $full ); + $this->assertEquals( $fragment, $title->getFragment() ); + } + + /** + * @covers Title::isAlwaysKnown + * @dataProvider provideIsAlwaysKnown + * @param string $page + * @param bool $isKnown + */ + public function testIsAlwaysKnown( $page, $isKnown ) { + $title = Title::newFromText( $page ); + $this->assertEquals( $isKnown, $title->isAlwaysKnown() ); + } + + public static function provideIsAlwaysKnown() { + return array( + array( 'Some nonexistent page', false ), + array( 'UTPage', false ), + array( '#test', true ), + array( 'Special:BlankPage', true ), + array( 'Special:SomeNonexistentSpecialPage', false ), + array( 'MediaWiki:Parentheses', true ), + array( 'MediaWiki:Some nonexistent message', false ), + ); + } + + /** + * @covers Title::isAlwaysKnown + */ + public function testIsAlwaysKnownOnInterwiki() { + $title = Title::makeTitle( NS_MAIN, 'Interwiki link', '', 'externalwiki' ); + $this->assertTrue( $title->isAlwaysKnown() ); + } +} diff --git a/tests/phpunit/includes/UserArrayFromResultTest.php b/tests/phpunit/includes/UserArrayFromResultTest.php new file mode 100644 index 00000000..62989faa --- /dev/null +++ b/tests/phpunit/includes/UserArrayFromResultTest.php @@ -0,0 +1,114 @@ +getMockBuilder( 'ResultWrapper' ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithUsername( $username = 'fooUser' ) { + $row = new stdClass(); + $row->user_name = $username; + return $row; + } + + private function getUserArrayFromResult( $resultWrapper ) { + return new UserArrayFromResult( $resultWrapper ); + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getUserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $username = 'addshore'; + $row = $this->getRowWithUsername( $username ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getUserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( 'User', $object->current ); + $this->assertEquals( $username, $object->current->mName ); + } + + public static function provideNumberOfRows() { + return array( + array( 0 ), + array( 1 ), + array( 122 ), + ); + } + + /** + * @dataProvider provideNumberOfRows + * @covers UserArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithUsername(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers UserArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $username = 'addshore'; + $userRow = $this->getRowWithUsername( $username ); + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $userRow ) ); + $this->assertInstanceOf( 'User', $object->current() ); + $this->assertEquals( $username, $object->current()->mName ); + } + + public function provideTestValid() { + return array( + array( $this->getRowWithUsername(), true ), + array( false, false ), + ); + } + + /** + * @dataProvider provideTestValid + * @covers UserArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + //@todo unit test for key() + //@todo unit test for next() + //@todo unit test for rewind() +} diff --git a/tests/phpunit/includes/UserTest.php b/tests/phpunit/includes/UserTest.php new file mode 100644 index 00000000..af95a721 --- /dev/null +++ b/tests/phpunit/includes/UserTest.php @@ -0,0 +1,369 @@ +setMwGlobals( array( + 'wgGroupPermissions' => array(), + 'wgRevokePermissions' => array(), + ) ); + + $this->setUpPermissionGlobals(); + + $this->user = new User; + $this->user->addGroup( 'unittesters' ); + } + + private function setUpPermissionGlobals() { + global $wgGroupPermissions, $wgRevokePermissions; + + # Data for regular $wgGroupPermissions test + $wgGroupPermissions['unittesters'] = array( + 'test' => true, + 'runtest' => true, + 'writetest' => false, + 'nukeworld' => false, + ); + $wgGroupPermissions['testwriters'] = array( + 'test' => true, + 'writetest' => true, + 'modifytest' => true, + ); + + # Data for regular $wgRevokePermissions test + $wgRevokePermissions['formertesters'] = array( + 'runtest' => true, + ); + + # For the options test + $wgGroupPermissions['*'] = array( + 'editmyoptions' => true, + ); + } + + /** + * @covers User::getGroupPermissions + */ + public function testGroupPermissions() { + $rights = User::getGroupPermissions( array( 'unittesters' ) ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + + $rights = User::getGroupPermissions( array( 'unittesters', 'testwriters' ) ); + $this->assertContains( 'runtest', $rights ); + $this->assertContains( 'writetest', $rights ); + $this->assertContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers User::getGroupPermissions + */ + public function testRevokePermissions() { + $rights = User::getGroupPermissions( array( 'unittesters', 'formertesters' ) ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers User::getRights + */ + public function testUserPermissions() { + $rights = $this->user->getRights(); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @dataProvider provideGetGroupsWithPermission + * @covers User::getGroupsWithPermission + */ + public function testGetGroupsWithPermission( $expected, $right ) { + $result = User::getGroupsWithPermission( $right ); + sort( $result ); + sort( $expected ); + + $this->assertEquals( $expected, $result, "Groups with permission $right" ); + } + + public static function provideGetGroupsWithPermission() { + return array( + array( + array( 'unittesters', 'testwriters' ), + 'test' + ), + array( + array( 'unittesters' ), + 'runtest' + ), + array( + array( 'testwriters' ), + 'writetest' + ), + array( + array( 'testwriters' ), + 'modifytest' + ), + ); + } + + /** + * @dataProvider provideIPs + * @covers User::isIP + */ + public function testIsIP( $value, $result, $message ) { + $this->assertEquals( $this->user->isIP( $value ), $result, $message ); + } + + public static function provideIPs() { + return array( + array( '', false, 'Empty string' ), + array( ' ', false, 'Blank space' ), + array( '10.0.0.0', true, 'IPv4 private 10/8' ), + array( '10.255.255.255', true, 'IPv4 private 10/8' ), + array( '192.168.1.1', true, 'IPv4 private 192.168/16' ), + array( '203.0.113.0', true, 'IPv4 example' ), + array( '2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true, 'IPv6 example' ), + // Not valid IPs but classified as such by MediaWiki for negated asserting + // of whether this might be the identifier of a logged-out user or whether + // to allow usernames like it. + array( '300.300.300.300', true, 'Looks too much like an IPv4 address' ), + array( '203.0.113.xxx', true, 'Assigned by UseMod to cloaked logged-out users' ), + ); + } + + /** + * @dataProvider provideUserNames + * @covers User::isValidUserName + */ + public function testIsValidUserName( $username, $result, $message ) { + $this->assertEquals( $this->user->isValidUserName( $username ), $result, $message ); + } + + public static function provideUserNames() { + return array( + array( '', false, 'Empty string' ), + array( ' ', false, 'Blank space' ), + array( 'abcd', false, 'Starts with small letter' ), + array( 'Ab/cd', false, 'Contains slash' ), + array( 'Ab cd', true, 'Whitespace' ), + array( '192.168.1.1', false, 'IP' ), + array( 'User:Abcd', false, 'Reserved Namespace' ), + array( '12abcd232', true, 'Starts with Numbers' ), + array( '?abcd', true, 'Start with ? mark' ), + array( '#abcd', false, 'Start with #' ), + array( 'Abcdകഖഗഘ', true, ' Mixed scripts' ), + array( 'ജോസ്‌തോമസ്', false, 'ZWNJ- Format control character' ), + array( 'Ab cd', false, ' Ideographic space' ), + array( '300.300.300.300', false, 'Looks too much like an IPv4 address' ), + array( '302.113.311.900', false, 'Looks too much like an IPv4 address' ), + array( '203.0.113.xxx', false, 'Reserved for usage by UseMod for cloaked logged-out users' ), + ); + } + + /** + * Test, if for all rights a right- message exist, + * which is used on Special:ListGroupRights as help text + * Extensions and core + */ + public function testAllRightsWithMessage() { + // Getting all user rights, for core: User::$mCoreRights, for extensions: $wgAvailableRights + $allRights = User::getAllRights(); + $allMessageKeys = Language::getMessageKeysFor( 'en' ); + + $rightsWithMessage = array(); + foreach ( $allMessageKeys as $message ) { + // === 0: must be at beginning of string (position 0) + if ( strpos( $message, 'right-' ) === 0 ) { + $rightsWithMessage[] = substr( $message, strlen( 'right-' ) ); + } + } + + sort( $allRights ); + sort( $rightsWithMessage ); + + $this->assertEquals( + $allRights, + $rightsWithMessage, + 'Each user rights (core/extensions) has a corresponding right- message.' + ); + } + + /** + * Test User::editCount + * @group medium + * @covers User::getEditCount + */ + public function testEditCount() { + $user = User::newFromName( 'UnitTestUser' ); + $user->loadDefaults(); + $user->addToDatabase(); + + // let the user have a few (3) edits + $page = WikiPage::factory( Title::newFromText( 'Help:UserTest_EditCount' ) ); + for ( $i = 0; $i < 3; $i++ ) { + $page->doEdit( (string)$i, 'test', 0, false, $user ); + } + + $user->clearInstanceCache(); + $this->assertEquals( + 3, + $user->getEditCount(), + 'After three edits, the user edit count should be 3' + ); + + // increase the edit count and clear the cache + $user->incEditCount(); + + $user->clearInstanceCache(); + $this->assertEquals( + 4, + $user->getEditCount(), + 'After increasing the edit count manually, the user edit count should be 4' + ); + } + + /** + * Test changing user options. + * @covers User::setOption + * @covers User::getOption + */ + public function testOptions() { + $user = User::newFromName( 'UnitTestUser' ); + $user->addToDatabase(); + + $user->setOption( 'someoption', 'test' ); + $user->setOption( 'cols', 200 ); + $user->saveSettings(); + + $user = User::newFromName( 'UnitTestUser' ); + $this->assertEquals( 'test', $user->getOption( 'someoption' ) ); + $this->assertEquals( 200, $user->getOption( 'cols' ) ); + } + + /** + * Bug 37963 + * Make sure defaults are loaded when setOption is called. + * @covers User::loadOptions + */ + public function testAnonOptions() { + global $wgDefaultUserOptions; + $this->user->setOption( 'someoption', 'test' ); + $this->assertEquals( $wgDefaultUserOptions['cols'], $this->user->getOption( 'cols' ) ); + $this->assertEquals( 'test', $this->user->getOption( 'someoption' ) ); + } + + /** + * Test password expiration. + * @covers User::getPasswordExpired() + */ + public function testPasswordExpire() { + global $wgPasswordExpireGrace; + $wgTemp = $wgPasswordExpireGrace; + $wgPasswordExpireGrace = 3600 * 24 * 7; // 7 days + + $user = User::newFromName( 'UnitTestUser' ); + $user->loadDefaults(); + $this->assertEquals( false, $user->getPasswordExpired() ); + + $ts = time() - ( 3600 * 24 * 1 ); // 1 day ago + $user->expirePassword( $ts ); + $this->assertEquals( 'soft', $user->getPasswordExpired() ); + + $ts = time() - ( 3600 * 24 * 10 ); // 10 days ago + $user->expirePassword( $ts ); + $this->assertEquals( 'hard', $user->getPasswordExpired() ); + + $wgPasswordExpireGrace = $wgTemp; + } + + /** + * Test password validity checks. There are 3 checks in core, + * - ensure the password meets the minimal length + * - ensure the password is not the same as the username + * - ensure the username/password combo isn't forbidden + * @covers User::checkPasswordValidity() + * @covers User::getPasswordValidity() + * @covers User::isValidPassword() + */ + public function testCheckPasswordValidity() { + $this->setMwGlobals( array( + 'wgMinimalPasswordLength' => 6, + 'wgMaximalPasswordLength' => 30, + ) ); + $user = User::newFromName( 'Useruser' ); + // Sanity + $this->assertTrue( $user->isValidPassword( 'Password1234' ) ); + + // Minimum length + $this->assertFalse( $user->isValidPassword( 'a' ) ); + $this->assertFalse( $user->checkPasswordValidity( 'a' )->isGood() ); + $this->assertTrue( $user->checkPasswordValidity( 'a' )->isOK() ); + $this->assertEquals( 'passwordtooshort', $user->getPasswordValidity( 'a' ) ); + + // Maximum length + $longPass = str_repeat( 'a', 31 ); + $this->assertFalse( $user->isValidPassword( $longPass ) ); + $this->assertFalse( $user->checkPasswordValidity( $longPass )->isGood() ); + $this->assertFalse( $user->checkPasswordValidity( $longPass )->isOK() ); + $this->assertEquals( 'passwordtoolong', $user->getPasswordValidity( $longPass ) ); + + // Matches username + $this->assertFalse( $user->checkPasswordValidity( 'Useruser' )->isGood() ); + $this->assertTrue( $user->checkPasswordValidity( 'Useruser' )->isOK() ); + $this->assertEquals( 'password-name-match', $user->getPasswordValidity( 'Useruser' ) ); + + // On the forbidden list + $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() ); + $this->assertEquals( 'password-login-forbidden', $user->getPasswordValidity( 'Passpass' ) ); + } + + /** + * @covers User::getCanonicalName() + * @dataProvider provideGetCanonicalName + */ + public function testGetCanonicalName( $name, $expectedArray, $msg ) { + foreach ( $expectedArray as $validate => $expected ) { + $this->assertEquals( + User::getCanonicalName( $name, $validate === 'false' ? false : $validate ), + $expected, + $msg . ' (' . $validate . ')' + ); + } + } + + public static function provideGetCanonicalName() { + return array( + array( ' trailing space ', array( 'creatable' => 'Trailing space' ), 'Trailing spaces' ), + // @todo FIXME: Maybe the createable name should be 'Talk:Username' or false to reject? + array( 'Talk:Username', array( 'creatable' => 'Username', 'usable' => 'Username', + 'valid' => 'Username', 'false' => 'Talk:Username' ), 'Namespace prefix' ), + array( ' name with # hash', array( 'creatable' => false, 'usable' => false ), 'With hash' ), + array( 'Multi spaces', array( 'creatable' => 'Multi spaces', + 'usable' => 'Multi spaces' ), 'Multi spaces' ), + array( 'lowercase', array( 'creatable' => 'Lowercase' ), 'Lowercase' ), + array( 'in[]valid', array( 'creatable' => false, 'usable' => false, 'valid' => false, + 'false' => 'In[]valid' ), 'Invalid' ), + array( 'with / slash', array( 'creatable' => false, 'usable' => false, 'valid' => false, + 'false' => 'With / slash' ), 'With slash' ), + ); + } +} diff --git a/tests/phpunit/includes/WebRequestTest.php b/tests/phpunit/includes/WebRequestTest.php new file mode 100644 index 00000000..12d7d2a3 --- /dev/null +++ b/tests/phpunit/includes/WebRequestTest.php @@ -0,0 +1,358 @@ +oldServer = $_SERVER; + IP::clearCaches(); + } + + protected function tearDown() { + $_SERVER = $this->oldServer; + IP::clearCaches(); + + parent::tearDown(); + } + + /** + * @dataProvider provideDetectServer + * @covers WebRequest::detectServer + */ + public function testDetectServer( $expected, $input, $description ) { + $_SERVER = $input; + $result = WebRequest::detectServer(); + $this->assertEquals( $expected, $result, $description ); + } + + public static function provideDetectServer() { + return array( + array( + 'http://x', + array( + 'HTTP_HOST' => 'x' + ), + 'Host header' + ), + array( + 'https://x', + array( + 'HTTP_HOST' => 'x', + 'HTTPS' => 'on', + ), + 'Host header with secure' + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'SERVER_PORT' => 80, + ), + 'Default SERVER_PORT', + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'HTTPS' => 'off', + ), + 'Secure off' + ), + array( + 'http://y', + array( + 'SERVER_NAME' => 'y', + ), + 'Server name' + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'SERVER_NAME' => 'y', + ), + 'Host server name precedence' + ), + array( + 'http://[::1]:81', + array( + 'HTTP_HOST' => '[::1]', + 'SERVER_NAME' => '::1', + 'SERVER_PORT' => '81', + ), + 'Apache bug 26005' + ), + array( + 'http://localhost', + array( + 'SERVER_NAME' => '[2001' + ), + 'Kind of like lighttpd per commit message in MW r83847', + ), + array( + 'http://[2a01:e35:2eb4:1::2]:777', + array( + 'SERVER_NAME' => '[2a01:e35:2eb4:1::2]:777' + ), + 'Possible lighttpd environment per bug 14977 comment 13', + ), + ); + } + + /** + * @dataProvider provideGetIP + * @covers WebRequest::getIP + */ + public function testGetIP( $expected, $input, $squid, $xffList, $private, $description ) { + $_SERVER = $input; + $this->setMwGlobals( array( + 'wgSquidServersNoPurge' => $squid, + 'wgUsePrivateIPs' => $private, + 'wgHooks' => array( + 'IsTrustedProxy' => array( + function ( &$ip, &$trusted ) use ( $xffList ) { + $trusted = $trusted || in_array( $ip, $xffList ); + return true; + } + ) + ) + ) ); + + $request = new WebRequest(); + $result = $request->getIP(); + $this->assertEquals( $expected, $result, $description ); + } + + public static function provideGetIP() { + return array( + array( + '127.0.0.1', + array( + 'REMOTE_ADDR' => '127.0.0.1' + ), + array(), + array(), + false, + 'Simple IPv4' + ), + array( + '::1', + array( + 'REMOTE_ADDR' => '::1' + ), + array(), + array(), + false, + 'Simple IPv6' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777', + ), + array( 'ABCD:1:2:3:4:555:6666:7777' ), + array(), + false, + 'IPv6 normalisation' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array(), + false, + 'With X-Forwaded-For' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array(), + false, + 'With X-Forwaded-For and disallowed server' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1' ), + array(), + false, + 'With multiple X-Forwaded-For and only one allowed server' + ), + array( + '10.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array(), + false, + 'With X-Forwaded-For and private IP (from cache proxy)' + ), + array( + '10.0.0.4', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2', '10.0.0.3' ), + array(), + true, + 'With X-Forwaded-For and private IP (allowed)' + ), + array( + '10.0.0.4', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array( '10.0.0.3' ), + true, + 'With X-Forwaded-For and private IP (allowed)' + ), + array( + '10.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array( '10.0.0.3' ), + false, + 'With X-Forwaded-For and private IP (disallowed)' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.1', '12.0.0.2' ), + false, + 'With X-Forwaded-For' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.1' ), + false, + 'With multiple X-Forwaded-For and only one allowed server' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.2' ), + false, + 'With X-Forwaded-For and private IP and hook (disallowed)' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777', + ), + array( 'ABCD:1:2:3::/64' ), + array(), + false, + 'IPv6 CIDR' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.0/24' ), + array(), + false, + 'IPv4 CIDR' + ), + ); + } + + /** + * @expectedException MWException + * @covers WebRequest::getIP + */ + public function testGetIpLackOfRemoteAddrThrowAnException() { + // ensure that local install state doesn't interfere with test + $this->setMwGlobals( array( + 'wgSquidServersNoPurge' => array(), + 'wgSquidServers' => array(), + 'wgUsePrivateIPs' => false, + 'wgHooks' => array(), + ) ); + + $request = new WebRequest(); + # Next call throw an exception about lacking an IP + $request->getIP(); + } + + public static function provideLanguageData() { + return array( + array( '', array(), 'Empty Accept-Language header' ), + array( 'en', array( 'en' => 1 ), 'One language' ), + array( 'en, ar', array( 'en' => 1, 'ar' => 1 ), 'Two languages listed in appearance order.' ), + array( + 'zh-cn,zh-tw', + array( 'zh-cn' => 1, 'zh-tw' => 1 ), + 'Two equally prefered languages, listed in appearance order per rfc3282. Checks c9119' + ), + array( + 'es, en; q=0.5', + array( 'es' => 1, 'en' => '0.5' ), + 'Spanish as first language and English and second' + ), + array( 'en; q=0.5, es', array( 'es' => 1, 'en' => '0.5' ), 'Less prefered language first' ), + array( 'fr, en; q=0.5, es', array( 'fr' => 1, 'es' => 1, 'en' => '0.5' ), 'Three languages' ), + array( 'en; q=0.5, es', array( 'es' => 1, 'en' => '0.5' ), 'Two languages' ), + array( 'en, zh;q=0', array( 'en' => 1 ), "It's Chinese to me" ), + array( + 'es; q=1, pt;q=0.7, it; q=0.6, de; q=0.1, ru;q=0', + array( 'es' => '1', 'pt' => '0.7', 'it' => '0.6', 'de' => '0.1' ), + 'Preference for Romance languages' + ), + array( + 'en-gb, en-us; q=1', + array( 'en-gb' => 1, 'en-us' => '1' ), + 'Two equally prefered English variants' + ), + ); + } + + /** + * @dataProvider provideLanguageData + * @covers WebRequest::getAcceptLang + */ + public function testAcceptLang( $acceptLanguageHeader, $expectedLanguages, $description ) { + $_SERVER = array( 'HTTP_ACCEPT_LANGUAGE' => $acceptLanguageHeader ); + $request = new WebRequest(); + $this->assertSame( $request->getAcceptLang(), $expectedLanguages, $description ); + } +} diff --git a/tests/phpunit/includes/WikiPageTest.php b/tests/phpunit/includes/WikiPageTest.php new file mode 100644 index 00000000..7f7945b8 --- /dev/null +++ b/tests/phpunit/includes/WikiPageTest.php @@ -0,0 +1,1301 @@ +tablesUsed = array_merge( + $this->tablesUsed, + array( 'page', + 'revision', + 'text', + + 'recentchanges', + 'logging', + + 'page_props', + 'pagelinks', + 'categorylinks', + 'langlinks', + 'externallinks', + 'imagelinks', + 'templatelinks', + 'iwlinks' ) ); + } + + protected function setUp() { + parent::setUp(); + $this->pages_to_delete = array(); + + LinkCache::singleton()->clear(); # avoid cached redirect status, etc + } + + protected function tearDown() { + foreach ( $this->pages_to_delete as $p ) { + /* @var $p WikiPage */ + + try { + if ( $p->exists() ) { + $p->doDeleteArticle( "testing done." ); + } + } catch ( MWException $ex ) { + // fail silently + } + } + parent::tearDown(); + } + + /** + * @param Title $title + * @param string $model + * @return WikiPage + */ + protected function newPage( $title, $model = null ) { + if ( is_string( $title ) ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + } + + $p = new WikiPage( $title ); + + $this->pages_to_delete[] = $p; + + return $p; + } + + /** + * @param string|Title|WikiPage $page + * @param string $text + * @param int $model + * + * @return WikiPage + */ + protected function createPage( $page, $text, $model = null ) { + if ( is_string( $page ) || $page instanceof Title ) { + $page = $this->newPage( $page, $model ); + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); + + return $page; + } + + /** + * @covers WikiPage::doEditContent + */ + public function testDoEditContent() { + $page = $this->newPage( "WikiPageTest_testDoEditContent" ); + $title = $page->getTitle(); + + $content = ContentHandler::makeContent( + "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.", + $title, + CONTENT_MODEL_WIKITEXT + ); + + $page->doEditContent( $content, "[[testing]] 1" ); + + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); + $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); + $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); + + $id = $page->getId(); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $content = ContentHandler::makeContent( + "At vero eos et accusam et justo duo [[dolores]] et ea rebum. " + . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.", + $title, + CONTENT_MODEL_WIKITEXT + ); + + $page->doEditContent( $content, "testing 2" ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); + } + + /** + * @covers WikiPage::doEdit + */ + public function testDoEdit() { + $this->hideDeprecated( "WikiPage::doEdit" ); + $this->hideDeprecated( "WikiPage::getText" ); + $this->hideDeprecated( "Revision::getText" ); + + //NOTE: assume help namespace will default to wikitext + $title = Title::newFromText( "Help:WikiPageTest_testDoEdit" ); + + $page = $this->newPage( $title ); + + $text = "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat."; + + $page->doEdit( $text, "[[testing]] 1" ); + + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); + $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); + $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); + + $id = $page->getId(); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getText(); + $this->assertEquals( $text, $retrieved, 'retrieved text doesn\'t equal original' ); + + # ------------------------ + $text = "At vero eos et accusam et justo duo [[dolores]] et ea rebum. " + . "Stet clita kasd [[gubergren]], no sea takimata sanctus est."; + + $page->doEdit( $text, "testing 2" ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getText(); + $this->assertEquals( $text, $retrieved, 'retrieved text doesn\'t equal original' ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); + } + + /** + * @covers WikiPage::doQuickEdit + */ + public function testDoQuickEdit() { + global $wgUser; + + $this->hideDeprecated( "WikiPage::doQuickEdit" ); + + //NOTE: assume help namespace will default to wikitext + $page = $this->createPage( "Help:WikiPageTest_testDoQuickEdit", "original text" ); + + $text = "quick text"; + $page->doQuickEdit( $text, $wgUser, "testing q" ); + + # --------------------- + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $text, $page->getText() ); + } + + /** + * @covers WikiPage::doQuickEditContent + */ + public function testDoQuickEditContent() { + global $wgUser; + + $page = $this->createPage( + "WikiPageTest_testDoQuickEditContent", + "original text", + CONTENT_MODEL_WIKITEXT + ); + + $content = ContentHandler::makeContent( + "quick text", + $page->getTitle(), + CONTENT_MODEL_WIKITEXT + ); + $page->doQuickEditContent( $content, $wgUser, "testing q" ); + + # --------------------- + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $content->equals( $page->getContent() ) ); + } + + /** + * @covers WikiPage::doDeleteArticle + */ + public function testDoDeleteArticle() { + $page = $this->createPage( + "WikiPageTest_testDoDeleteArticle", + "[[original text]] foo", + CONTENT_MODEL_WIKITEXT + ); + $id = $page->getId(); + + $page->doDeleteArticle( "testing deletion" ); + + $this->assertFalse( + $page->getTitle()->getArticleID() > 0, + "Title object should now have page id 0" + ); + $this->assertFalse( $page->getId() > 0, "WikiPage should now have page id 0" ); + $this->assertFalse( + $page->exists(), + "WikiPage::exists should return false after page was deleted" + ); + $this->assertNull( + $page->getContent(), + "WikiPage::getContent should return null after page was deleted" + ); + $this->assertFalse( + $page->getText(), + "WikiPage::getText should return false after page was deleted" + ); + + $t = Title::newFromText( $page->getTitle()->getPrefixedText() ); + $this->assertFalse( + $t->exists(), + "Title::exists should return false after page was deleted" + ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' ); + } + + /** + * @covers WikiPage::doDeleteUpdates + */ + public function testDoDeleteUpdates() { + $page = $this->createPage( + "WikiPageTest_testDoDeleteArticle", + "[[original text]] foo", + CONTENT_MODEL_WIKITEXT + ); + $id = $page->getId(); + + $page->doDeleteUpdates( $id ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' ); + } + + /** + * @covers WikiPage::getRevision + */ + public function testGetRevision() { + $page = $this->newPage( "WikiPageTest_testGetRevision" ); + + $rev = $page->getRevision(); + $this->assertNull( $rev ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $rev = $page->getRevision(); + + $this->assertEquals( $page->getLatest(), $rev->getId() ); + $this->assertEquals( "some text", $rev->getContent()->getNativeData() ); + } + + /** + * @covers WikiPage::getContent + */ + public function testGetContent() { + $page = $this->newPage( "WikiPageTest_testGetContent" ); + + $content = $page->getContent(); + $this->assertNull( $content ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $content = $page->getContent(); + $this->assertEquals( "some text", $content->getNativeData() ); + } + + /** + * @covers WikiPage::getText + */ + public function testGetText() { + $this->hideDeprecated( "WikiPage::getText" ); + + $page = $this->newPage( "WikiPageTest_testGetText" ); + + $text = $page->getText(); + $this->assertFalse( $text ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $text = $page->getText(); + $this->assertEquals( "some text", $text ); + } + + /** + * @covers WikiPage::getRawText + */ + public function testGetRawText() { + $this->hideDeprecated( "WikiPage::getRawText" ); + + $page = $this->newPage( "WikiPageTest_testGetRawText" ); + + $text = $page->getRawText(); + $this->assertFalse( $text ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $text = $page->getRawText(); + $this->assertEquals( "some text", $text ); + } + + /** + * @covers WikiPage::getContentModel + */ + public function testGetContentModel() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $page = $this->createPage( + "WikiPageTest_testGetContentModel", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $page->getContentModel() ); + } + + /** + * @covers WikiPage::getContentHandler + */ + public function testGetContentHandler() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $page = $this->createPage( + "WikiPageTest_testGetContentHandler", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'JavaScriptContentHandler', get_class( $page->getContentHandler() ) ); + } + + /** + * @covers WikiPage::exists + */ + public function testExists() { + $page = $this->newPage( "WikiPageTest_testExists" ); + $this->assertFalse( $page->exists() ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + $this->assertTrue( $page->exists() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $page->exists() ); + + # ----------------- + $page->doDeleteArticle( "done testing" ); + $this->assertFalse( $page->exists() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertFalse( $page->exists() ); + } + + public static function provideHasViewableContent() { + return array( + array( 'WikiPageTest_testHasViewableContent', false, true ), + array( 'Special:WikiPageTest_testHasViewableContent', false ), + array( 'MediaWiki:WikiPageTest_testHasViewableContent', false ), + array( 'Special:Userlogin', true ), + array( 'MediaWiki:help', true ), + ); + } + + /** + * @dataProvider provideHasViewableContent + * @covers WikiPage::hasViewableContent + */ + public function testHasViewableContent( $title, $viewable, $create = false ) { + $page = $this->newPage( $title ); + $this->assertEquals( $viewable, $page->hasViewableContent() ); + + if ( $create ) { + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + $this->assertTrue( $page->hasViewableContent() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $page->hasViewableContent() ); + } + } + + public static function provideGetRedirectTarget() { + return array( + array( 'WikiPageTest_testGetRedirectTarget_1', CONTENT_MODEL_WIKITEXT, "hello world", null ), + array( + 'WikiPageTest_testGetRedirectTarget_2', + CONTENT_MODEL_WIKITEXT, + "#REDIRECT [[hello world]]", + "Hello world" + ), + ); + } + + /** + * @dataProvider provideGetRedirectTarget + * @covers WikiPage::getRedirectTarget + */ + public function testGetRedirectTarget( $title, $model, $text, $target ) { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $page = $this->createPage( $title, $text, $model ); + + # sanity check, because this test seems to fail for no reason for some people. + $c = $page->getContent(); + $this->assertEquals( 'WikitextContent', get_class( $c ) ); + + # now, test the actual redirect + $t = $page->getRedirectTarget(); + $this->assertEquals( $target, is_null( $t ) ? null : $t->getPrefixedText() ); + } + + /** + * @dataProvider provideGetRedirectTarget + * @covers WikiPage::isRedirect + */ + public function testIsRedirect( $title, $model, $text, $target ) { + $page = $this->createPage( $title, $text, $model ); + $this->assertEquals( !is_null( $target ), $page->isRedirect() ); + } + + public static function provideIsCountable() { + return array( + + // any + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '', + 'any', + true + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'any', + true + ), + + // comma + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'comma', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo, bar', + 'comma', + true + ), + + // link + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'link', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo [[bar]]', + 'link', + true + ), + + // redirects + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'any', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'comma', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'link', + false + ), + + // not a content namespace + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'any', + false + ), + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo, bar', + 'comma', + false + ), + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo [[bar]]', + 'link', + false + ), + + // not a content namespace, different model + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo', + 'any', + false + ), + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo, bar', + 'comma', + false + ), + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo [[bar]]', + 'link', + false + ), + ); + } + + /** + * @dataProvider provideIsCountable + * @covers WikiPage::isCountable + */ + public function testIsCountable( $title, $model, $text, $mode, $expected ) { + global $wgContentHandlerUseDB; + + $this->setMwGlobals( 'wgArticleCountMethod', $mode ); + + $title = Title::newFromText( $title ); + + if ( !$wgContentHandlerUseDB + && $model + && ContentHandler::getDefaultModelFor( $title ) != $model + ) { + $this->markTestSkipped( "Can not use non-default content model $model for " + . $title->getPrefixedDBkey() . " with \$wgContentHandlerUseDB disabled." ); + } + + $page = $this->createPage( $title, $text, $model ); + + $editInfo = $page->prepareContentForEdit( $page->getContent() ); + + $v = $page->isCountable(); + $w = $page->isCountable( $editInfo ); + + $this->assertEquals( + $expected, + $v, + "isCountable( null ) returned unexpected value " . var_export( $v, true ) + . " instead of " . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + + $this->assertEquals( + $expected, + $w, + "isCountable( \$editInfo ) returned unexpected value " . var_export( $v, true ) + . " instead of " . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + } + + public static function provideGetParserOutput() { + return array( + array( CONTENT_MODEL_WIKITEXT, "hello ''world''\n", "

hello world

" ), + // @todo more...? + ); + } + + /** + * @dataProvider provideGetParserOutput + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput( $model, $text, $expectedHtml ) { + $page = $this->createPage( 'WikiPageTest_testGetParserOutput', $text, $model ); + + $opt = $page->makeParserOptions( 'canonical' ); + $po = $page->getParserOutput( $opt ); + $text = $po->getText(); + + $text = trim( preg_replace( '//sm', '', $text ) ); # strip injected comments + $text = preg_replace( '!\s*(

)!sm', '\1', $text ); # don't let tidy confuse us + + $this->assertEquals( $expectedHtml, $text ); + + return $po; + } + + /** + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput_nonexisting() { + static $count = 0; + $count++; + + $page = new WikiPage( new Title( "WikiPageTest_testGetParserOutput_nonexisting_$count" ) ); + + $opt = new ParserOptions(); + $po = $page->getParserOutput( $opt ); + + $this->assertFalse( $po, "getParserOutput() shall return false for non-existing pages." ); + } + + /** + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput_badrev() { + $page = $this->createPage( 'WikiPageTest_testGetParserOutput', "dummy", CONTENT_MODEL_WIKITEXT ); + + $opt = new ParserOptions(); + $po = $page->getParserOutput( $opt, $page->getLatest() + 1234 ); + + // @todo would be neat to also test deleted revision + + $this->assertFalse( $po, "getParserOutput() shall return false for non-existing revisions." ); + } + + public static $sections = + + "Intro + +== stuff == +hello world + +== test == +just a test + +== foo == +more stuff +"; + + public function dataReplaceSection() { + //NOTE: assume the Help namespace to contain wikitext + return array( + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "0", + "No more", + null, + trim( preg_replace( '/^Intro/sm', 'No more', WikiPageTest::$sections ) ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "", + "No more", + null, + "No more" + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + trim( preg_replace( '/^== test ==.*== foo ==/sm', + "== TEST ==\nmore fun\n\n== foo ==", + WikiPageTest::$sections ) ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "8", + "No more", + null, + trim( WikiPageTest::$sections ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "new", + "No more", + "New", + trim( WikiPageTest::$sections ) . "\n\n== New ==\n\nNo more" + ), + ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSection + */ + public function testReplaceSection( $title, $model, $text, $section, $with, + $sectionTitle, $expected + ) { + $this->hideDeprecated( "WikiPage::replaceSection" ); + + $page = $this->createPage( $title, $text, $model ); + $text = $page->replaceSection( $section, $with, $sectionTitle ); + $text = trim( $text ); + + $this->assertEquals( $expected, $text ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSectionContent + */ + public function testReplaceSectionContent( $title, $model, $text, $section, + $with, $sectionTitle, $expected + ) { + $page = $this->createPage( $title, $text, $model ); + + $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() ); + $c = $page->replaceSectionContent( $section, $content, $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSectionAtRev + */ + public function testReplaceSectionAtRev( $title, $model, $text, $section, + $with, $sectionTitle, $expected + ) { + $page = $this->createPage( $title, $text, $model ); + $baseRevId = $page->getLatest(); + + $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() ); + $c = $page->replaceSectionAtRev( $section, $content, $sectionTitle, $baseRevId ); + + $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) ); + } + + /* @todo FIXME: fix this! + public function testGetUndoText() { + $this->checkHasDiff3(); + + $text = "one"; + $page = $this->createPage( "WikiPageTest_testGetUndoText", $text ); + $rev1 = $page->getRevision(); + + $text .= "\n\ntwo"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section two" + ); + $rev2 = $page->getRevision(); + + $text .= "\n\nthree"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section three" + ); + $rev3 = $page->getRevision(); + + $text .= "\n\nfour"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section four" + ); + $rev4 = $page->getRevision(); + + $text .= "\n\nfive"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section five" + ); + $rev5 = $page->getRevision(); + + $text .= "\n\nsix"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section six" + ); + $rev6 = $page->getRevision(); + + $undo6 = $page->getUndoText( $rev6 ); + if ( $undo6 === false ) $this->fail( "getUndoText failed for rev6" ); + $this->assertEquals( "one\n\ntwo\n\nthree\n\nfour\n\nfive", $undo6 ); + + $undo3 = $page->getUndoText( $rev4, $rev2 ); + if ( $undo3 === false ) $this->fail( "getUndoText failed for rev4..rev2" ); + $this->assertEquals( "one\n\ntwo\n\nfive", $undo3 ); + + $undo2 = $page->getUndoText( $rev2 ); + if ( $undo2 === false ) $this->fail( "getUndoText failed for rev2" ); + $this->assertEquals( "one\n\nfive", $undo2 ); + } + */ + + /** + * @todo FIXME: this is a better rollback test than the one below, but it + * keeps failing in jenkins for some reason. + */ + public function broken_testDoRollback() { + $admin = new User(); + $admin->setName( "Admin" ); + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "section one", EDIT_NEW, false, $admin ); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section two", 0, false, $user1 ); + + $user2 = new User(); + $user2->setName( "127.0.2.13" ); + $text .= "\n\nthree"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section three", 0, false, $user2 ); + + # we are having issues with doRollback spuriously failing. Apparently + # the last revision somehow goes missing or not committed under some + # circumstances. So, make sure the last revision has the right user name. + $dbr = wfGetDB( DB_SLAVE ); + $this->assertEquals( 3, Revision::countByPageId( $dbr, $page->getId() ) ); + + $page = new WikiPage( $page->getTitle() ); + $rev3 = $page->getRevision(); + $this->assertEquals( '127.0.2.13', $rev3->getUserText() ); + + $rev2 = $rev3->getPrevious(); + $this->assertEquals( '127.0.1.11', $rev2->getUserText() ); + + $rev1 = $rev2->getPrevious(); + $this->assertEquals( 'Admin', $rev1->getUserText() ); + + # now, try the actual rollback + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user2->getName() ), + null + ); + $errors = $page->doRollback( + $user2->getName(), + "testing revert", + $token, + false, + $details, + $admin + ); + + if ( $errors ) { + $this->fail( "Rollback failed:\n" . print_r( $errors, true ) + . ";\n" . print_r( $details, true ) ); + } + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev2->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one\n\ntwo", $page->getContent()->getNativeData() ); + } + + /** + * @todo FIXME: the above rollback test is better, but it keeps failing in jenkins for some reason. + * @covers WikiPage::doRollback + */ + public function testDoRollback() { + $admin = new User(); + $admin->setName( "Admin" ); + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "section one", + EDIT_NEW, + false, + $admin + ); + $rev1 = $page->getRevision(); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "adding section two", + 0, + false, + $user1 + ); + + # now, try the rollback + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert", + $token, + false, + $details, + $admin + ); + + if ( $errors ) { + $this->fail( "Rollback failed:\n" . print_r( $errors, true ) + . ";\n" . print_r( $details, true ) ); + } + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one", $page->getContent()->getNativeData() ); + } + + /** + * @covers WikiPage::doRollback + */ + public function testDoRollbackFailureSameContent() { + $admin = new User(); + $admin->setName( "Admin" ); + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "section one", + EDIT_NEW, + false, + $admin + ); + $rev1 = $page->getRevision(); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $user1->addGroup( "sysop" ); #XXX: make the test user a sysop... + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "adding section two", + 0, + false, + $user1 + ); + + # now, do a the rollback from the same user was doing the edit before + $resultDetails = array(); + $token = $user1->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert same user", + $token, + false, + $resultDetails, + $admin + ); + + $this->assertEquals( array(), $errors, "Rollback failed same user" ); + + # now, try the rollback + $resultDetails = array(); + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert", + $token, + false, + $resultDetails, + $admin + ); + + $this->assertEquals( array( array( 'alreadyrolled', 'WikiPageTest testDoRollback', + '127.0.1.11', 'Admin' ) ), $errors, "Rollback not failed" ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one", $page->getContent()->getNativeData() ); + } + + public static function provideGetAutosummary() { + return array( + array( + 'Hello there, world!', + '#REDIRECT [[Foo]]', + 0, + '/^Redirected page .*Foo/' + ), + + array( + null, + 'Hello world!', + EDIT_NEW, + '/^Created page .*Hello/' + ), + + array( + 'Hello there, world!', + '', + 0, + '/^Blanked/' + ), + + array( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Hello world!', + 0, + '/^Replaced .*Hello/' + ), + + array( + 'foo', + 'bar', + 0, + '/^$/' + ), + ); + } + + /** + * @dataProvider provideGetAutoSummary + * @covers WikiPage::getAutosummary + */ + public function testGetAutosummary( $old, $new, $flags, $expected ) { + $this->hideDeprecated( "WikiPage::getAutosummary" ); + + $page = $this->newPage( "WikiPageTest_testGetAutosummary" ); + + $summary = $page->getAutosummary( $old, $new, $flags ); + + $this->assertTrue( (bool)preg_match( $expected, $summary ), + "Autosummary didn't match expected pattern $expected: $summary" ); + } + + public static function provideGetAutoDeleteReason() { + return array( + array( + array(), + false, + false + ), + + array( + array( + array( "first edit", null ), + ), + "/first edit.*only contributor/", + false + ), + + array( + array( + array( "first edit", null ), + array( "second edit", null ), + ), + "/second edit.*only contributor/", + true + ), + + array( + array( + array( "first edit", "127.0.2.22" ), + array( "second edit", "127.0.3.33" ), + ), + "/second edit/", + true + ), + + array( + array( + array( + "first edit: " + . "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna " + . "aliquyam erat, sed diam voluptua. At vero eos et accusam " + . "et justo duo dolores et ea rebum. Stet clita kasd gubergren, " + . "no sea takimata sanctus est Lorem ipsum dolor sit amet.'", + null + ), + ), + '/first edit:.*\.\.\."/', + false + ), + + array( + array( + array( "first edit", "127.0.2.22" ), + array( "", "127.0.3.33" ), + ), + "/before blanking.*first edit/", + true + ), + + ); + } + + /** + * @dataProvider provideGetAutoDeleteReason + * @covers WikiPage::getAutoDeleteReason + */ + public function testGetAutoDeleteReason( $edits, $expectedResult, $expectedHistory ) { + global $wgUser; + + //NOTE: assume Help namespace to contain wikitext + $page = $this->newPage( "Help:WikiPageTest_testGetAutoDeleteReason" ); + + $c = 1; + + foreach ( $edits as $edit ) { + $user = new User(); + + if ( !empty( $edit[1] ) ) { + $user->setName( $edit[1] ); + } else { + $user = $wgUser; + } + + $content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() ); + + $page->doEditContent( $content, "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user ); + + $c += 1; + } + + $reason = $page->getAutoDeleteReason( $hasHistory ); + + if ( is_bool( $expectedResult ) || is_null( $expectedResult ) ) { + $this->assertEquals( $expectedResult, $reason ); + } else { + $this->assertTrue( (bool)preg_match( $expectedResult, $reason ), + "Autosummary didn't match expected pattern $expectedResult: $reason" ); + } + + $this->assertEquals( $expectedHistory, $hasHistory, + "expected \$hasHistory to be " . var_export( $expectedHistory, true ) ); + + $page->doDeleteArticle( "done" ); + } + + public static function providePreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is ~~~', + 'hello \'\'this\'\' is ~~~', + ), + ); + } + + /** + * @dataProvider providePreSaveTransform + * @covers WikiPage::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + $this->hideDeprecated( 'WikiPage::preSaveTransform' ); + $user = new User(); + $user->setName( "127.0.0.1" ); + + //NOTE: assume Help namespace to contain wikitext + $page = $this->newPage( "Help:WikiPageTest_testPreloadTransform" ); + $text = $page->preSaveTransform( $text, $user ); + + $this->assertEquals( $expected, $text ); + } + + /** + * @covers WikiPage::factory + */ + public function testWikiPageFactory() { + $title = Title::makeTitle( NS_FILE, 'Someimage.png' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiFilePage', get_class( $page ) ); + + $title = Title::makeTitle( NS_CATEGORY, 'SomeCategory' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiCategoryPage', get_class( $page ) ); + + $title = Title::makeTitle( NS_MAIN, 'SomePage' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiPage', get_class( $page ) ); + } +} diff --git a/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php b/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php new file mode 100644 index 00000000..3db76280 --- /dev/null +++ b/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php @@ -0,0 +1,61 @@ +setMwGlobals( 'wgContentHandlerUseDB', false ); + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + } + + /** + * @covers WikiPage::getContentModel + */ + public function testGetContentModel() { + $page = $this->createPage( + "WikiPageTest_testGetContentModel", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $page->getContentModel() ); + } + + /** + * @covers WikiPage::getContentHandler + */ + public function testGetContentHandler() { + $page = $this->createPage( + "WikiPageTest_testGetContentHandler", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'WikitextContentHandler', get_class( $page->getContentHandler() ) ); + } +} diff --git a/tests/phpunit/includes/XmlJsTest.php b/tests/phpunit/includes/XmlJsTest.php new file mode 100644 index 00000000..0dbb0109 --- /dev/null +++ b/tests/phpunit/includes/XmlJsTest.php @@ -0,0 +1,24 @@ +assertEquals( $value, $obj->value ); + } + + public static function provideConstruction() { + return array( + array( null ), + array( '' ), + ); + } + +} diff --git a/tests/phpunit/includes/XmlSelectTest.php b/tests/phpunit/includes/XmlSelectTest.php new file mode 100644 index 00000000..9f154bb7 --- /dev/null +++ b/tests/phpunit/includes/XmlSelectTest.php @@ -0,0 +1,185 @@ +setMwGlobals( array( + 'wgWellFormedXml' => true, + ) ); + $this->select = new XmlSelect(); + } + + protected function tearDown() { + parent::tearDown(); + $this->select = null; + } + + /** + * @covers XmlSelect::__construct + */ + public function testConstructWithoutParameters() { + $this->assertEquals( '', $this->select->getHTML() ); + } + + /** + * Parameters are $name (false), $id (false), $default (false) + * @dataProvider provideConstructionParameters + * @covers XmlSelect::__construct + */ + public function testConstructParameters( $name, $id, $default, $expected ) { + $this->select = new XmlSelect( $name, $id, $default ); + $this->assertEquals( $expected, $this->select->getHTML() ); + } + + /** + * Provide parameters for testConstructParameters() which use three + * parameters: + * - $name (default: false) + * - $id (default: false) + * - $default (default: false) + * Provides a fourth parameters representing the expected HTML output + */ + public static function provideConstructionParameters() { + return array( + /** + * Values are set following a 3-bit Gray code where two successive + * values differ by only one value. + * See http://en.wikipedia.org/wiki/Gray_code + */ + # $name $id $default + array( false, false, false, '' ), + array( false, false, 'foo', '' ), + array( false, 'id', 'foo', '' ), + array( false, 'id', false, '' ), + array( 'name', 'id', false, '' ), + array( 'name', 'id', 'foo', '' ), + array( 'name', false, 'foo', '' ), + array( 'name', false, false, '' ), + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOption() { + $this->select->addOption( 'foo' ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithDefault() { + $this->select->addOption( 'foo', true ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithFalse() { + $this->select->addOption( 'foo', false ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithValueZero() { + $this->select->addOption( 'foo', 0 ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::setDefault + */ + public function testSetDefault() { + $this->select->setDefault( 'bar1' ); + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * Adding default later on should set the correct selection or + * raise an exception. + * To handle this, we need to render the options in getHtml() + * @covers XmlSelect::setDefault + */ + public function testSetDefaultAfterAddingOptions() { + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->select->setDefault( 'bar1' ); # setting default after adding options + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * @covers XmlSelect::setAttribute + * @covers XmlSelect::getAttribute + */ + public function testGetAttributes() { + # create some attributes + $this->select->setAttribute( 'dummy', 0x777 ); + $this->select->setAttribute( 'string', 'euro €' ); + $this->select->setAttribute( 1911, 'razor' ); + + # verify we can retrieve them + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + $this->assertEquals( + $this->select->getAttribute( 'string' ), + 'euro €' + ); + $this->assertEquals( + $this->select->getAttribute( 1911 ), + 'razor' + ); + + # inexistant keys should give us 'null' + $this->assertEquals( + $this->select->getAttribute( 'I DO NOT EXIT' ), + null + ); + + # verify string / integer + $this->assertEquals( + $this->select->getAttribute( '1911' ), + 'razor' + ); + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + } +} diff --git a/tests/phpunit/includes/XmlTest.php b/tests/phpunit/includes/XmlTest.php new file mode 100644 index 00000000..e6558819 --- /dev/null +++ b/tests/phpunit/includes/XmlTest.php @@ -0,0 +1,411 @@ +setNamespaces( array( + -2 => 'Media', + -1 => 'Special', + 0 => '', + 1 => 'Talk', + 2 => 'User', + 3 => 'User_talk', + 4 => 'MyWiki', + 5 => 'MyWiki_Talk', + 6 => 'File', + 7 => 'File_talk', + 8 => 'MediaWiki', + 9 => 'MediaWiki_talk', + 10 => 'Template', + 11 => 'Template_talk', + 100 => 'Custom', + 101 => 'Custom_talk', + ) ); + + $this->setMwGlobals( array( + 'wgLang' => $langObj, + 'wgWellFormedXml' => true, + ) ); + } + + /** + * @covers Xml::expandAttributes + */ + public function testExpandAttributes() { + $this->assertNull( Xml::expandAttributes( null ), + 'Converting a null list of attributes' + ); + $this->assertEquals( '', Xml::expandAttributes( array() ), + 'Converting an empty list of attributes' + ); + } + + /** + * @covers Xml::expandAttributes + */ + public function testExpandAttributesException() { + $this->setExpectedException( 'MWException' ); + Xml::expandAttributes( 'string' ); + } + + /** + * @covers Xml::element + */ + public function testElementOpen() { + $this->assertEquals( + '', + Xml::element( 'element', null, null ), + 'Opening element with no attributes' + ); + } + + /** + * @covers Xml::element + */ + public function testElementEmpty() { + $this->assertEquals( + '', + Xml::element( 'element', null, '' ), + 'Terminated empty element' + ); + } + + /** + * @covers Xml::input + */ + public function testElementInputCanHaveAValueOfZero() { + $this->assertEquals( + '', + Xml::input( 'name', false, 0 ), + 'Input with a value of 0 (bug 23797)' + ); + } + + /** + * @covers Xml::element + */ + public function testElementEscaping() { + $this->assertEquals( + 'hello <there> you & you', + Xml::element( 'element', null, 'hello you & you' ), + 'Element with no attributes and content that needs escaping' + ); + } + + /** + * @covers Xml::escapeTagsOnly + */ + public function testEscapeTagsOnly() { + $this->assertEquals( '"><', Xml::escapeTagsOnly( '"><' ), + 'replace " > and < with their HTML entitites' + ); + } + + /** + * @covers Xml::element + */ + public function testElementAttributes() { + $this->assertEquals( + '="<>">', + Xml::element( 'element', array( 'key' => 'value', '<>' => '<>' ), null ), + 'Element attributes, keys are not escaped' + ); + } + + /** + * @covers Xml::openElement + */ + public function testOpenElement() { + $this->assertEquals( + '', + Xml::openElement( 'element', array( 'k' => 'v' ) ), + 'openElement() shortcut' + ); + } + + /** + * @covers Xml::closeElement + */ + public function testCloseElement() { + $this->assertEquals( '', Xml::closeElement( 'element' ), 'closeElement() shortcut' ); + } + + /** + * @covers Xml::dateMenu + */ + public function testDateMenu() { + $curYear = intval( gmdate( 'Y' ) ); + $prevYear = $curYear - 1; + + $curMonth = intval( gmdate( 'n' ) ); + + $nextMonth = $curMonth + 1; + if ( $nextMonth == 13 ) { + $nextMonth = 1; + } + + $this->assertEquals( + ' ' . + ' ' . + ' ' . + '', + Xml::dateMenu( 2011, 02 ), + "Date menu for february 2011" + ); + $this->assertEquals( + ' ' . + ' ' . + ' ' . + '', + Xml::dateMenu( 2011, -1 ), + "Date menu with negative month for 'All'" + ); + $this->assertEquals( + Xml::dateMenu( $curYear, $curMonth ), + Xml::dateMenu( '', $curMonth ), + "Date menu year is the current one when not specified" + ); + + $wantedYear = $nextMonth == 1 ? $curYear : $prevYear; + $this->assertEquals( + Xml::dateMenu( $wantedYear, $nextMonth ), + Xml::dateMenu( '', $nextMonth ), + "Date menu next month is 11 months ago" + ); + + $this->assertEquals( + ' ' . + ' ' . + ' ' . + '', + Xml::dateMenu( '', '' ), + "Date menu with neither year or month" + ); + } + + /** + * @covers Xml::textarea + */ + public function testTextareaNoContent() { + $this->assertEquals( + '', + Xml::textarea( 'name', '' ), + 'textarea() with not content' + ); + } + + /** + * @covers Xml::textarea + */ + public function testTextareaAttribs() { + $this->assertEquals( + '', + Xml::textarea( 'name', '', 20, 10 ), + 'textarea() with custom attribs' + ); + } + + /** + * @covers Xml::label + */ + public function testLabelCreation() { + $this->assertEquals( + '', + Xml::label( 'name', 'id' ), + 'label() with no attribs' + ); + } + + /** + * @covers Xml::label + */ + public function testLabelAttributeCanOnlyBeClassOrTitle() { + $this->assertEquals( + '', + Xml::label( 'name', 'id', array( 'generated' => true ) ), + 'label() can not be given a generated attribute' + ); + $this->assertEquals( + '', + Xml::label( 'name', 'id', array( 'class' => 'nice' ) ), + 'label() can get a class attribute' + ); + $this->assertEquals( + '', + Xml::label( 'name', 'id', array( 'title' => 'nice tooltip' ) ), + 'label() can get a title attribute' + ); + $this->assertEquals( + '', + Xml::label( 'name', 'id', array( + 'generated' => true, + 'class' => 'nice', + 'title' => 'nice tooltip', + 'anotherattr' => 'value', + ) + ), + 'label() skip all attributes but "class" and "title"' + ); + } + + /** + * @covers Xml::languageSelector + */ + public function testLanguageSelector() { + $select = Xml::languageSelector( 'en', true, null, + array( 'id' => 'testlang' ), wfMessage( 'yourlanguage' ) ); + $this->assertEquals( + '', + $select[0] + ); + } + + /** + * @covers Xml::escapeJsString + */ + public function testEscapeJsStringSpecialChars() { + $this->assertEquals( + '\\\\\r\n', + Xml::escapeJsString( "\\\r\n" ), + 'escapeJsString() with special characters' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarBoolean() { + $this->assertEquals( + 'true', + Xml::encodeJsVar( true ), + 'encodeJsVar() with boolean' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarNull() { + $this->assertEquals( + 'null', + Xml::encodeJsVar( null ), + 'encodeJsVar() with null' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarArray() { + $this->assertEquals( + '["a",1]', + Xml::encodeJsVar( array( 'a', 1 ) ), + 'encodeJsVar() with array' + ); + $this->assertEquals( + '{"a":"a","b":1}', + Xml::encodeJsVar( array( 'a' => 'a', 'b' => 1 ) ), + 'encodeJsVar() with associative array' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarObject() { + $this->assertEquals( + '{"a":"a","b":1}', + Xml::encodeJsVar( (object)array( 'a' => 'a', 'b' => 1 ) ), + 'encodeJsVar() with object' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarInt() { + $this->assertEquals( + '123456', + Xml::encodeJsVar( 123456 ), + 'encodeJsVar() with int' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarFloat() { + $this->assertEquals( + '1.23456', + Xml::encodeJsVar( 1.23456 ), + 'encodeJsVar() with float' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarIntString() { + $this->assertEquals( + '"123456"', + Xml::encodeJsVar( '123456' ), + 'encodeJsVar() with int-like string' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarFloatString() { + $this->assertEquals( + '"1.23456"', + Xml::encodeJsVar( '1.23456' ), + 'encodeJsVar() with float-like string' + ); + } +} diff --git a/tests/phpunit/includes/XmlTypeCheckTest.php b/tests/phpunit/includes/XmlTypeCheckTest.php new file mode 100644 index 00000000..6ad97fd4 --- /dev/null +++ b/tests/phpunit/includes/XmlTypeCheckTest.php @@ -0,0 +1,49 @@ +"; + const MAL_FORMED_XML = ""; + const XML_WITH_PIH = ''; + + /** + * @covers XMLTypeCheck::newFromString + * @covers XMLTypeCheck::getRootElement + */ + public function testWellFormedXML() { + $testXML = XmlTypeCheck::newFromString( self::WELL_FORMED_XML ); + $this->assertTrue( $testXML->wellFormed ); + $this->assertEquals( 'root', $testXML->getRootElement() ); + } + + /** + * @covers XMLTypeCheck::newFromString + */ + public function testMalFormedXML() { + $testXML = XmlTypeCheck::newFromString( self::MAL_FORMED_XML ); + $this->assertFalse( $testXML->wellFormed ); + } + + /** + * @covers XMLTypeCheck::processingInstructionHandler + */ + public function testProcessingInstructionHandler() { + $called = false; + $testXML = new XmlTypeCheck( + self::XML_WITH_PIH, + null, + false, + array( + 'processing_instruction_handler' => function() use ( &$called ) { + $called = true; + } + ) + ); + $this->assertTrue( $called ); + } + +} diff --git a/tests/phpunit/includes/actions/ActionTest.php b/tests/phpunit/includes/actions/ActionTest.php new file mode 100644 index 00000000..cc6fb11a --- /dev/null +++ b/tests/phpunit/includes/actions/ActionTest.php @@ -0,0 +1,199 @@ +getContext(); + $this->setMwGlobals( 'wgActions', array( + 'null' => null, + 'disabled' => false, + 'view' => true, + 'edit' => true, + 'revisiondelete' => true, + 'dummy' => true, + 'string' => 'NamedDummyAction', + 'declared' => 'NonExistingClassName', + 'callable' => array( $this, 'dummyActionCallback' ), + 'object' => new InstantiatedDummyAction( $context->getWikiPage(), $context ), + ) ); + } + + private function getPage() { + return WikiPage::factory( Title::makeTitle( 0, 'Title' ) ); + } + + private function getContext( $requestedAction = null ) { + $request = new FauxRequest( array( 'action' => $requestedAction ) ); + + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( $request ); + $context->setWikiPage( $this->getPage() ); + + return $context; + } + + public function actionProvider() { + return array( + array( 'dummy', 'DummyAction' ), + array( 'string', 'NamedDummyAction' ), + array( 'callable', 'CalledDummyAction' ), + array( 'object', 'InstantiatedDummyAction' ), + + // Capitalization is ignored + array( 'DUMMY', 'DummyAction' ), + array( 'STRING', 'NamedDummyAction' ), + + // Null and non-existing values + array( 'null', null ), + array( 'undeclared', null ), + array( '', null ), + array( false, null ), + ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testActionExists( $requestedAction, $expected ) { + $exists = Action::exists( $requestedAction ); + + $this->assertSame( $expected !== null, $exists ); + } + + public function testActionExists_doesNotRequireInstantiation() { + // The method is not supposed to check if the action can be instantiated. + $exists = Action::exists( 'declared' ); + + $this->assertTrue( $exists ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testGetActionName( $requestedAction, $expected ) { + $context = $this->getContext( $requestedAction ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( $expected ?: 'nosuchaction', $actionName ); + } + + public function testGetActionName_editredlinkWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'editredlink' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'edit', $actionName ); + } + + public function testGetActionName_historysubmitWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'historysubmit' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'view', $actionName ); + } + + public function testGetActionName_revisiondeleteWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'historysubmit' ); + $context->getRequest()->setVal( 'revisiondelete', true ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'revisiondelete', $actionName ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testActionFactory( $requestedAction, $expected ) { + $context = $this->getContext(); + $action = Action::factory( $requestedAction, $context->getWikiPage(), $context ); + + $this->assertType( $expected ?: 'null', $action ); + } + + public function testNull_doesNotExist() { + $exists = Action::exists( null ); + + $this->assertFalse( $exists ); + } + + public function testNull_defaultsToView() { + $context = $this->getContext( null ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'view', $actionName ); + } + + public function testNull_canNotBeInstantiated() { + $page = $this->getPage(); + $action = Action::factory( null, $page ); + + $this->assertNull( $action ); + } + + public function testDisabledAction_exists() { + $exists = Action::exists( 'disabled' ); + + $this->assertTrue( $exists ); + } + + public function testDisabledAction_isNotResolved() { + $context = $this->getContext( 'disabled' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'nosuchaction', $actionName ); + } + + public function testDisabledAction_factoryReturnsFalse() { + $page = $this->getPage(); + $action = Action::factory( 'disabled', $page ); + + $this->assertFalse( $action ); + } + + public function dummyActionCallback() { + $context = $this->getContext(); + return new CalledDummyAction( $context->getWikiPage(), $context ); + } + +} + +class DummyAction extends Action { + + public function getName() { + return get_called_class(); + } + + public function show() { + } + + public function execute() { + } +} + +class NamedDummyAction extends DummyAction { +} + +class CalledDummyAction extends DummyAction { +} + +class InstantiatedDummyAction extends DummyAction { +} diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php new file mode 100644 index 00000000..a05c4fa8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -0,0 +1,46 @@ +requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => false ), + "filename", "enablechunks" + ); + $this->assertTrue( true ); + } + + /** + * @expectedException UsageException + * @covers ApiBase::requireOnlyOneParameter + */ + public function testRequireOnlyOneParameterZero() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => 0 ), + "filename", "enablechunks" + ); + } + + /** + * @expectedException UsageException + * @covers ApiBase::requireOnlyOneParameter + */ + public function testRequireOnlyOneParameterTrue() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => true ), + "filename", "enablechunks" + ); + } + +} diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php new file mode 100644 index 00000000..d98eec6a --- /dev/null +++ b/tests/phpunit/includes/api/ApiBlockTest.php @@ -0,0 +1,83 @@ +doLogin(); + } + + protected function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + function addDBData() { + $user = User::newFromName( 'UTApiBlockee' ); + + if ( $user->getId() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTApiBlockeePassword' ); + + $user->saveSettings(); + } + } + + /** + * This test has probably always been broken and use an invalid token + * Bug tracking brokenness is https://bugzilla.wikimedia.org/35646 + * + * Root cause is https://gerrit.wikimedia.org/r/3434 + * Which made the Block/Unblock API to actually verify the token + * previously always considered valid (bug 34212). + */ + public function testMakeNormalBlock() { + $tokens = $this->getTokens(); + + $user = User::newFromName( 'UTApiBlockee' ); + + if ( !$user->getId() ) { + $this->markTestIncomplete( "The user UTApiBlockee does not exist" ); + } + + if ( !array_key_exists( 'blocktoken', $tokens ) ) { + $this->markTestIncomplete( "No block token found" ); + } + + $this->doApiRequest( array( + 'action' => 'block', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + 'token' => $tokens['blocktoken'] ), null, false, self::$users['sysop']->user ); + + $block = Block::newFromTarget( 'UTApiBlockee' ); + + $this->assertTrue( !is_null( $block ), 'Block is valid' ); + + $this->assertEquals( 'UTApiBlockee', (string)$block->getTarget() ); + $this->assertEquals( 'Some reason', $block->mReason ); + $this->assertEquals( 'infinity', $block->mExpiry ); + } + + /** + * @expectedException UsageException + * @expectedExceptionMessage The token parameter must be set + */ + public function testBlockingActionWithNoToken( ) { + $this->doApiRequest( + array( + 'action' => 'block', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + ), + null, + false, + self::$users['sysop']->user + ); + } +} diff --git a/tests/phpunit/includes/api/ApiCreateAccountTest.php b/tests/phpunit/includes/api/ApiCreateAccountTest.php new file mode 100644 index 00000000..8d134f76 --- /dev/null +++ b/tests/phpunit/includes/api/ApiCreateAccountTest.php @@ -0,0 +1,161 @@ +setMwGlobals( array( 'wgEnableEmail' => true ) ); + } + + /** + * Test the account creation API with a valid request. Also + * make sure the new account can log in and is valid. + * + * This test does multiple API requests so it might end up being + * a bit slow. Raise the default timeout. + * @group medium + */ + public function testValid() { + global $wgServer; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $password = User::randomPassword(); + + $ret = $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Apitestnew', + 'password' => $password, + 'email' => 'test@domain.test', + 'realname' => 'Test Name' + ) ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['createaccount'] ); + + // Should first ask for token. + $a = $result['createaccount']; + $this->assertEquals( 'NeedToken', $a['result'] ); + $token = $a['token']; + + // Finally create the account + $ret = $this->doApiRequest( + array( + 'action' => 'createaccount', + 'name' => 'Apitestnew', + 'password' => $password, + 'token' => $token, + 'email' => 'test@domain.test', + 'realname' => 'Test Name' + ), + $ret[2] + ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertEquals( 'Success', $result['createaccount']['result'] ); + + // Try logging in with the new user. + $ret = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => 'Apitestnew', + 'lgpassword' => $password, + ) ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['login'] ); + + $a = $result['login']['result']; + $this->assertEquals( 'NeedToken', $a ); + $token = $result['login']['token']; + + $ret = $this->doApiRequest( + array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => 'Apitestnew', + 'lgpassword' => $password, + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( 'bool', $result ); + $a = $result['login']['result']; + + $this->assertEquals( 'Success', $a ); + + // log out to destroy the session + $ret = $this->doApiRequest( + array( + 'action' => 'logout', + ), + $ret[2] + ); + $this->assertEquals( array(), $ret[0] ); + } + + /** + * Make sure requests with no names are invalid. + * @expectedException UsageException + */ + public function testNoName() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + ) ); + } + + /** + * Make sure requests with no password are invalid. + * @expectedException UsageException + */ + public function testNoPassword() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'testName', + 'token' => LoginForm::getCreateaccountToken(), + ) ); + } + + /** + * Make sure requests with existing users are invalid. + * @expectedException UsageException + */ + public function testExistingUser() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Apitestsysop', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + 'email' => 'test@domain.test', + ) ); + } + + /** + * Make sure requests with invalid emails are invalid. + * @expectedException UsageException + */ + public function testInvalidEmail() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Test User', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + 'email' => 'invalid', + ) ); + } +} diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php new file mode 100644 index 00000000..3179a452 --- /dev/null +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -0,0 +1,496 @@ +setMwGlobals( array( + 'wgExtraNamespaces' => $wgExtraNamespaces, + 'wgNamespaceContentModels' => $wgNamespaceContentModels, + 'wgContentHandlers' => $wgContentHandlers, + 'wgContLang' => $wgContLang, + ) ); + + $wgExtraNamespaces[12312] = 'Dummy'; + $wgExtraNamespaces[12313] = 'Dummy_talk'; + + $wgNamespaceContentModels[12312] = "testing"; + $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + + $this->doLogin(); + } + + protected function tearDown() { + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + parent::tearDown(); + } + + public function testEdit() { + $name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext + + // -- test new page -------------------------------------------- + $apiResult = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ) ); + $apiResult = $apiResult[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // -- test existing page, no change ---------------------------- + $data = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ) ); + + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayHasKey( 'nochange', $data[0]['edit'] ); + + // -- test existing page, with change -------------------------- + $data = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'different text' + ) ); + + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] ); + + $this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] ); + $this->assertArrayHasKey( 'newrevid', $data[0]['edit'] ); + $this->assertNotEquals( + $data[0]['edit']['newrevid'], + $data[0]['edit']['oldrevid'], + "revision id should change after edit" + ); + } + + public function testNonTextEdit() { + $name = 'Dummy:ApiEditPageTest_testNonTextEdit'; + $data = serialize( 'some bla bla text' ); + + // -- test new page -------------------------------------------- + $apiResult = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => $data, ) ); + $apiResult = $apiResult[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // validate resulting revision + $page = WikiPage::factory( Title::newFromText( $name ) ); + $this->assertEquals( "testing", $page->getContentModel() ); + $this->assertEquals( $data, $page->getContent()->serialize() ); + } + + /** + * @return array + */ + public static function provideEditAppend() { + return array( + array( #0: append + 'foo', 'append', 'bar', "foobar" + ), + array( #1: prepend + 'foo', 'prepend', 'bar', "barfoo" + ), + array( #2: append to empty page + '', 'append', 'foo', "foo" + ), + array( #3: prepend to empty page + '', 'prepend', 'foo', "foo" + ), + array( #4: append to non-existing page + null, 'append', 'foo', "foo" + ), + array( #5: prepend to non-existing page + null, 'prepend', 'foo', "foo" + ), + ); + } + + /** + * @dataProvider provideEditAppend + */ + public function testEditAppend( $text, $op, $append, $expected ) { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditAppend_$count"; + + // -- create page (or not) ----------------------------------------- + if ( $text !== null ) { + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => $text, ) ); + + $this->assertEquals( 'Success', $re['edit']['result'] ); // sanity + } + + // -- try append/prepend -------------------------------------------- + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + $op . 'text' => $append, ) ); + + $this->assertEquals( 'Success', $re['edit']['result'] ); + + // -- validate ----------------------------------------------------- + $page = new WikiPage( Title::newFromText( $name ) ); + $content = $page->getContent(); + $this->assertNotNull( $content, 'Page should have been created' ); + + $text = $content->getNativeData(); + + $this->assertEquals( $expected, $text ); + } + + /** + * Test editing of sections + */ + public function testEditSection() { + $name = 'Help:ApiEditPageTest_testEditSection'; + $page = WikiPage::factory( Title::newFromText( $name ) ); + $text = "==section 1==\ncontent 1\n==section 2==\ncontent2"; + // Preload the page with some text + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), 'summary' ); + + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => '1', + 'text' => "==section 1==\nnew content 1", + ) ); + $this->assertEquals( 'Success', $re['edit']['result'] ); + $newtext = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertEquals( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext ); + + // Test that we raise a 'nosuchsection' error + try { + $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => '9999', + 'text' => 'text', + ) ); + $this->fail( "Should have raised a UsageException" ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nosuchsection', $e->getCodeString() ); + } + } + + /** + * Test action=edit§ion=new + * Run it twice so we test adding a new section on a + * page that doesn't exist (bug 52830) and one that + * does exist + */ + public function testEditNewSection() { + $name = 'Help:ApiEditPageTest_testEditNewSection'; + + // Test on a page that does not already exist + $this->assertFalse( Title::newFromText( $name )->exists() ); + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => 'new', + 'text' => 'test', + 'summary' => 'header', + )); + + $this->assertEquals( 'Success', $re['edit']['result'] ); + // Check the page text is correct + $text = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertEquals( "== header ==\n\ntest", $text ); + + // Now on one that does + $this->assertTrue( Title::newFromText( $name )->exists() ); + list( $re2 ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => 'new', + 'text' => 'test', + 'summary' => 'header', + )); + + $this->assertEquals( 'Success', $re2['edit']['result'] ); + $text = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertEquals( "== header ==\n\ntest\n\n== header ==\n\ntest", $text ); + } + + /** + * Ensure we can edit through a redirect, if adding a section + */ + public function testEdit_redirect() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEdit_redirect_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEdit_redirect_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // conflicting edit to redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit, following the redirect + list( $re, , ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'section' => 'new', + 'redirect' => true, + ), null, self::$users['sysop']->user ); + + $this->assertEquals( 'Success', $re['edit']['result'], + "no problems expected when following redirect" ); + } + + /** + * Ensure we cannot edit through a redirect, if attempting to overwrite content + */ + public function testEdit_redirectText() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEdit_redirectText_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEdit_redirectText_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // conflicting edit to redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit, following the redirect but without creating a section + try { + $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'redirect' => true, + ), null, self::$users['sysop']->user ); + + $this->fail( 'redirect-appendonly error expected' ); + } catch ( UsageException $ex ) { + $this->assertEquals( 'redirect-appendonly', $ex->getCodeString() ); + } + } + + public function testEditConflict() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_$count"; + $title = Title::newFromText( $name ); + + $page = WikiPage::factory( $title ); + + // base edit + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // conflicting edit + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user ); + $this->forceRevisionDate( $page, '20120101020202' ); + + // try to save edit, expect conflict + try { + $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + ), null, self::$users['sysop']->user ); + + $this->fail( 'edit conflict expected' ); + } catch ( UsageException $ex ) { + $this->assertEquals( 'editconflict', $ex->getCodeString() ); + } + } + + /** + * Ensure that editing using section=new will prevent simple conflicts + */ + public function testEditConflict_newSection() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_newSection_$count"; + $title = Title::newFromText( $name ); + + $page = WikiPage::factory( $title ); + + // base edit + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // conflicting edit + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user ); + $this->forceRevisionDate( $page, '20120101020202' ); + + // try to save edit, expect no conflict + list( $re, , ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'section' => 'new', + ), null, self::$users['sysop']->user ); + + $this->assertEquals( 'Success', $re['edit']['result'], + "no edit conflict expected here" ); + } + + public function testEditConflict_bug41990() { + static $count = 0; + $count++; + + /* + * bug 41990: if the target page has a newer revision than the redirect, then editing the + * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously + * caused an edit conflict to be detected. + */ + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $page, '20120101000000' ); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->user ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // new edit to content + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit; should work, following the redirect. + list( $re, , ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'section' => 'new', + 'redirect' => true, + ), null, self::$users['sysop']->user ); + + $this->assertEquals( 'Success', $re['edit']['result'], + "no edit conflict expected here" ); + } + + /** + * @param WikiPage $page + * @param string|int $timestamp + */ + protected function forceRevisionDate( WikiPage $page, $timestamp ) { + $dbw = wfGetDB( DB_MASTER ); + + $dbw->update( 'revision', + array( 'rev_timestamp' => $dbw->timestamp( $timestamp ) ), + array( 'rev_id' => $page->getLatest() ) ); + + $page->clear(); + } +} diff --git a/tests/phpunit/includes/api/ApiLoginTest.php b/tests/phpunit/includes/api/ApiLoginTest.php new file mode 100644 index 00000000..67a75f36 --- /dev/null +++ b/tests/phpunit/includes/api/ApiLoginTest.php @@ -0,0 +1,181 @@ +doApiRequest( array( 'action' => 'login', + 'lgname' => '', 'lgpassword' => self::$users['sysop']->password, + ) ); + $this->assertEquals( 'NoName', $data[0]['login']['result'] ); + } + + public function testApiLoginBadPass() { + global $wgServer; + + $user = self::$users['sysop']; + $user->user->logOut(); + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $ret = $this->doApiRequest( array( + "action" => "login", + "lgname" => $user->username, + "lgpassword" => "bad", + ) ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + array( + "action" => "login", + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => "badnowayinhell", + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( "WrongPass", $a ); + } + + public function testApiLoginGoodPass() { + global $wgServer; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $user = self::$users['sysop']; + $user->user->logOut(); + + $ret = $this->doApiRequest( array( + "action" => "login", + "lgname" => $user->username, + "lgpassword" => $user->password, + ) + ); + + $result = $ret[0]; + $this->assertNotInternalType( "bool", $result ); + $this->assertNotInternalType( "null", $result["login"] ); + + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + array( + "action" => "login", + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => $user->password, + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( "Success", $a ); + } + + /** + * @group Broken + */ + public function testApiLoginGotCookie() { + $this->markTestIncomplete( "The server can't do external HTTP requests, " + . "and the internal one won't give cookies" ); + + global $wgServer, $wgScriptPath; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $user = self::$users['sysop']; + + $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml", + array( "method" => "POST", + "postData" => array( + "lgname" => $user->username, + "lgpassword" => $user->password + ) + ) + ); + $req->execute(); + + libxml_use_internal_errors( true ); + $sxe = simplexml_load_string( $req->getContent() ); + $this->assertNotInternalType( "bool", $sxe ); + $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) ); + $this->assertNotInternalType( "null", $sxe->login[0] ); + + $a = $sxe->login[0]->attributes()->result[0]; + $this->assertEquals( ' result="NeedToken"', $a->asXML() ); + $token = (string)$sxe->login[0]->attributes()->token; + + $req->setData( array( + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => $user->password ) ); + $req->execute(); + + $cj = $req->getCookieJar(); + $serverName = parse_url( $wgServer, PHP_URL_HOST ); + $this->assertNotEquals( false, $serverName ); + $serializedCookie = $cj->serializeToHttpRequest( $wgScriptPath, $serverName ); + $this->assertNotEquals( '', $serializedCookie ); + $this->assertRegexp( + '/_session=[^;]*; .*UserID=[0-9]*; .*UserName=' . $user->userName . '; .*Token=/', + $serializedCookie + ); + } + + public function testRunLogin() { + $sysopUser = self::$users['sysop']; + $data = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => $sysopUser->username, + 'lgpassword' => $sysopUser->password ) ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "NeedToken", $data[0]['login']['result'] ); + $token = $data[0]['login']['token']; + + $data = $this->doApiRequest( array( + 'action' => 'login', + "lgtoken" => $token, + "lgname" => $sysopUser->username, + "lgpassword" => $sysopUser->password ), $data[2] ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "Success", $data[0]['login']['result'] ); + $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] ); + } + +} diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php new file mode 100644 index 00000000..780cf9ed --- /dev/null +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -0,0 +1,72 @@ + 'help', 'format' => 'xml' ) ) + ); + $api->execute(); + $api->getPrinter()->setBufferResult( true ); + $api->printResult( false ); + $resp = $api->getPrinter()->getBuffer(); + + libxml_use_internal_errors( true ); + $sxe = simplexml_load_string( $resp ); + $this->assertNotInternalType( "bool", $sxe ); + $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) ); + } + + public static function provideAssert() { + $anon = new User(); + $bot = new User(); + $bot->setName( 'Bot' ); + $bot->addToDatabase(); + $bot->addGroup( 'bot' ); + $user = new User(); + $user->setName( 'User' ); + $user->addToDatabase(); + return array( + array( $anon, 'user', 'assertuserfailed' ), + array( $user, 'user', false ), + array( $user, 'bot', 'assertbotfailed' ), + array( $bot, 'user', false ), + array( $bot, 'bot', false ), + ); + } + + /** + * Tests the assert={user|bot} functionality + * + * @covers ApiMain::checkAsserts + * @dataProvider provideAssert + * @param User $user + * @param string $assert + * @param string|bool $error False if no error expected + */ + public function testAssert( $user, $assert, $error ) { + try { + $this->doApiRequest( array( + 'action' => 'query', + 'assert' => $assert, + ), null, null, $user ); + $this->assertFalse( $error ); // That no error was expected + } catch ( UsageException $e ) { + $this->assertEquals( $e->getCodeString(), $error ); + } + } + +} diff --git a/tests/phpunit/includes/api/ApiModuleManagerTest.php b/tests/phpunit/includes/api/ApiModuleManagerTest.php new file mode 100644 index 00000000..dab81e16 --- /dev/null +++ b/tests/phpunit/includes/api/ApiModuleManagerTest.php @@ -0,0 +1,318 @@ + array( + 'login', + 'action', + 'ApiLogin', + null, + ), + + 'with factory' => array( + 'login', + 'action', + 'ApiLogin', + array( $this, 'newApiLogin' ), + ), + + 'with closure' => array( + 'logout', + 'action', + 'ApiLogout', + function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ); + } + + /** + * @dataProvider addModuleProvider + */ + public function testAddModule( $name, $group, $class, $factory = null ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModule( $name, $group, $class, $factory ); + + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + public function addModulesProvider() { + return array( + 'empty' => array( + array(), + 'action', + ), + + 'simple' => array( + array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ), + 'action', + ), + + 'with factories' => array( + array( + 'login' => array( + 'class' => 'ApiLogin', + 'factory' => array( $this, 'newApiLogin' ), + ), + 'logout' => array( + 'class' => 'ApiLogout', + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ), + 'action', + ), + ); + } + + /** + * @dataProvider addModulesProvider + */ + public function testAddModules( array $modules, $group ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, $group ); + + foreach ( array_keys( $modules ) as $name ) { + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + $this->assertTrue( true ); // Don't mark the test as risky if $modules is empty + } + + public function getModuleProvider() { + $modules = array( + 'feedrecentchanges' => 'ApiFeedRecentChanges', + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'login' => array( + 'class' => 'ApiLogin', + 'factory' => array( $this, 'newApiLogin' ), + ), + 'logout' => array( + 'class' => 'ApiLogout', + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ); + + return array( + 'legacy entry' => array( + $modules, + 'feedrecentchanges', + 'ApiFeedRecentChanges', + ), + + 'just a class' => array( + $modules, + 'feedcontributions', + 'ApiFeedContributions', + ), + + 'with factory' => array( + $modules, + 'login', + 'ApiLogin', + ), + + 'with closure' => array( + $modules, + 'logout', + 'ApiLogout', + ), + ); + } + + /** + * @covers ApiModuleManager::getModule + * @dataProvider getModuleProvider + */ + public function testGetModule( $modules, $name, $expectedClass ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + // should return the right module + $module1 = $moduleManager->getModule( $name, null, false ); + $this->assertInstanceOf( $expectedClass, $module1 ); + + // should pass group check (with caching disabled) + $module2 = $moduleManager->getModule( $name, 'test', true ); + $this->assertNotNull( $module2 ); + + // should use cached instance + $module3 = $moduleManager->getModule( $name, null, false ); + $this->assertSame( $module1, $module3 ); + + // should not use cached instance if caching is disabled + $module4 = $moduleManager->getModule( $name, null, true ); + $this->assertNotSame( $module1, $module4 ); + } + + /** + * @covers ApiModuleManager::getModule + */ + public function testGetModule_null() { + $modules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + $this->assertNull( $moduleManager->getModule( 'quux' ), 'unknown name' ); + $this->assertNull( $moduleManager->getModule( 'login', 'bla' ), 'wrong group' ); + } + + /** + * @covers ApiModuleManager::getNames + */ + public function testGetNames() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNames = $moduleManager->getNames( 'foo' ); + $this->assertArrayEquals( array_keys( $fooModules ), $fooNames ); + + $allNames = $moduleManager->getNames(); + $allModules = array_merge( $fooModules, $barModules ); + $this->assertArrayEquals( array_keys( $allModules ), $allNames ); + } + + /** + * @covers ApiModuleManager::getNamesWithClasses + */ + public function testGetNamesWithClasses() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNamesWithClasses = $moduleManager->getNamesWithClasses( 'foo' ); + $this->assertArrayEquals( $fooModules, $fooNamesWithClasses ); + + $allNamesWithClasses = $moduleManager->getNamesWithClasses(); + $allModules = array_merge( $fooModules, array( + 'feedcontributions' => 'ApiFeedContributions', + 'feedrecentchanges' => 'ApiFeedRecentChanges', + ) ); + $this->assertArrayEquals( $allModules, $allNamesWithClasses ); + } + + /** + * @covers ApiModuleManager::getModuleGroup + */ + public function testGetModuleGroup() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( 'foo', $moduleManager->getModuleGroup( 'login' ) ); + $this->assertEquals( 'bar', $moduleManager->getModuleGroup( 'feedrecentchanges' ) ); + $this->assertNull( $moduleManager->getModuleGroup( 'quux' ) ); + } + + /** + * @covers ApiModuleManager::getGroups + */ + public function testGetGroups() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $groups = $moduleManager->getGroups(); + $this->assertArrayEquals( array( 'foo', 'bar' ), $groups ); + } + + /** + * @covers ApiModuleManager::getClassName + */ + public function testGetClassName() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( 'ApiLogin', $moduleManager->getClassName( 'login' ) ); + $this->assertEquals( 'ApiLogout', $moduleManager->getClassName( 'logout' ) ); + $this->assertEquals( 'ApiFeedContributions', $moduleManager->getClassName( 'feedcontributions' ) ); + $this->assertEquals( 'ApiFeedRecentChanges', $moduleManager->getClassName( 'feedrecentchanges' ) ); + $this->assertFalse( $moduleManager->getClassName( 'nonexistentmodule' ) ); + } + + +} diff --git a/tests/phpunit/includes/api/ApiOptionsTest.php b/tests/phpunit/includes/api/ApiOptionsTest.php new file mode 100644 index 00000000..5f955bbc --- /dev/null +++ b/tests/phpunit/includes/api/ApiOptionsTest.php @@ -0,0 +1,459 @@ + 'success' ); + + protected function setUp() { + parent::setUp(); + + $this->mUserMock = $this->getMockBuilder( 'User' ) + ->disableOriginalConstructor() + ->getMock(); + + // Set up groups and rights + $this->mUserMock->expects( $this->any() ) + ->method( 'getEffectiveGroups' )->will( $this->returnValue( array( '*', 'user' ) ) ); + $this->mUserMock->expects( $this->any() ) + ->method( 'isAllowed' )->will( $this->returnValue( true ) ); + + // Set up callback for User::getOptionKinds + $this->mUserMock->expects( $this->any() ) + ->method( 'getOptionKinds' )->will( $this->returnCallback( array( $this, 'getOptionKinds' ) ) ); + + // Create a new context + $this->mContext = new DerivativeContext( new RequestContext() ); + $this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) ); + $this->mContext->setUser( $this->mUserMock ); + + $main = new ApiMain( $this->mContext ); + + // Empty session + $this->mSession = array(); + + $this->mTested = new ApiOptions( $main, 'options' ); + + global $wgHooks; + if ( !isset( $wgHooks['GetPreferences'] ) ) { + $wgHooks['GetPreferences'] = array(); + } + $this->mOldGetPreferencesHooks = $wgHooks['GetPreferences']; + $wgHooks['GetPreferences'][] = array( $this, 'hookGetPreferences' ); + } + + protected function tearDown() { + global $wgHooks; + + $wgHooks['GetPreferences'] = $this->mOldGetPreferencesHooks; + $this->mOldGetPreferencesHooks = false; + + parent::tearDown(); + } + + public function hookGetPreferences( $user, &$preferences ) { + $preferences = array(); + + foreach ( array( 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ) as $k ) { + $preferences[$k] = array( + 'type' => 'text', + 'section' => 'test', + 'label' => ' ', + ); + } + + $preferences['testmultiselect'] = array( + 'type' => 'multiselect', + 'options' => array( + 'Test' => array( + 'Some HTML here for option 1' => 'opt1', + 'Some HTML here for option 2' => 'opt2', + 'Some HTML here for option 3' => 'opt3', + 'Some HTML here for option 4' => 'opt4', + ), + ), + 'section' => 'test', + 'label' => ' ', + 'prefix' => 'testmultiselect-', + 'default' => array(), + ); + + return true; + } + + /** + * @param IContextSource $context + * @param array|null $options + * + * @return array + */ + public function getOptionKinds( IContextSource $context, $options = null ) { + // Match with above. + $kinds = array( + 'name' => 'registered', + 'willBeNull' => 'registered', + 'willBeEmpty' => 'registered', + 'willBeHappy' => 'registered', + 'testmultiselect-opt1' => 'registered-multiselect', + 'testmultiselect-opt2' => 'registered-multiselect', + 'testmultiselect-opt3' => 'registered-multiselect', + 'testmultiselect-opt4' => 'registered-multiselect', + 'special' => 'special', + ); + + if ( $options === null ) { + return $kinds; + } + + $mapping = array(); + foreach ( $options as $key => $value ) { + if ( isset( $kinds[$key] ) ) { + $mapping[$key] = $kinds[$key]; + } elseif ( substr( $key, 0, 7 ) === 'userjs-' ) { + $mapping[$key] = 'userjs'; + } else { + $mapping[$key] = 'unused'; + } + } + + return $mapping; + } + + private function getSampleRequest( $custom = array() ) { + $request = array( + 'token' => '123ABC', + 'change' => null, + 'optionname' => null, + 'optionvalue' => null, + ); + + return array_merge( $request, $custom ); + } + + private function executeQuery( $request ) { + $this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) ); + $this->mTested->execute(); + + return $this->mTested->getResult()->getData(); + } + + /** + * @expectedException UsageException + */ + public function testNoToken() { + $request = $this->getSampleRequest( array( 'token' => null ) ); + + $this->executeQuery( $request ); + } + + public function testAnon() { + $this->mUserMock->expects( $this->once() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'notloggedin', $e->getCodeString() ); + $this->assertEquals( 'Anonymous users cannot change preferences', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testNoOptionname() { + try { + $request = $this->getSampleRequest( array( 'optionvalue' => '1' ) ); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nooptionname', $e->getCodeString() ); + $this->assertEquals( 'The optionname parameter must be set', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testNoChanges() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nochanges', $e->getCodeString() ); + $this->assertEquals( 'No changes were requested', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testReset() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( array( 'all' ) ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'reset' => '' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetKinds() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( array( 'registered' ) ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'reset' => '', 'resetkinds' => 'registered' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionWithValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'optionname' => 'name', 'optionvalue' => 'value' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionResetValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'optionname' => 'name' ) ); + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testChange() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 2 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) ); + + $this->mUserMock->expects( $this->at( 7 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 8 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetChangeOption() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 7 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $args = array( + 'reset' => '', + 'change' => 'willBeHappy=Happy', + 'optionname' => 'name', + 'optionvalue' => 'value' + ); + + $response = $this->executeQuery( $this->getSampleRequest( $args ) ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testMultiSelect() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 3 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|' + . 'testmultiselect-opt3=|testmultiselect-opt4=0' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testSpecialOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'special=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( array( + 'options' => 'success', + 'warnings' => array( + 'options' => array( + '*' => "Validation error for 'special': cannot be set by this module" + ) + ) + ), $response ); + } + + public function testUnknownOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'unknownOption=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( array( + 'options' => 'success', + 'warnings' => array( + 'options' => array( + '*' => "Validation error for 'unknownOption': not a valid preference" + ) + ) + ), $response ); + } + + public function testUserjsOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 3 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'userjs-option=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } +} diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php new file mode 100644 index 00000000..d038a4f5 --- /dev/null +++ b/tests/phpunit/includes/api/ApiParseTest.php @@ -0,0 +1,35 @@ +doLogin(); + } + + public function testParseNonexistentPage() { + $somePage = mt_rand(); + + try { + $this->doApiRequest( array( + 'action' => 'parse', + 'page' => $somePage ) ); + + $this->fail( "API did not return an error when parsing a nonexistent page" ); + } catch ( UsageException $ex ) { + $this->assertEquals( + 'missingtitle', + $ex->getCodeString(), + "Parse request for nonexistent page must give 'missingtitle' error: " + . var_export( $ex->getMessageArray(), true ) + ); + } + } +} diff --git a/tests/phpunit/includes/api/ApiPurgeTest.php b/tests/phpunit/includes/api/ApiPurgeTest.php new file mode 100644 index 00000000..7fce134a --- /dev/null +++ b/tests/phpunit/includes/api/ApiPurgeTest.php @@ -0,0 +1,45 @@ +doLogin(); + } + + /** + * @group Broken + */ + public function testPurgeMainPage() { + if ( !Title::newFromText( 'UTPage' )->exists() ) { + $this->markTestIncomplete( "The article [[UTPage]] does not exist" ); + } + + $somePage = mt_rand(); + + $data = $this->doApiRequest( array( + 'action' => 'purge', + 'titles' => 'UTPage|' . $somePage . '|%5D' ) ); + + $this->assertArrayHasKey( 'purge', $data[0], + "Must receive a 'purge' result from API" ); + + $this->assertEquals( + 3, + count( $data[0]['purge'] ), + "Purge request for three articles should give back three results received: " + . var_export( $data[0]['purge'], true ) ); + + $pages = array( 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' ); + foreach ( $data[0]['purge'] as $v ) { + $this->assertArrayHasKey( $pages[$v['title']], $v ); + } + } +} diff --git a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php new file mode 100644 index 00000000..124988f3 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php @@ -0,0 +1,34 @@ +doLogin(); + } + + /** + * @todo give this test a real name explaining what is being tested here + */ + public function testBug25702() { + $title = Title::newFromText( 'Category:Template:xyz' ); + $page = WikiPage::factory( $title ); + $page->doEdit( 'Some text', 'inserting content' ); + + $result = $this->doApiRequest( array( + 'action' => 'query', + 'list' => 'allpages', + 'apnamespace' => NS_CATEGORY, + 'apprefix' => 'Template:x' ) ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'allpages', $result[0]['query'] ); + $this->assertNotEquals( 0, count( $result[0]['query']['allpages'] ), + 'allpages list does not contain page Category:Template:xyz' ); + } +} diff --git a/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php new file mode 100644 index 00000000..b03836eb --- /dev/null +++ b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php @@ -0,0 +1,114 @@ +mergeMwGlobalArrayValue( 'wgGroupPermissions', array( 'sysop' => array( 'deleterevision' => true ) ) ); + parent::setUp(); + // Make a few edits for us to play with + for ( $i = 1; $i <= 5; $i++ ) { + self::editPage( self::$page, MWCryptRand::generateHex( 10 ), 'summary' ); + $this->revs[] = Title::newFromText( self::$page )->getLatestRevID( Title::GAID_FOR_UPDATE ); + } + + } + + public function testHidingRevisions() { + $user = self::$users['sysop']->user; + $revid = array_shift( $this->revs ); + $out = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + // Check the output + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + $this->assertArrayHasKey( 'userhidden', $item ); + $this->assertArrayHasKey( 'commenthidden', $item ); + $this->assertArrayHasKey( 'texthidden', $item ); + $this->assertEquals( $item['id'], $revid ); + + // Now check that that revision was actually hidden + $rev = Revision::newFromId( $revid ); + $this->assertEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + + // Now test unhiding! + $out2 = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + + // Check the output + $out2 = $out2[0]['revisiondelete']; + $this->assertEquals( $out2['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out2 ); + $item = $out2['items'][0]; + + $this->assertArrayNotHasKey( 'userhidden', $item ); + $this->assertArrayNotHasKey( 'commenthidden', $item ); + $this->assertArrayNotHasKey( 'texthidden', $item ); + + $this->assertEquals( $item['id'], $revid ); + + $rev = Revision::newFromId( $revid ); + $this->assertNotEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertNotEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertNotEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + } + + public function testUnhidingOutput() { + $user = self::$users['sysop']->user; + $revid = array_shift( $this->revs ); + // Hide revisions + $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + + $out = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'comment', + 'token' => $user->getEditToken(), + ) ); + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + // Check it has userhidden & texthidden keys + // but no commenthidden key + $this->assertArrayHasKey( 'userhidden', $item ); + $this->assertArrayNotHasKey( 'commenthidden', $item ); + $this->assertArrayHasKey( 'texthidden', $item ); + $this->assertEquals( $item['id'], $revid ); + } +} diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php new file mode 100644 index 00000000..cd141947 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -0,0 +1,196 @@ + new TestUser( + 'Apitestsysop', + 'Api Test Sysop', + 'api_test_sysop@example.com', + array( 'sysop' ) + ), + 'uploader' => new TestUser( + 'Apitestuser', + 'Api Test User', + 'api_test_user@example.com', + array() + ) + ); + + $this->setMwGlobals( array( + 'wgMemc' => new EmptyBagOStuff(), + 'wgAuth' => new StubObject( 'wgAuth', 'AuthPlugin' ), + 'wgRequest' => new FauxRequest( array() ), + 'wgUser' => self::$users['sysop']->user, + ) ); + + $this->apiContext = new ApiTestContext(); + } + + /** + * Edits or creates a page/revision + * @param string $pageName Page title + * @param string $text Content of the page + * @param string $summary Optional summary string for the revision + * @param int $defaultNs Optional namespace id + * @return array Array as returned by WikiPage::doEditContent() + */ + protected function editPage( $pageName, $text, $summary = '', $defaultNs = NS_MAIN ) { + $title = Title::newFromText( $pageName, $defaultNs ); + $page = WikiPage::factory( $title ); + + return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary ); + } + + /** + * Does the API request and returns the result. + * + * The returned value is an array containing + * - the result data (array) + * - the request (WebRequest) + * - the session data of the request (array) + * - if $appendModule is true, the Api module $module + * + * @param array $params + * @param array|null $session + * @param bool $appendModule + * @param User|null $user + * + * @return array + */ + protected function doApiRequest( array $params, array $session = null, + $appendModule = false, User $user = null + ) { + global $wgRequest, $wgUser; + + if ( is_null( $session ) ) { + // re-use existing global session by default + $session = $wgRequest->getSessionArray(); + } + + // set up global environment + if ( $user ) { + $wgUser = $user; + } + + $wgRequest = new FauxRequest( $params, true, $session ); + RequestContext::getMain()->setRequest( $wgRequest ); + + // set up local environment + $context = $this->apiContext->newTestContext( $wgRequest, $wgUser ); + + $module = new ApiMain( $context, true ); + + // run it! + $module->execute(); + + // construct result + $results = array( + $module->getResultData(), + $context->getRequest(), + $context->getRequest()->getSessionArray() + ); + + if ( $appendModule ) { + $results[] = $module; + } + + return $results; + } + + /** + * Add an edit token to the API request + * This is cheating a bit -- we grab a token in the correct format and then + * add it to the pseudo-session and to the request, without actually + * requesting a "real" edit token. + * + * @param array $params Key-value API params + * @param array|null $session Session array + * @param User|null $user A User object for the context + * @return array Result of the API call + * @throws Exception In case wsToken is not set in the session + */ + protected function doApiRequestWithToken( array $params, array $session = null, + User $user = null + ) { + global $wgRequest; + + if ( $session === null ) { + $session = $wgRequest->getSessionArray(); + } + + if ( isset( $session['wsToken'] ) && $session['wsToken'] ) { + // add edit token to fake session + $session['wsEditToken'] = $session['wsToken']; + // add token to request parameters + $params['token'] = md5( $session['wsToken'] ) . User::EDIT_TOKEN_SUFFIX; + + return $this->doApiRequest( $params, $session, false, $user ); + } else { + throw new Exception( "Session token not available" ); + } + } + + protected function doLogin( $user = 'sysop' ) { + if ( !array_key_exists( $user, self::$users ) ) { + throw new MWException( "Can not log in to undefined user $user" ); + } + + $data = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => self::$users[$user]->username, + 'lgpassword' => self::$users[$user]->password ) ); + + $token = $data[0]['login']['token']; + + $data = $this->doApiRequest( + array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => self::$users[$user]->username, + 'lgpassword' => self::$users[$user]->password, + ), + $data[2] + ); + + return $data; + } + + protected function getTokenList( $user, $session = null ) { + $data = $this->doApiRequest( array( + 'action' => 'tokens', + 'type' => 'edit|delete|protect|move|block|unblock|watch' + ), $session, false, $user->user ); + + if ( !array_key_exists( 'tokens', $data[0] ) ) { + throw new MWException( 'Api failed to return a token list' ); + } + + return $data[0]['tokens']; + } + + public function testApiTestGroup() { + $groups = PHPUnit_Util_Test::getGroups( get_class( $this ) ); + $constraint = PHPUnit_Framework_Assert::logicalOr( + $this->contains( 'medium' ), + $this->contains( 'large' ) + ); + $this->assertThat( $groups, $constraint, + 'ApiTestCase::setUp can be slow, tests must be "medium" or "large"' + ); + } +} diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php new file mode 100644 index 00000000..7e513394 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php @@ -0,0 +1,171 @@ +setMwGlobals( array( + 'wgEnableUploads' => true, + 'wgEnableAPI' => true, + ) ); + + wfSetupSession(); + + $this->clearFakeUploads(); + } + + protected function tearDown() { + $this->clearTempUpload(); + + parent::tearDown(); + } + + /** + * Helper function -- remove files and associated articles by Title + * + * @param Title $title Title to be removed + * + * @return bool + */ + public function deleteFileByTitle( $title ) { + if ( $title->exists() ) { + $file = wfFindFile( $title, array( 'ignoreRedirect' => true ) ); + $noOldArchive = ""; // yes this really needs to be set this way + $comment = "removing for test"; + $restrictDeletedVersions = false; + $status = FileDeleteForm::doDelete( + $title, + $file, + $noOldArchive, + $comment, + $restrictDeletedVersions + ); + + if ( !$status->isGood() ) { + return false; + } + + $page = WikiPage::factory( $title ); + $page->doDeleteArticle( "removing for test" ); + + // see if it now doesn't exist; reload + $title = Title::newFromText( $title->getText(), NS_FILE ); + } + + return !( $title && $title instanceof Title && $title->exists() ); + } + + /** + * Helper function -- remove files and associated articles with a particular filename + * + * @param string $fileName Filename to be removed + * + * @return bool + */ + public function deleteFileByFileName( $fileName ) { + return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) ); + } + + /** + * Helper function -- given a file on the filesystem, find matching + * content in the db (and associated articles) and remove them. + * + * @param string $filePath Path to file on the filesystem + * + * @return bool + */ + public function deleteFileByContent( $filePath ) { + $hash = FSFile::getSha1Base36FromPath( $filePath ); + $dupes = RepoGroup::singleton()->findBySha1( $hash ); + $success = true; + foreach ( $dupes as $dupe ) { + $success &= $this->deleteFileByTitle( $dupe->getTitle() ); + } + + return $success; + } + + /** + * Fake an upload by dumping the file into temp space, and adding info to $_FILES. + * (This is what PHP would normally do). + * + * @param string $fieldName Name this would have in the upload form + * @param string $fileName Name to title this + * @param string $type MIME type + * @param string $filePath Path where to find file contents + * + * @throws Exception + * @return bool + */ + function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) { + $tmpName = tempnam( wfTempDir(), "" ); + if ( !file_exists( $filePath ) ) { + throw new Exception( "$filePath doesn't exist!" ); + } + + if ( !copy( $filePath, $tmpName ) ) { + throw new Exception( "couldn't copy $filePath to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = array( + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ); + + return true; + } + + function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) { + $tmpName = tempnam( wfTempDir(), "" ); + // copy the chunk data to temp location: + if ( !file_put_contents( $tmpName, $chunkData ) ) { + throw new Exception( "couldn't copy chunk data to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = array( + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ); + } + + function clearTempUpload() { + if ( isset( $_FILES['file']['tmp_name'] ) ) { + $tmp = $_FILES['file']['tmp_name']; + if ( file_exists( $tmp ) ) { + unlink( $tmp ); + } + } + } + + /** + * Remove traces of previous fake uploads + */ + function clearFakeUploads() { + $_FILES = array(); + } +} diff --git a/tests/phpunit/includes/api/ApiTestContext.php b/tests/phpunit/includes/api/ApiTestContext.php new file mode 100644 index 00000000..17dad1fa --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestContext.php @@ -0,0 +1,21 @@ +setRequest( $request ); + if ( $user !== null ) { + $context->setUser( $user ); + } + + return $context; + } +} diff --git a/tests/phpunit/includes/api/ApiTokensTest.php b/tests/phpunit/includes/api/ApiTokensTest.php new file mode 100644 index 00000000..fbe97893 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTokensTest.php @@ -0,0 +1,40 @@ +runTokenTest( $user ); + } + } + + protected function runTokenTest( $user ) { + $tokens = $this->getTokenList( $user ); + + $rights = $user->user->getRights(); + + $this->assertArrayHasKey( 'edittoken', $tokens ); + $this->assertArrayHasKey( 'movetoken', $tokens ); + + if ( isset( $rights['delete'] ) ) { + $this->assertArrayHasKey( 'deletetoken', $tokens ); + } + + if ( isset( $rights['block'] ) ) { + $this->assertArrayHasKey( 'blocktoken', $tokens ); + $this->assertArrayHasKey( 'unblocktoken', $tokens ); + } + + if ( isset( $rights['protect'] ) ) { + $this->assertArrayHasKey( 'protecttoken', $tokens ); + } + } + +} diff --git a/tests/phpunit/includes/api/ApiUnblockTest.php b/tests/phpunit/includes/api/ApiUnblockTest.php new file mode 100644 index 00000000..2c2370a8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiUnblockTest.php @@ -0,0 +1,31 @@ +doLogin(); + } + + /** + * @expectedException UsageException + */ + public function testWithNoToken( ) { + $this->doApiRequest( + array( + 'action' => 'unblock', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + ), + null, + false, + self::$users['sysop']->user + ); + } +} diff --git a/tests/phpunit/includes/api/ApiUploadTest.php b/tests/phpunit/includes/api/ApiUploadTest.php new file mode 100644 index 00000000..8ea761f8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiUploadTest.php @@ -0,0 +1,572 @@ + 'login', + 'lgname' => $user->username, + 'lgpassword' => $user->password + ); + list( $result, , $session ) = $this->doApiRequest( $params ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "NeedToken", $result['login']['result'] ); + $token = $result['login']['token']; + + $params = array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => $user->username, + 'lgpassword' => $user->password + ); + list( $result, , $session ) = $this->doApiRequest( $params, $session ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "Success", $result['login']['result'] ); + $this->assertArrayHasKey( 'lgtoken', $result['login'] ); + + $this->assertNotEmpty( $session, 'API Login must return a session' ); + + return $session; + } + + /** + * @depends testLogin + */ + public function testUploadRequiresToken( $session ) { + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload' + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "The token parameter must be set", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUploadMissingParams( $session ) { + $exception = false; + try { + $this->doApiRequestWithToken( array( + 'action' => 'upload', + ), $session, self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "One of the parameters filekey, file, url, statuskey is required", + $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUpload( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadZeroLength( $session ) { + $mimeType = 'image/png'; + + $filePath = tempnam( wfTempDir(), "" ); + $fileName = "apiTestUploadZeroLength.png"; + + $this->deleteFileByFileName( $fileName ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + $this->doApiRequestWithToken( $params, $session, self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->assertContains( 'The file you submitted was empty', $e->getMessage() ); + $exception = true; + } + $this->assertTrue( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadSameFileName( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 2, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + // we'll reuse this filename + /** @var array $filePaths */ + $fileName = basename( $filePaths[0] ); + + // clear any other files with the same name + $this->deleteFileByFileName( $fileName ); + + // we reuse these params + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + // first upload .... should succeed + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same name (but different content) + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePaths[0] ); + unlink( $filePaths[1] ); + } + + /** + * @depends testLogin + */ + public function testUploadSameContent( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $fileNames[0] = basename( $filePaths[0] ); + $fileNames[1] = "SameContentAs" . $fileNames[0]; + + // clear any other files with the same name or content + $this->deleteFileByContent( $filePaths[0] ); + $this->deleteFileByFileName( $fileNames[0] ); + $this->deleteFileByFileName( $fileNames[1] ); + + // first upload .... should succeed + + $params = array( + 'action' => 'upload', + 'filename' => $fileNames[0], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[0], + ); + + if ( !$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same content (but different name) + + if ( !$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileNames[1], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[1], + ); + + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileNames[0] ); + $this->deleteFileByFilename( $fileNames[1] ); + unlink( $filePaths[0] ); + } + + /** + * @depends testLogin + */ + public function testUploadStash( $session ) { + $this->setMwGlobals( array( + 'wgUser' => self::$users['uploader']->user, // @todo FIXME: still used somewhere + ) ); + + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertFalse( $exception ); + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] ); + $filekey = $result['upload']['filekey']; + + // it should be visible from Special:UploadStash + // XXX ...but how to test this, with a fake WebRequest with the session? + + // now we should try to release the file from stash + $params = array( + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ); + + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception, "No UsageException exception." ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadChunks( $session ) { + $this->setMwGlobals( array( + // @todo FIXME: still used somewhere + 'wgUser' => self::$users['uploader']->user, + ) ); + + $chunkSize = 1048576; + // Download a large image file + // ( using RandomImageGenerator for large files is not stable ) + $mimeType = 'image/jpeg'; + $url = 'http://upload.wikimedia.org/wikipedia/commons/' + . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG'; + $filePath = wfTempDir() . '/Oberaargletscher_from_Oberaar.jpg'; + try { + // Only download if the file is not avaliable in the temp location: + if ( !is_file( $filePath ) ) { + copy( $url, $filePath ); + } + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + // Base upload params: + $params = array( + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'filesize' => $fileSize, + 'offset' => 0, + ); + + // Upload chunks + $chunkSessionKey = false; + $resultOffset = 0; + // Open the file: + wfSuppressWarnings(); + $handle = fopen( $filePath, "r" ); + wfRestoreWarnings(); + + if ( $handle === false ) { + $this->markTestIncomplete( "could not open file: $filePath" ); + } + + while ( !feof( $handle ) ) { + // Get the current chunk + wfSuppressWarnings(); + $chunkData = fread( $handle, $chunkSize ); + wfRestoreWarnings(); + + // Upload the current chunk into the $_FILE object: + $this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData ); + + // Check for chunkSessionKey + if ( !$chunkSessionKey ) { + // Upload fist chunk ( and get the session key ) + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + // If we don't get a session key mark test incomplete. + if ( !isset( $result['upload']['filekey'] ) ) { + $this->markTestIncomplete( "no filekey provided" ); + } + $chunkSessionKey = $result['upload']['filekey']; + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // First chunk should have chunkSize == offset + $this->assertEquals( $chunkSize, $result['upload']['offset'] ); + $resultOffset = $result['upload']['offset']; + continue; + } + // Filekey set to chunk session + $params['filekey'] = $chunkSessionKey; + // Update the offset ( always add chunkSize for subquent chunks + // should be in-sync with $result['upload']['offset'] ) + $params['offset'] += $chunkSize; + // Make sure param offset is insync with resultOffset: + $this->assertEquals( $resultOffset, $params['offset'] ); + // Upload current chunk + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + + // Check if we were on the last chunk: + if ( $params['offset'] + $chunkSize >= $fileSize ) { + $this->assertEquals( 'Success', $result['upload']['result'] ); + break; + } else { + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // update $resultOffset + $resultOffset = $result['upload']['offset']; + } + } + fclose( $handle ); + + // Check that we got a valid file result: + wfDebug( __METHOD__ + . " hohoh filesize {$fileSize} info {$result['upload']['imageinfo']['size']}\n\n" ); + $this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $filekey = $result['upload']['filekey']; + + // Now we should try to release the file from stash + $params = array( + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ); + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + // don't remove downloaded temporary file for fast subquent tests. + //unlink( $filePath ); + } +} diff --git a/tests/phpunit/includes/api/ApiWatchTest.php b/tests/phpunit/includes/api/ApiWatchTest.php new file mode 100644 index 00000000..e49c6c0e --- /dev/null +++ b/tests/phpunit/includes/api/ApiWatchTest.php @@ -0,0 +1,157 @@ +doLogin(); + } + + function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + /** + */ + public function testWatchEdit() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'edit', + 'title' => 'Help:UTPage', // Help namespace is hopefully wikitext + 'text' => 'new text', + 'token' => $tokens['edittoken'], + 'watchlist' => 'watch' ) ); + $this->assertArrayHasKey( 'edit', $data[0] ); + $this->assertArrayHasKey( 'result', $data[0]['edit'] ); + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + return $data; + } + + /** + * @depends testWatchEdit + */ + public function testWatchClear() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'wllimit' => 'max', + 'list' => 'watchlist' ) ); + + if ( isset( $data[0]['query']['watchlist'] ) ) { + $wl = $data[0]['query']['watchlist']; + + foreach ( $wl as $page ) { + $data = $this->doApiRequest( array( + 'action' => 'watch', + 'title' => $page['title'], + 'unwatch' => true, + 'token' => $tokens['watchtoken'] ) ); + } + } + $data = $this->doApiRequest( array( + 'action' => 'query', + 'list' => 'watchlist' ), $data ); + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'watchlist', $data[0]['query'] ); + foreach ( $data[0]['query']['watchlist'] as $index => $item ) { + // Previous tests may insert an invalid title + // like ":ApiEditPageTest testNonTextEdit", which + // can't be cleared. + if ( strpos( $item['title'], ':' ) === 0 ) { + unset( $data[0]['query']['watchlist'][$index] ); + } + } + $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) ); + + return $data; + } + + /** + */ + public function testWatchProtect() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'protect', + 'token' => $tokens['protecttoken'], + 'title' => 'Help:UTPage', + 'protections' => 'edit=sysop', + 'watchlist' => 'unwatch' ) ); + + $this->assertArrayHasKey( 'protect', $data[0] ); + $this->assertArrayHasKey( 'protections', $data[0]['protect'] ); + $this->assertEquals( 1, count( $data[0]['protect']['protections'] ) ); + $this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] ); + } + + /** + */ + public function testGetRollbackToken() { + $this->getTokens(); + + if ( !Title::newFromText( 'Help:UTPage' )->exists() ) { + $this->markTestSkipped( "The article [[Help:UTPage]] does not exist" ); //TODO: just create it? + } + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => 'Help:UTPage', + 'rvtoken' => 'rollback' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + + if ( isset( $data[0]['query']['pages'][$key]['missing'] ) ) { + $this->markTestSkipped( "Target page (Help:UTPage) doesn't exist" ); + } + + $this->assertArrayHasKey( 'pageid', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 'revisions', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 0, $data[0]['query']['pages'][$key]['revisions'] ); + $this->assertArrayHasKey( 'rollbacktoken', $data[0]['query']['pages'][$key]['revisions'][0] ); + + return $data; + } + + /** + * @group Broken + * Broken because there is currently no revision info in the $pageinfo + * + * @depends testGetRollbackToken + */ + public function testWatchRollback( $data ) { + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + $pageinfo = $data[0]['query']['pages'][$key]; + $revinfo = $pageinfo['revisions'][0]; + + try { + $data = $this->doApiRequest( array( + 'action' => 'rollback', + 'title' => 'Help:UTPage', + 'user' => $revinfo['user'], + 'token' => $pageinfo['rollbacktoken'], + 'watchlist' => 'watch' ) ); + + $this->assertArrayHasKey( 'rollback', $data[0] ); + $this->assertArrayHasKey( 'title', $data[0]['rollback'] ); + } catch ( UsageException $ue ) { + if ( $ue->getCodeString() == 'onlyauthor' ) { + $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" ); + } else { + $this->fail( "Received error '" . $ue->getCodeString() . "'" ); + } + } + } +} diff --git a/tests/phpunit/includes/api/MockApi.php b/tests/phpunit/includes/api/MockApi.php new file mode 100644 index 00000000..d94aa2cd --- /dev/null +++ b/tests/phpunit/includes/api/MockApi.php @@ -0,0 +1,20 @@ + null, + 'enablechunks' => false, + 'sessionkey' => null, + ); + } +} diff --git a/tests/phpunit/includes/api/MockApiQueryBase.php b/tests/phpunit/includes/api/MockApiQueryBase.php new file mode 100644 index 00000000..4bede519 --- /dev/null +++ b/tests/phpunit/includes/api/MockApiQueryBase.php @@ -0,0 +1,11 @@ +getModuleManager(); + + $modules = $moduleManager->getNames(); + $prefixes = array(); + + foreach ( $modules as $name ) { + $module = $moduleManager->getModule( $name ); + $class = get_class( $module ); + + $prefix = $module->getModulePrefix(); + if ( isset( $prefixes[$prefix] ) ) { + $this->fail( "Module prefix '{$prefix}' is shared between {$class} and {$prefixes[$prefix]}" ); + } + $prefixes[$module->getModulePrefix()] = $class; + } + $this->assertTrue( true ); // dummy call to make this test non-incomplete + } +} diff --git a/tests/phpunit/includes/api/RandomImageGenerator.php b/tests/phpunit/includes/api/RandomImageGenerator.php new file mode 100644 index 00000000..6374cfac --- /dev/null +++ b/tests/phpunit/includes/api/RandomImageGenerator.php @@ -0,0 +1,496 @@ + + */ + +/** + * RandomImageGenerator: does what it says on the tin. + * Can fetch a random image, or also write a number of them to disk with random filenames. + */ +class RandomImageGenerator { + private $dictionaryFile; + private $minWidth = 400; + private $maxWidth = 800; + private $minHeight = 400; + private $maxHeight = 800; + private $shapesToDraw = 5; + + /** + * Orientations: 0th row, 0th column, Exif orientation code, rotation 2x2 + * matrix that is opposite of orientation. N.b. we do not handle the + * 'flipped' orientations, which is why there is no entry for 2, 4, 5, or 7. + * Those seem to be rare in real images anyway (we also would need a + * non-symmetric shape for the images to test those, like a letter F). + */ + private static $orientations = array( + array( + '0thRow' => 'top', + '0thCol' => 'left', + 'exifCode' => 1, + 'counterRotation' => array( array( 1, 0 ), array( 0, 1 ) ) + ), + array( + '0thRow' => 'bottom', + '0thCol' => 'right', + 'exifCode' => 3, + 'counterRotation' => array( array( -1, 0 ), array( 0, -1 ) ) + ), + array( + '0thRow' => 'right', + '0thCol' => 'top', + 'exifCode' => 6, + 'counterRotation' => array( array( 0, 1 ), array( 1, 0 ) ) + ), + array( + '0thRow' => 'left', + '0thCol' => 'bottom', + 'exifCode' => 8, + 'counterRotation' => array( array( 0, -1 ), array( -1, 0 ) ) + ) + ); + + public function __construct( $options = array() ) { + foreach ( array( 'dictionaryFile', 'minWidth', 'minHeight', + 'maxWidth', 'maxHeight', 'shapesToDraw' ) as $property + ) { + if ( isset( $options[$property] ) ) { + $this->$property = $options[$property]; + } + } + + // find the dictionary file, to generate random names + if ( !isset( $this->dictionaryFile ) ) { + foreach ( + array( + '/usr/share/dict/words', + '/usr/dict/words', + __DIR__ . '/words.txt' + ) as $dictionaryFile + ) { + if ( is_file( $dictionaryFile ) and is_readable( $dictionaryFile ) ) { + $this->dictionaryFile = $dictionaryFile; + break; + } + } + } + if ( !isset( $this->dictionaryFile ) ) { + throw new Exception( "RandomImageGenerator: dictionary file not " + . "found or not specified properly" ); + } + } + + /** + * Writes random images with random filenames to disk in the directory you + * specify, or current working directory. + * + * @param int $number Number of filenames to write + * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif' + * @param string $dir Directory, optional (will default to current working directory) + * @return array Filenames we just wrote + */ + function writeImages( $number, $format = 'jpg', $dir = null ) { + $filenames = $this->getRandomFilenames( $number, $format, $dir ); + $imageWriteMethod = $this->getImageWriteMethod( $format ); + foreach ( $filenames as $filename ) { + $this->{$imageWriteMethod}( $this->getImageSpec(), $format, $filename ); + } + + return $filenames; + } + + /** + * Figure out how we write images. This is a factor of both format and the local system + * + * @param string $format (a typical extension like 'svg', 'jpg', etc.) + * + * @throws Exception + * @return string + */ + function getImageWriteMethod( $format ) { + global $wgUseImageMagick, $wgImageMagickConvertCommand; + if ( $format === 'svg' ) { + return 'writeSvg'; + } else { + // figure out how to write images + global $wgExiv2Command; + if ( class_exists( 'Imagick' ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) { + return 'writeImageWithApi'; + } elseif ( $wgUseImageMagick + && $wgImageMagickConvertCommand + && is_executable( $wgImageMagickConvertCommand ) + ) { + return 'writeImageWithCommandLine'; + } + } + throw new Exception( "RandomImageGenerator: could not find a suitable " + . "method to write images in '$format' format" ); + } + + /** + * Return a number of randomly-generated filenames + * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg + * + * @param int $number Number of filenames to generate + * @param string $extension Optional, defaults to 'jpg' + * @param string $dir Optional, defaults to current working directory + * @return array Array of filenames + */ + private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) { + if ( is_null( $dir ) ) { + $dir = getcwd(); + } + $filenames = array(); + foreach ( $this->getRandomWordPairs( $number ) as $pair ) { + $basename = $pair[0] . '_' . $pair[1]; + if ( !is_null( $extension ) ) { + $basename .= '.' . $extension; + } + $basename = preg_replace( '/\s+/', '', $basename ); + $filenames[] = "$dir/$basename"; + } + + return $filenames; + } + + /** + * Generate data representing an image of random size (within limits), + * consisting of randomly colored and sized upward pointing triangles + * against a random background color. (This data is used in the + * writeImage* methods). + * + * @return mixed + */ + public function getImageSpec() { + $spec = array(); + + $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth ); + $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight ); + $spec['fill'] = $this->getRandomColor(); + + $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) ); + + $draws = array(); + for ( $i = 0; $i <= $this->shapesToDraw; $i++ ) { + $radius = mt_rand( 0, $diagonalLength / 4 ); + if ( $radius == 0 ) { + continue; + } + $originX = mt_rand( -1 * $radius, $spec['width'] + $radius ); + $originY = mt_rand( -1 * $radius, $spec['height'] + $radius ); + $angle = mt_rand( 0, ( 3.141592 / 2 ) * $radius ) / $radius; + $legDeltaX = round( $radius * sin( $angle ) ); + $legDeltaY = round( $radius * cos( $angle ) ); + + $draw = array(); + $draw['fill'] = $this->getRandomColor(); + $draw['shape'] = array( + array( 'x' => $originX, 'y' => $originY - $radius ), + array( 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ), + array( 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ), + array( 'x' => $originX, 'y' => $originY - $radius ) + ); + $draws[] = $draw; + } + + $spec['draws'] = $draws; + + return $spec; + } + + /** + * Given array( array('x' => 10, 'y' => 20), array( 'x' => 30, y=> 5 ) ) + * returns "10,20 30,5" + * Useful for SVG and imagemagick command line arguments + * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values + * @return string + */ + static function shapePointsToString( $shape ) { + $points = array(); + foreach ( $shape as $point ) { + $points[] = $point['x'] . ',' . $point['y']; + } + + return join( " ", $points ); + } + + /** + * Based on image specification, write a very simple SVG file to disk. + * Ignores the background spec because transparency is cool. :) + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (which is obviously always svg here) + * @param string $filename Filename to write to + * + * @throws Exception + */ + public function writeSvg( $spec, $format, $filename ) { + $svg = new SimpleXmlElement( '' ); + $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' ); + $svg->addAttribute( 'version', '1.1' ); + $svg->addAttribute( 'width', $spec['width'] ); + $svg->addAttribute( 'height', $spec['height'] ); + $g = $svg->addChild( 'g' ); + foreach ( $spec['draws'] as $drawSpec ) { + $shape = $g->addChild( 'polygon' ); + $shape->addAttribute( 'fill', $drawSpec['fill'] ); + $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) ); + } + + if ( !$fh = fopen( $filename, 'w' ) ) { + throw new Exception( "couldn't open $filename for writing" ); + } + fwrite( $fh, $svg->asXML() ); + if ( !fclose( $fh ) ) { + throw new Exception( "couldn't close $filename" ); + } + } + + /** + * Based on an image specification, write such an image to disk, using Imagick PHP extension + * @param array $spec Spec describing background and circles to draw + * @param string $format File format to write + * @param string $filename Filename to write to + */ + public function writeImageWithApi( $spec, $format, $filename ) { + // this is a hack because I can't get setImageOrientation() to work. See below. + global $wgExiv2Command; + + $image = new Imagick(); + /** + * If the format is 'jpg', will also add a random orientation -- the + * image will be drawn rotated with triangle points facing in some + * direction (0, 90, 180 or 270 degrees) and a countering rotation + * should turn the triangle points upward again. + */ + $orientation = self::$orientations[0]; // default is normal orientation + if ( $format == 'jpg' ) { + $orientation = self::$orientations[array_rand( self::$orientations )]; + $spec = self::rotateImageSpec( $spec, $orientation['counterRotation'] ); + } + + $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) ); + + foreach ( $spec['draws'] as $drawSpec ) { + $draw = new ImagickDraw(); + $draw->setFillColor( $drawSpec['fill'] ); + $draw->polygon( $drawSpec['shape'] ); + $image->drawImage( $draw ); + } + + $image->setImageFormat( $format ); + + // this doesn't work, even though it's documented to do so... + // $image->setImageOrientation( $orientation['exifCode'] ); + + $image->writeImage( $filename ); + + // because the above setImageOrientation call doesn't work... nor can I + // get an external imagemagick binary to do this either... Hacking this + // for now (only works if you have exiv2 installed, a program to read + // and manipulate exif). + if ( $wgExiv2Command ) { + $cmd = wfEscapeShellArg( $wgExiv2Command ) + . " -M " + . wfEscapeShellArg( "set Exif.Image.Orientation " . $orientation['exifCode'] ) + . " " + . wfEscapeShellArg( $filename ); + + $retval = 0; + $err = wfShellExec( $cmd, $retval ); + if ( $retval !== 0 ) { + print "Error with $cmd: $retval, $err\n"; + } + } + } + + /** + * Given an image specification, produce rotated version + * This is used when simulating a rotated image capture with Exif orientation + * @param array $spec Returned by getImageSpec + * @param array $matrix 2x2 transformation matrix + * @return array Transformed Spec + */ + private static function rotateImageSpec( &$spec, $matrix ) { + $tSpec = array(); + $dims = self::matrixMultiply2x2( $matrix, $spec['width'], $spec['height'] ); + $correctionX = 0; + $correctionY = 0; + if ( $dims['x'] < 0 ) { + $correctionX = abs( $dims['x'] ); + } + if ( $dims['y'] < 0 ) { + $correctionY = abs( $dims['y'] ); + } + $tSpec['width'] = abs( $dims['x'] ); + $tSpec['height'] = abs( $dims['y'] ); + $tSpec['fill'] = $spec['fill']; + $tSpec['draws'] = array(); + foreach ( $spec['draws'] as $draw ) { + $tDraw = array( + 'fill' => $draw['fill'], + 'shape' => array() + ); + foreach ( $draw['shape'] as $point ) { + $tPoint = self::matrixMultiply2x2( $matrix, $point['x'], $point['y'] ); + $tPoint['x'] += $correctionX; + $tPoint['y'] += $correctionY; + $tDraw['shape'][] = $tPoint; + } + $tSpec['draws'][] = $tDraw; + } + + return $tSpec; + } + + /** + * Given a matrix and a pair of images, return new position + * @param array $matrix 2x2 rotation matrix + * @param int $x The x-coordinate number + * @param int $y The y-coordinate number + * @return array Transformed with properties x, y + */ + private static function matrixMultiply2x2( $matrix, $x, $y ) { + return array( + 'x' => $x * $matrix[0][0] + $y * $matrix[0][1], + 'y' => $x * $matrix[1][0] + $y * $matrix[1][1] + ); + } + + /** + * Based on an image specification, write such an image to disk, using the + * command line ImageMagick program ('convert'). + * + * Sample command line: + * $ convert -size 100x60 xc:rgb(90,87,45) \ + * -draw 'fill rgb(12,34,56) polygon 41,39 44,57 50,57 41,39' \ + * -draw 'fill rgb(99,123,231) circle 59,39 56,57' \ + * -draw 'fill rgb(240,12,32) circle 50,21 50,3' filename.png + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (unused by this method but + * kept so it has the same signature as writeImageWithApi). + * @param string $filename Filename to write to + * + * @return bool + */ + public function writeImageWithCommandLine( $spec, $format, $filename ) { + global $wgImageMagickConvertCommand; + $args = array(); + $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] ); + $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] ); + foreach ( $spec['draws'] as $draw ) { + $fill = $draw['fill']; + $polygon = self::shapePointsToString( $draw['shape'] ); + $drawCommand = "fill $fill polygon $polygon"; + $args[] = '-draw ' . wfEscapeShellArg( $drawCommand ); + } + $args[] = wfEscapeShellArg( $filename ); + + $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args ); + $retval = null; + wfShellExec( $command, $retval ); + + return ( $retval === 0 ); + } + + /** + * Generate a string of random colors for ImageMagick or SVG, like "rgb(12, 37, 98)" + * + * @return string + */ + public function getRandomColor() { + $components = array(); + for ( $i = 0; $i <= 2; $i++ ) { + $components[] = mt_rand( 0, 255 ); + } + + return 'rgb(' . join( ', ', $components ) . ')'; + } + + /** + * Get an array of random pairs of random words, like + * array( array( 'foo', 'bar' ), array( 'quux', 'baz' ) ); + * + * @param int $number Number of pairs + * @return array Two-element arrays + */ + private function getRandomWordPairs( $number ) { + $lines = $this->getRandomLines( $number * 2 ); + // construct pairs of words + $pairs = array(); + $count = count( $lines ); + for ( $i = 0; $i < $count; $i += 2 ) { + $pairs[] = array( $lines[$i], $lines[$i + 1] ); + } + + return $pairs; + } + + /** + * Return N random lines from a file + * + * Will throw exception if the file could not be read or if it had fewer lines than requested. + * + * @param int $number_desired Number of lines desired + * + * @throws Exception + * @return array Array of exactly n elements, drawn randomly from lines the file + */ + private function getRandomLines( $number_desired ) { + $filepath = $this->dictionaryFile; + + // initialize array of lines + $lines = array(); + for ( $i = 0; $i < $number_desired; $i++ ) { + $lines[] = null; + } + + /* + * This algorithm obtains N random lines from a file in one single pass. + * It does this by replacing elements of a fixed-size array of lines, + * less and less frequently as it reads the file. + */ + $fh = fopen( $filepath, "r" ); + if ( !$fh ) { + throw new Exception( "couldn't open $filepath" ); + } + $line_number = 0; + $max_index = $number_desired - 1; + while ( !feof( $fh ) ) { + $line = fgets( $fh ); + if ( $line !== false ) { + $line_number++; + $line = trim( $line ); + if ( mt_rand( 0, $line_number ) <= $max_index ) { + $lines[mt_rand( 0, $max_index )] = $line; + } + } + } + fclose( $fh ); + if ( $line_number < $number_desired ) { + throw new Exception( "not enough lines in $filepath" ); + } + + return $lines; + } +} diff --git a/tests/phpunit/includes/api/UserWrapper.php b/tests/phpunit/includes/api/UserWrapper.php new file mode 100644 index 00000000..f8da0ff4 --- /dev/null +++ b/tests/phpunit/includes/api/UserWrapper.php @@ -0,0 +1,25 @@ +userName = $userName; + $this->password = $password; + + $this->user = User::newFromName( $this->userName ); + if ( !$this->user->getID() ) { + $this->user = User::createNew( $this->userName, array( + "email" => "test@example.com", + "real_name" => "Test User" ) ); + } + $this->user->setPassword( $this->password ); + + if ( $group !== '' ) { + $this->user->addGroup( $group ); + } + $this->user->saveSettings(); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php new file mode 100644 index 00000000..fc1f9021 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php @@ -0,0 +1,22 @@ +apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', json_decode( $data, true ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } + + public function testJsonpInjection( ) { + $data = $this->apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo', 'callback' => 'myCallback' ) ); + $this->assertEquals( '/**/myCallback(', substr( $data, 0, 15 ) ); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php new file mode 100644 index 00000000..cabd750b --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php @@ -0,0 +1,16 @@ +apiRequest( 'none', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertEquals( '', $data ); // No output! + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php new file mode 100644 index 00000000..54f447a9 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php @@ -0,0 +1,17 @@ +apiRequest( 'php', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', unserialize( $data ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/tests/phpunit/includes/api/format/ApiFormatTestBase.php new file mode 100644 index 00000000..5f6d53ce --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatTestBase.php @@ -0,0 +1,32 @@ +createPrinterByName( $format ); + $printer->setUnescapeAmps( false ); + + $printer->initPrinter( false ); + + ob_start(); + $printer->execute(); + $out = ob_get_clean(); + + $printer->closePrinter(); + + return $out; + } + +} diff --git a/tests/phpunit/includes/api/format/ApiFormatWddxTest.php b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php new file mode 100644 index 00000000..d075f547 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php @@ -0,0 +1,20 @@ +apiRequest( 'wddx', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', wddx_deserialize( $data ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } +} diff --git a/tests/phpunit/includes/api/generateRandomImages.php b/tests/phpunit/includes/api/generateRandomImages.php new file mode 100644 index 00000000..87f5c4c0 --- /dev/null +++ b/tests/phpunit/includes/api/generateRandomImages.php @@ -0,0 +1,46 @@ +writeImages( $number, $format ); + } +} + +$maintClass = 'GenerateRandomImages'; +require RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php new file mode 100644 index 00000000..e486c4f4 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php @@ -0,0 +1,353 @@ +@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +require_once 'ApiQueryTestBase.php'; + +/** + * These tests validate basic functionality of the api query module + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryBasicTest extends ApiQueryTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + if ( Title::newFromText( 'AQBT-All' )->exists() ) { + return; + } + + // Ordering is important, as it will be returned in the same order as stored in the index + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' ); + $this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + $this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE ); + + // Refresh due to the bug with listing transclusions as links if they don't exist + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + private static $links = array( + array( 'prop' => 'links', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'links' => array( + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + ) + ) + ) ) + ); + + private static $templates = array( + array( 'prop' => 'templates', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + ) + ) + ) ) + ); + + private static $categories = array( + array( 'prop' => 'categories', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'categories' => array( + array( 'ns' => 14, 'title' => 'Category:AQBT-Cat' ), + ) + ) + ) ) + ); + + private static $allpages = array( + array( 'list' => 'allpages', 'apprefix' => 'AQBT-' ), + array( 'allpages' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ), + array( 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ), + array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $alllinks = array( + array( 'list' => 'alllinks', 'alprefix' => 'AQBT-' ), + array( 'alllinks' => array( + array( 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'ns' => 0, 'title' => 'AQBT-Categories' ), + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + array( 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $alltransclusions = array( + array( 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ), + array( 'alltransclusions' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + ) ) + ); + + // Although this appears to have no use it is used by testLists() + private static $allcategories = array( + array( 'list' => 'allcategories', 'acprefix' => 'AQBT-' ), + array( 'allcategories' => array( + array( '*' => 'AQBT-Cat' ), + ) ) + ); + + private static $backlinks = array( + array( 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ), + array( 'backlinks' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + ) ) + ); + + private static $embeddedin = array( + array( 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ), + array( 'embeddedin' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $categorymembers = array( + array( 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ), + array( 'categorymembers' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ), + ) ) + ); + + private static $generatorAllpages = array( + array( 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ), + '2' => array( + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ), + '3' => array( + 'pageid' => 3, + 'ns' => 0, + 'title' => 'AQBT-Links' ), + '4' => array( + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $generatorLinks = array( + array( 'generator' => 'links', 'titles' => 'AQBT-Links' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ), + '2' => array( + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ), + '4' => array( + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $generatorLinksPropLinks = array( + array( 'prop' => 'links' ), + array( 'pages' => array( + '1' => array( 'links' => array( + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + ) ) + ) ) + ); + + private static $generatorLinksPropTemplates = array( + array( 'prop' => 'templates' ), + array( 'pages' => array( + '1' => array( 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ) ) ), + '4' => array( 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ) ) ), + ) ) + ); + + /** + * Test basic props + */ + public function testProps() { + $this->check( self::$links ); + $this->check( self::$templates ); + $this->check( self::$categories ); + } + + /** + * Test basic lists + */ + public function testLists() { + $this->check( self::$allpages ); + $this->check( self::$alllinks ); + $this->check( self::$alltransclusions ); + // This test is temporarily disabled until a sqlite bug is fixed + // Confirmed still broken 15-nov-2013 + // $this->check( self::$allcategories ); + $this->check( self::$backlinks ); + $this->check( self::$embeddedin ); + $this->check( self::$categorymembers ); + } + + /** + * Test basic lists + */ + public function testAllTogether() { + + // All props together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories + ) ); + + // All lists together + $this->check( $this->merge( + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + + // All props+lists together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + } + + /** + * Test basic lists + */ + public function testGenerator() { + // generator=allpages + $this->check( self::$generatorAllpages ); + // generator=allpages & list=allpages + $this->check( $this->merge( + self::$generatorAllpages, + self::$allpages ) ); + // generator=links + $this->check( self::$generatorLinks ); + // generator=links & prop=links + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks ) ); + // generator=links & prop=templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates & list=allpages|... + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers ) ); + } + + /** + * Test bug 51821 + */ + public function testGeneratorRedirects() { + $this->editPage( 'AQBT-Target', 'test' ); + $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' ); + $this->check( array( + array( 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ), + array( + 'redirects' => array( + array( + 'from' => 'AQBT-Redir', + 'to' => 'AQBT-Target', + ) + ), + 'pages' => array( + '6' => array( + 'pageid' => 6, + 'ns' => 0, + 'title' => 'AQBT-Target', + ) + ), + ) + ) ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php new file mode 100644 index 00000000..347cd6f8 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php @@ -0,0 +1,71 @@ +@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +require_once 'ApiQueryContinueTestBase.php'; + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinue2Test extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * @medium + */ + public function testA() { + $this->mVerbose = false; + $mk = function ( $g, $p, $gDir ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT73462-', + 'prop' => 'links', + 'gaplimit' => "$g", + 'pllimit' => "$p", + 'gapdir' => $gDir ? "ascending" : "descending", + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ); + $this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' ); + $this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' ); + $this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' ); + $this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php new file mode 100644 index 00000000..03797901 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php @@ -0,0 +1,316 @@ +@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +require_once 'ApiQueryContinueTestBase.php'; + +/** + * These tests validate the new continue functionality of the api query module by + * doing multiple requests with varying parameters, merging the results, and checking + * that the result matches the full data received in one no-limits call. + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinueTest extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' ); + $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' ); + $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' ); + $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' ); + $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' ); + + $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' ); + $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * Test smart continue - list=allpages + * @medium + */ + public function test1List() { + $this->mVerbose = false; + $mk = function ( $l ) { + return array( + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + ); + }; + $data = $this->query( $mk( 99 ), 1, '1L', false ); + + // 1 list + $this->checkC( $data, $mk( 1 ), 5, '1L-1' ); + $this->checkC( $data, $mk( 2 ), 3, '1L-2' ); + $this->checkC( $data, $mk( 3 ), 2, '1L-3' ); + $this->checkC( $data, $mk( 4 ), 2, '1L-4' ); + $this->checkC( $data, $mk( 5 ), 1, '1L-5' ); + } + + /** + * Test smart continue - list=allpages|alltransclusions + * @medium + */ + public function test2Lists() { + $this->mVerbose = false; + $mk = function ( $l1, $l2 ) { + return array( + 'list' => 'allpages|alltransclusions', + 'apprefix' => 'AQCT-', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'aplimit' => "$l1", + 'atlimit' => "$l2", + ); + }; + // 2 lists + $data = $this->query( $mk( 99, 99 ), 1, '2L', false ); + $this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' ); + $this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' ); + $this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' ); + $this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' ); + $this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links + * @medium + */ + public function testGen1Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + ); + }; + // generator + 1 prop + $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ); + $this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' ); + $this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' ); + $this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' ); + $this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' ); + $this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates + * @medium + */ + public function testGen2Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2 ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + ); + }; + // generator + 2 props + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ); + $this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' ); + $this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' ); + $this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' ); + $this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' ); + $this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' ); + $this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' ); + } + + /** + * Test smart continue - generator=allpages, prop=links, list=alltransclusions + * @medium + */ + public function testGen1Prop1List() { + $this->mVerbose = false; + $mk = function ( $g, $p, $l ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + 'list' => 'alltransclusions', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l", + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ); + $this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' ); + $this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' ); + $this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates, + * list=alllinks|alltransclusions, meta=siteinfo + * @medium + */ + public function testGen2Prop2List1Meta() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2, $l1, $l2 ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + 'list' => 'alllinks|alltransclusions', + 'alprefix' => 'AQCT-', + 'alunique' => '', + 'allimit' => "$l1", + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l2", + 'meta' => 'siteinfo', + 'siprop' => 'namespaces', + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ); + $this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' ); + $this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' ); + $this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' ); + $this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' ); + $this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' ); + $this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' ); + $this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' ); + $this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' ); + $this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' ); + } + + /** + * Test smart continue - generator=templates, prop=templates + * @medium + */ + public function testSameGenAndProp() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $p, $pDir ) { + return array( + 'titles' => 'AQCT-1', + 'generator' => 'templates', + 'gtllimit' => "$g", + 'gtldir' => $gDir ? 'ascending' : 'descending', + 'prop' => 'templates', + 'tllimit' => "$p", + 'tldir' => $pDir ? 'ascending' : 'descending', + ); + }; + // generator + 1 prop + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ); + + $this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' ); + + $this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' ); + + $this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' ); + + $this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' ); + } + + /** + * Test smart continue - generator=allpages, list=allpages + * @medium + */ + public function testSameGenList() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $l, $pDir ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'gapdir' => $gDir ? 'ascending' : 'descending', + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + 'apdir' => $pDir ? 'ascending' : 'descending', + ); + }; + // generator + 1 list + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ); + + $this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' ); + $this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' ); + $this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' ); + $this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php new file mode 100644 index 00000000..bce62685 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php @@ -0,0 +1,218 @@ +@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +require_once 'ApiQueryTestBase.php'; + +abstract class ApiQueryContinueTestBase extends ApiQueryTestBase { + + /** + * Enable to print in-depth debugging info during the test run + */ + protected $mVerbose = false; + + /** + * Run query() and compare against expected values + * @param array $expected + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $continue True to use smart continue + * @return array Merged results data array + */ + protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) { + $result = $this->query( $params, $expectedCount, $id, $continue ); + $this->assertResult( $expected, $result, $id ); + } + + /** + * Run query in a loop until no more values are available + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $useContinue True to use smart continue + * @return array Merged results data array + * @throws Exception + */ + protected function query( $params, $expectedCount, $id, $useContinue = true ) { + if ( isset( $params['action'] ) ) { + $this->assertEquals( 'query', $params['action'], 'Invalid query action' ); + } else { + $params['action'] = 'query'; + } + if ( $useContinue && !isset( $params['continue'] ) ) { + $params['continue'] = ''; + } + $count = 0; + $result = array(); + $continue = array(); + do { + $request = array_merge( $params, $continue ); + uksort( $request, function ( $a, $b ) { + // put 'continue' params at the end - lazy method + $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a; + $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b; + + return strcmp( $a, $b ); + } ); + $reqStr = http_build_query( $request ); + //$reqStr = str_replace( '&', ' & ', $reqStr ); + $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" ); + if ( $this->mVerbose ) { + print "$id (#$count): $reqStr\n"; + } + try { + $data = $this->doApiRequest( $request ); + } catch ( Exception $e ) { + throw new Exception( "$id on $count", 0, $e ); + } + $data = $data[0]; + if ( isset( $data['warnings'] ) ) { + $warnings = json_encode( $data['warnings'] ); + $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" ); + } + $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" ); + if ( isset( $data['continue'] ) ) { + $continue = $data['continue']; + unset( $data['continue'] ); + } else { + $continue = array(); + } + if ( $this->mVerbose ) { + $this->printResult( $data ); + } + $this->mergeResult( $result, $data ); + $count++; + if ( empty( $continue ) ) { + $this->assertEquals( $expectedCount, $count, "$id finished early" ); + + return $result; + } elseif ( !$useContinue ) { + $this->assertFalse( 'Non-smart query must be requested all at once' ); + } + } while ( true ); + } + + /** + * @param array $data + */ + private function printResult( $data ) { + $q = $data['query']; + $print = array(); + if ( isset( $q['pages'] ) ) { + foreach ( $q['pages'] as $p ) { + $m = $p['title']; + if ( isset( $p['links'] ) ) { + $m .= '/[' . implode( ',', array_map( + function ( $v ) { + return $v['title']; + }, + $p['links'] ) ) . ']'; + } + if ( isset( $p['categories'] ) ) { + $m .= '/(' . implode( ',', array_map( + function ( $v ) { + return str_replace( 'Category:', '', $v['title'] ); + }, + $p['categories'] ) ) . ')'; + } + $print[] = $m; + } + } + if ( isset( $q['allcategories'] ) ) { + $print[] = '*Cats/(' . implode( ',', array_map( + function ( $v ) { + return $v['*']; + }, + $q['allcategories'] ) ) . ')'; + } + self::GetItems( $q, 'allpages', 'Pages', $print ); + self::GetItems( $q, 'alllinks', 'Links', $print ); + self::GetItems( $q, 'alltransclusions', 'Trnscl', $print ); + print ' ' . implode( ' ', $print ) . "\n"; + } + + private static function GetItems( $q, $moduleName, $name, &$print ) { + if ( isset( $q[$moduleName] ) ) { + $print[] = "*$name/[" . implode( ',', + array_map( + function ( $v ) { + return $v['title']; + }, + $q[$moduleName] ) ) . ']'; + } + } + + /** + * Recursively merge the new result returned from the query to the previous results. + * @param mixed $results + * @param mixed $newResult + * @param bool $numericIds If true, treat keys as ids to be merged instead of appending + */ + protected function mergeResult( &$results, $newResult, $numericIds = false ) { + $this->assertEquals( + is_array( $results ), + is_array( $newResult ), + 'Type of result and data do not match' + ); + if ( !is_array( $results ) ) { + $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' ); + } else { + $sort = null; + foreach ( $newResult as $key => $value ) { + if ( !$numericIds && $sort === null ) { + if ( !is_array( $value ) ) { + $sort = false; + } elseif ( array_key_exists( 'title', $value ) ) { + $sort = function ( $a, $b ) { + return strcmp( $a['title'], $b['title'] ); + }; + } else { + $sort = false; + } + } + $keyExists = array_key_exists( $key, $results ); + if ( is_numeric( $key ) ) { + if ( $numericIds ) { + if ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value ); + } + } else { + $results[] = $value; + } + } elseif ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value, $key === 'pages' ); + } + } + if ( $numericIds ) { + ksort( $results, SORT_NUMERIC ); + } elseif ( $sort !== null && $sort !== false ) { + usort( $results, $sort ); + } + } + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php new file mode 100644 index 00000000..74ceff90 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php @@ -0,0 +1,40 @@ +doEdit( 'Some text', 'inserting content' ); + + $apiResult = $this->doApiRequest( array( + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => $pageName, + 'rvprop' => 'content', + ) ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'pages', $apiResult[0]['query'] ); + foreach ( $apiResult[0]['query']['pages'] as $page ) { + $this->assertArrayHasKey( 'revisions', $page ); + foreach ( $page['revisions'] as $revision ) { + $this->assertArrayHasKey( 'contentformat', $revision, + 'contentformat should be included when asking content so client knows how to interpret it' + ); + $this->assertArrayHasKey( 'contentmodel', $revision, + 'contentmodel should be included when asking content so client knows how to interpret it' + ); + } + } + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php new file mode 100644 index 00000000..bba22c77 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -0,0 +1,130 @@ +doLogin(); + + // Setup en: as interwiki prefix + $this->hooks = $wgHooks; + $wgHooks['InterwikiLoadPrefix'][] = function ( $prefix, &$data ) { + if ( $prefix == 'apiquerytestiw' ) { + $data = array( 'iw_url' => 'wikipedia' ); + } + return false; + }; + } + + protected function tearDown() { + global $wgHooks; + $wgHooks = $this->hooks; + + parent::tearDown(); + } + + public function testTitlesGetNormalized() { + global $wgMetaNamespace; + + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'titles' => 'Project:articleA|article_B' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'normalized', $data[0]['query'] ); + + // Forge a normalized title + $to = Title::newFromText( $wgMetaNamespace . ':ArticleA' ); + + $this->assertEquals( + array( + 'from' => 'Project:articleA', + 'to' => $to->getPrefixedText(), + ), + $data[0]['query']['normalized'][0] + ); + + $this->assertEquals( + array( + 'from' => 'article_B', + 'to' => 'Article B' + ), + $data[0]['query']['normalized'][1] + ); + } + + public function testTitlesAreRejectedIfInvalid() { + $title = false; + while ( !$title || Title::newFromText( $title )->exists() ) { + $title = md5( mt_rand( 0, 10000 ) + rand( 0, 999000 ) ); + } + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'titles' => $title . '|Talk:' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $this->assertEquals( 2, count( $data[0]['query']['pages'] ) ); + + $this->assertArrayHasKey( -2, $data[0]['query']['pages'] ); + $this->assertArrayHasKey( -1, $data[0]['query']['pages'] ); + + $this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] ); + $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] ); + } + + /** + * Test the ApiBase::titlePartToKey function + * + * @param string $titlePart + * @param int $namespace + * @param string $expected + * @param string $expectException + * @dataProvider provideTestTitlePartToKey + */ + function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $api = new MockApiQueryBase(); + $exceptionCaught = false; + try { + $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) ); + } catch ( UsageException $e ) { + $exceptionCaught = true; + } + $this->assertEquals( $expectException, $exceptionCaught, + 'UsageException thrown by titlePartToKey' ); + } + + function provideTestTitlePartToKey() { + return array( + array( 'a b c', NS_MAIN, 'A_b_c', false ), + array( 'x', NS_MAIN, 'X', false ), + array( 'y ', NS_MAIN, 'Y_', false ), + array( 'template:foo', NS_CATEGORY, 'Template:foo', false ), + array( 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ), + array( "\xF7", NS_MAIN, null, true ), + array( 'template:foo', NS_MAIN, null, true ), + array( 'apiquerytestiw:foo', NS_MAIN, null, true ), + ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php new file mode 100644 index 00000000..56c15b23 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryTestBase.php @@ -0,0 +1,148 @@ +@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** This class has some common functionality for testing query module + */ +abstract class ApiQueryTestBase extends ApiTestCase { + + const PARAM_ASSERT = <<validateRequestExpectedPair( $v ); + $request = array_merge_recursive( $request, $req ); + $this->mergeExpected( $expected, $exp ); + } + + return array( $request, $expected ); + } + + /** + * Check that the parameter is a valid two element array, + * with the first element being API request and the second - expected result + * @param array $v + * @return array + */ + private function validateRequestExpectedPair( $v ) { + $this->assertType( 'array', $v, self::PARAM_ASSERT ); + $this->assertEquals( 2, count( $v ), self::PARAM_ASSERT ); + $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT ); + $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT ); + $this->assertType( 'array', $v[0], self::PARAM_ASSERT ); + $this->assertType( 'array', $v[1], self::PARAM_ASSERT ); + + return $v; + } + + /** + * Recursively merges the expected values in the $item into the $all + * @param array &$all + * @param array $item + */ + private function mergeExpected( &$all, $item ) { + foreach ( $item as $k => $v ) { + if ( array_key_exists( $k, $all ) ) { + if ( is_array( $all[$k] ) ) { + $this->mergeExpected( $all[$k], $v ); + } else { + $this->assertEquals( $all[$k], $v ); + } + } else { + $all[$k] = $v; + } + } + } + + /** + * Checks that the request's result matches the expected results. + * @param array $values Array is a two element array( request, expected_results ) + * @throws Exception + */ + protected function check( $values ) { + list( $req, $exp ) = $this->validateRequestExpectedPair( $values ); + if ( !array_key_exists( 'action', $req ) ) { + $req['action'] = 'query'; + } + foreach ( $req as &$val ) { + if ( is_array( $val ) ) { + $val = implode( '|', array_unique( $val ) ); + } + } + $result = $this->doApiRequest( $req ); + $this->assertResult( array( 'query' => $exp ), $result[0], $req ); + } + + protected function assertResult( $exp, $result, $message = '' ) { + try { + $exp = self::sanitizeResultArray( $exp ); + $result = self::sanitizeResultArray( $result ); + $this->assertEquals( $exp, $result ); + } catch ( PHPUnit_Framework_ExpectationFailedException $e ) { + if ( is_array( $message ) ) { + $message = http_build_query( $message ); + } + throw new PHPUnit_Framework_ExpectationFailedException( + $e->getMessage() . "\nRequest: $message", + new PHPUnit_Framework_ComparisonFailure( + $exp, + $result, + print_r( $exp, true ), + print_r( $result, true ), + false, + $e->getComparisonFailure()->getMessage() . "\nRequest: $message" + ) + ); + } + } + + /** + * Recursively ksorts a result array and removes any 'pageid' keys. + * @param array $result + * @return array + */ + private static function sanitizeResultArray( $result ) { + unset( $result['pageid'] ); + foreach ( $result as $key => $value ) { + if ( is_array( $value ) ) { + $result[$key] = self::sanitizeResultArray( $value ); + } + } + + // Sort the result by keys, then take advantage of how array_merge will + // renumber numeric keys while leaving others alone. + ksort( $result ); + return array_merge( $result ); + } +} diff --git a/tests/phpunit/includes/api/words.txt b/tests/phpunit/includes/api/words.txt new file mode 100644 index 00000000..7ce23ee3 --- /dev/null +++ b/tests/phpunit/includes/api/words.txt @@ -0,0 +1,1000 @@ +Andaquian +Anoplanthus +Araquaju +Astrophyton +Avarish +Batonga +Bdellidae +Betoyan +Bismarck +Britishness +Carmen +Chatillon +Clement +Coryphaena +Croton +Cyrillianism +Dagomba +Decimus +Dichorisandra +Duculinae +Empusa +Escallonia +Fathometer +Fon +Fundulinae +Gadswoons +Gederathite +Gemini +Gerbera +Gregarinida +Gyracanthus +Halopsychidae +Hasidim +Hemerobius +Ichthyosauridae +Iscariot +Jeames +Jesuitry +Jovian +Judaization +Katie +Ladin +Langhian +Lapithaean +Lisette +Macrochira +Malaxis +Malvastrum +Maranhao +Marxian +Maurist +Metrosideros +Micky +Microsporon +Odacidae +Ophiuchid +Osmorhiza +Paguma +Palesman +Papayaceae +Pastinaca +Philoxenian +Pleurostigma +Rarotongan +Rhodoraceae +Rong +Saho +Sanyakoan +Sardanapalian +Sauropoda +Sedentaria +Shambu +Shukulumbwe +Solonian +Spaniardization +Spirochaetaceae +Stomatopoda +Stratiotes +Taiwanhemp +Titanically +Venetianed +Victrola +Yuman +abatis +abaton +abjoint +acanthoma +acari +acceptance +actinography +acuteness +addiment +adelite +adelomorphic +adelphogamy +adipocele +aelurophobia +affined +aflaunt +agathokakological +aischrolatreia +alarmedly +alebench +aleurone +allelotropic +allerion +alloplastic +allowable +alternacy +alternariose +altricial +ambitionist +amendment +amiableness +amicableness +ammo +amortizable +anchorate +anemometrically +angelocracy +angelological +anodal +anomalure +antedate +antiagglutinin +antirationalist +antiscorbutic +antisplasher +antithesize +antiunionist +antoecian +apolegamic +appropriation +archididascalian +archival +arteriophlebotomy +articulable +asseveration +assignation +atelo +atrienses +atrophy +atterminement +atypic +automower +aveloz +awrist +azteca +bairnteam +balsamweed +bannerman +beardy +becry +beek +beggarwise +bescab +bestness +bethel +bewildering +bibliophilism +bitterblain +blakeberyed +boccarella +bocedization +boobyalla +bourbon +bowbent +bowerbird +brachygnathous +brail +branchiferous +brelaw +brew +brideweed +bridgeable +brombenzamide +buddler +burbankian +burr +buskin +cacochymical +calefactory +caliper +canaliculus +candidature +canellaceous +canniness +canning +cantilene +carbonatation +carthamic +caseum +caudated +causationist +ceruleite +chalder +chalta +charmel +chekan +chillness +chirogymnast +chirpling +chlorinous +cholanthrene +chondroblast +chromatography +chromophilous +chronical +cicatrice +cinchonine +city +clubbing +coastal +coaxially +coercible +coeternity +coff +coinventor +collyba +combinator +complanation +comprehensibility +conchuela +congenital +context +contranatural +corallum +cordately +cornupete +corolliferous +coroneted +corticosterone +coseat +cottage +crocetin +crossleted +crottels +curvedness +cycadeous +cyclism +cylindrically +cynanche +cyrtoceratitic +cystospasm +danceress +dancette +dawny +daydreamy +debar +decarburization +decorousness +decrepitness +delirious +deozonizer +dermatosis +desma +deutencephalic +diacetate +diarthrodial +diathermy +dicolic +dimastigate +dimidiation +dipetto +disavowable +disintrench +disman +dismay +disorder +disoxygenation +dithionous +dogman +dragonfly +dramatical +drawspan +drubbly +drunk +duskly +ecderonic +ectocuniform +ectocyst +ehrwaldite +electrocute +elemicin +embracing +emotionality +enactment +enamor +enclave +endameba +endochylous +endocrinologist +endolymph +endothecal +entasia +epigeous +episcopicide +epitrichial +erminee +erraticalness +eruptivity +erythrocytoschisis +esperance +estuous +eucrystalline +eugeny +evacuant +everbloomer +evocation +exarchateship +exasperate +excorticate +excrementary +exile +expandedly +exponency +expressionist +expulsion +extemporary +extollation +extortive +extrabulbar +extraprostatic +facticide +fairer +fakery +fasibitikite +fatiscent +fearless +febrifuge +ferie +fibrousness +fingered +fisheye +flagpole +flagrantness +fleche +fluidism +folliculin +footbreadth +forceps +forecontrive +forthbring +foveated +fuchsin +fungicidal +funori +gamelang +gametically +garvanzo +gasoliner +gastrophile +germproof +gerontism +gigantical +glaciology +godmotherhood +gooseherd +gordunite +gove +gracilis +greathead +grieveship +guidable +gyromancy +gyrostat +habitus +hailweed +handhole +hangalai +haznadar +heliced +hemihypertrophy +hemimorphic +hemistrumectomy +heptavalent +heptite +herbalist +herpetology +hesperid +hexacarbon +hieromnemon +hobbyless +holodactylic +homoeoarchy +hopperings +hospitable +houseboat +huh +huntedly +hydroponics +hydrosomal +hyperdactylia +hyperperistalsis +hypogeocarpous +ideogram +idiopathical +illegitimate +imambarah +impotently +improvise +impuberal +inaccurately +incarnant +inchoation +incliner +incredulous +indiscriminateness +indulgenced +inebriation +inexpressiveness +infibulate +inflectedness +iniome +ink +inquietly +insaturable +insinuative +instiller +institutive +insultproof +interactionist +intercensal +interpenetrable +intertranspicuous +intrinsicality +inwards +iridiocyte +iridoparalysis +irreportable +isoprene +isosmotic +izard +jacuaru +jaculative +jerkined +joe +joyous +julienne +justicehood +kali +kalidium +katha +kathal +keelage +keratomycosis +khaki +khedival +kinkily +knife +kolo +kraken +kwarta +labba +labber +laboress +lacunar +latch +lauric +lawter +lectotype +leeches +legible +lepidosteoid +leucobasalt +leverer +libellate +limnimeter +lithography +lithotypic +locomotor +logarithmetically +logistician +lyncine +lysogenesis +machan +macromyelon +maharana +mandibulate +manganapatite +marchpane +mas +masochistic +mastaba +matching +meditatively +megalopolitan +melaniline +mentum +mercaptides +mestome +metasomatism +meterless +micronuclear +micropetalous +microreaction +microsporophore +mileway +milliarium +millisecond +misbind +miscollocation +misreader +modernicide +modification +modulant +monkfish +monoamino +monocarbide +monographical +morphinomaniac +mullein +munge +mutilate +mycophagist +myelosarcoma +myospasm +myriadly +nagaika +naphthionate +natant +naviculaeform +nayward +neallotype +necrophilia +nectared +neigher +neogamous +neurodynia +neurorthopteran +nidation +nieceship +nitrobacteria +nitrosification +nogheaded +nonassertive +noneuphonious +nonextant +nonincrease +nonintermittent +nonmetallic +nonprehensile +nonremunerative +nonsocial +nonvesting +noontime +noreaster +nounal +nub +nucleoplasm +nullisome +numero +numerous +oblongatal +observe +obtusilingual +obvert +occipitoatlantal +oceanside +ochlophobist +odontiasis +opalescence +opticon +oraculousness +orarium +organically +orthopedically +ostosis +overadvance +overbuilt +overdiscouragement +overdoer +overhardy +overjocular +overmagnify +overofficered +overpotent +overprizer +overrunner +overshrink +oversimply +oversplash +ovology +oxskin +oxychloride +oxygenant +ozokerite +pactional +palaeoanthropography +palaeographical +palaeopsychology +palliasse +palpebral +pandaric +pantelegraph +papicolist +papulate +parakinetic +parasitism +parochialic +parochialize +passionlike +patch +paucidentate +pawnbrokeress +pecite +pecky +pedipulation +pellitory +perfilograph +periblast +perigemmal +periost +periplus +perishable +periwig +permansive +persistingly +persymmetrical +phantom +phasmatrope +philocaly +philogyny +philosophister +philotherianism +phorology +phototrophic +phrator +phratral +phthisipneumony +physogastry +phytologic +phytoptid +pianograph +picqueter +piculet +pigeoner +pimaric +pinesap +pist +planometer +platano +playful +plea +pleuropneumonic +plowwoman +plump +pluviographical +pneumocele +podophthalmate +polyad +polythalamian +poppyhead +portamento +portmanteau +portraitlike +possible +potassamide +powderer +praepubis +preanesthetic +prebarbaric +predealer +predomination +prefactory +preirrigational +prelector +presbytership +presecure +preservable +prespecialist +preventionism +prewound +princely +priorship +proannexationist +proanthropos +probeable +probouleutic +profitless +proplasma +prosectorial +protecting +protochemistry +protosulphate +pseudoataxia +psilology +psychoneurotic +pterygial +publicist +purgation +purplishness +putatively +pyracene +pyrenomycete +pyromancy +pyrophone +quadroon +quailhead +qualifier +quaternal +rabblelike +rambunctious +rapidness +ratably +rationalism +razor +reannoy +recultivation +regulable +reimplant +reimposition +reimprison +reinjure +reinspiration +reintroduce +remantle +reprehensibility +reptant +require +resteal +restful +returnability +revisableness +rewash +rewhirl +reyield +rhizotomy +rhodamine +rigwiddie +rimester +ripper +rippet +rockish +rockwards +rollicky +roosters +rooted +rosal +rozum +saccharated +sagamore +sagy +salesmanship +salivous +sallet +salta +saprostomous +satiation +sauropsid +sawarra +sawback +scabish +scabrate +scampavia +scientificophilosophical +scirrosity +scoliometer +scolopendrelloid +secantly +seignioral +semibull +semic +seminarianism +semiped +semiprivate +semispherical +semispontaneous +seneschal +septendecimal +serotherapist +servation +sesquisulphuret +severish +sextipartite +sextubercular +shipyard +shuckpen +siderosis +silex +sillyhow +silverbelly +silverbelly +simulacrum +sisham +sixte +skeiner +skiapod +slopped +slubby +smalts +sockmaker +solute +somethingness +somnify +southwester +spathilla +spectrochemical +sphagnology +spinales +spiriting +spirling +spirochetemia +spreadboard +spurflower +squawdom +squeezing +staircase +staker +stamphead +statolith +stekan +stellulate +stinker +stomodaea +streamingly +strikingness +strouthocamelian +stuprum +subacutely +subboreal +subcontractor +subendorsement +subprofitable +subserviate +subsneer +subungual +sucuruju +sugan +sulphocarbolate +summerwood +superficialist +superinference +superregenerative +supplicate +suspendible +synchronizer +syntectic +tachyglossate +tailless +taintment +takingly +taletelling +tarpon +tasteful +taxeater +taxy +teache +teachless +teg +tegmen +teletyper +temperable +ten +tenent +teskere +testes +thallogen +thapsia +thewness +thickety +thiobacteria +thorniness +throwing +thyroprivic +tinnitus +tocalote +tolerationist +tonalamatl +torvous +totality +tottering +toug +tracheopathia +tragedical +translucent +trifoveolate +trilaurin +trophoplasmatic +trunkless +turbanless +turnpiker +twangle +twitterboned +ultraornate +umbilication +unabatingly +unabjured +unadequateness +unaffectedness +unarriving +unassorted +unattacked +unbenumbed +unboasted +unburning +uncensorious +uncongested +uncontemnedly +uncontemporary +uncrook +uncrystallizability +uncurb +uncustomariness +underbillow +undercanopy +underestimation +underhanging +underpetticoated +underpropped +undersole +understocking +underworld +undevout +undisappointing +undistinctive +unfiscal +unfluted +unfreckled +ungentilize +unglobe +unhelped +unhomogeneously +unifoliate +uninflammable +uninterrogated +unisonal +unkindled +unlikeableness +unlisty +unlocked +unmoving +unmultipliable +unnestled +unnoticed +unobservable +unobviated +unoffensively +unofficerlike +unpoetic +unpractically +unquestionableness +unrehearsed +unrevised +unrhetorical +unsadden +unsaluting +unscriptural +unseeking +unshowed +unsolicitous +unsprouted +unsubjective +unsubsidized +unsymbolic +untenant +unterrified +untranquil +untraversed +untrusty +untying +unwillful +unwinding +upspring +uptwist +urachovesical +uropygial +vagabondism +varicoid +varletess +vasal +ventrocaudal +verisimilitude +vermigerous +vibrometer +viminal +virus +vocationalism +voguey +vulnerability +waggle +wamblingly +warmus +waxer +waying +wedgeable +wellmaker +whomever +wigged +witchlike +wokas +woodrowel +woodsman +woolding +xanthelasmic +xiphosternum +yachtman +yachtsmanlike +yelp +zoophytal \ No newline at end of file diff --git a/tests/phpunit/includes/cache/GenderCacheTest.php b/tests/phpunit/includes/cache/GenderCacheTest.php new file mode 100644 index 00000000..ce2db5d7 --- /dev/null +++ b/tests/phpunit/includes/cache/GenderCacheTest.php @@ -0,0 +1,104 @@ +getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTMalePassword' ); + } + //ensure the right gender + $user->setOption( 'gender', 'male' ); + $user->saveSettings(); + + $user = User::newFromName( 'UTFemale' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTFemalePassword' ); + } + //ensure the right gender + $user->setOption( 'gender', 'female' ); + $user->saveSettings(); + + $user = User::newFromName( 'UTDefaultGender' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTDefaultGenderPassword' ); + } + //ensure the default gender + $user->setOption( 'gender', null ); + $user->saveSettings(); + } + + /** + * test usernames + * + * @dataProvider provideUserGenders + * @covers GenderCache::getGenderOf + */ + public function testUserName( $username, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $gender = $genderCache->getGenderOf( $username ); + $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); + } + + /** + * genderCache should work with user objects, too + * + * @dataProvider provideUserGenders + * @covers GenderCache::getGenderOf + */ + public function testUserObjects( $username, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $user = User::newFromName( $username ); + $gender = $genderCache->getGenderOf( $user ); + $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); + } + + public static function provideUserGenders() { + return array( + array( 'UTMale', 'male' ), + array( 'UTFemale', 'female' ), + array( 'UTDefaultGender', 'unknown' ), + array( 'UTNotExist', 'unknown' ), + //some not valid user + array( '127.0.0.1', 'unknown' ), + array( 'user@test', 'unknown' ), + ); + } + + /** + * test strip of subpages to avoid unnecessary queries + * against the never existing username + * + * @dataProvider provideStripSubpages + * @covers GenderCache::getGenderOf + */ + public function testStripSubpages( $pageWithSubpage, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $gender = $genderCache->getGenderOf( $pageWithSubpage ); + $this->assertEquals( $gender, $expectedGender, "GenderCache must strip of subpages" ); + } + + public static function provideStripSubpages() { + return array( + array( 'UTMale/subpage', 'male' ), + array( 'UTFemale/subpage', 'female' ), + array( 'UTDefaultGender/subpage', 'unknown' ), + array( 'UTNotExist/subpage', 'unknown' ), + array( '127.0.0.1/subpage', 'unknown' ), + ); + } +} diff --git a/tests/phpunit/includes/cache/LocalisationCacheTest.php b/tests/phpunit/includes/cache/LocalisationCacheTest.php new file mode 100644 index 00000000..fc06a501 --- /dev/null +++ b/tests/phpunit/includes/cache/LocalisationCacheTest.php @@ -0,0 +1,91 @@ +setMwGlobals( array( + 'wgMessagesDirs' => array( "$IP/tests/phpunit/data/localisationcache" ), + 'wgExtensionMessagesFiles' => array(), + 'wgHooks' => array(), + ) ); + } + + public function testPuralRulesFallback() { + $cache = new LocalisationCache( array( 'store' => 'detect' ) ); + + $this->assertEquals( + $cache->getItem( 'ar', 'pluralRules' ), + $cache->getItem( 'arz', 'pluralRules' ), + 'arz plural rules (undefined) fallback to ar (defined)' + ); + + $this->assertEquals( + $cache->getItem( 'ar', 'compiledPluralRules' ), + $cache->getItem( 'arz', 'compiledPluralRules' ), + 'arz compiled plural rules (undefined) fallback to ar (defined)' + ); + + $this->assertNotEquals( + $cache->getItem( 'ksh', 'pluralRules' ), + $cache->getItem( 'de', 'pluralRules' ), + 'ksh plural rules (defined) dont fallback to de (defined)' + ); + + $this->assertNotEquals( + $cache->getItem( 'ksh', 'compiledPluralRules' ), + $cache->getItem( 'de', 'compiledPluralRules' ), + 'ksh compiled plural rules (defined) dont fallback to de (defined)' + ); + } + + public function testRecacheFallbacks() { + $lc = new LocalisationCache( array( 'store' => 'detect' ) ); + $lc->recache( 'uk' ); + $this->assertEquals( + array( + 'present-uk' => 'uk', + 'present-ru' => 'ru', + 'present-en' => 'en', + ), + $lc->getItem( 'uk', 'messages' ), + 'Fallbacks are only used to fill missing data' + ); + } + + public function testRecacheFallbacksWithHooks() { + global $wgHooks; + + // Use hook to provide updates for messages. This is what the + // LocalisationUpdate extension does. See bug 68781. + $wgHooks['LocalisationCacheRecacheFallback'][] = function ( + LocalisationCache $lc, + $code, + array &$cache + ) { + if ( $code === 'ru' ) { + $cache['messages']['present-uk'] = 'ru-override'; + $cache['messages']['present-ru'] = 'ru-override'; + $cache['messages']['present-en'] = 'ru-override'; + } + }; + + $lc = new LocalisationCache( array( 'store' => 'detect' ) ); + $lc->recache( 'uk' ); + $this->assertEquals( + array( + 'present-uk' => 'uk', + 'present-ru' => 'ru-override', + 'present-en' => 'ru-override', + ), + $lc->getItem( 'uk', 'messages' ), + 'Updates provided by hooks follow the normal fallback order.' + ); + } +} diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php new file mode 100644 index 00000000..442e9f9f --- /dev/null +++ b/tests/phpunit/includes/cache/MessageCacheTest.php @@ -0,0 +1,128 @@ +configureLanguages(); + MessageCache::singleton()->enable(); + } + + /** + * Helper function -- setup site language for testing + */ + protected function configureLanguages() { + // for the test, we need the content language to be anything but English, + // let's choose e.g. German (de) + $langCode = 'de'; + $langObj = Language::factory( $langCode ); + + $this->setMwGlobals( array( + 'wgLanguageCode' => $langCode, + 'wgLang' => $langObj, + 'wgContLang' => $langObj, + ) ); + } + + function addDBData() { + $this->configureLanguages(); + + // Set up messages and fallbacks ab -> ru -> de + $this->makePage( 'FallbackLanguageTest-Full', 'ab' ); + $this->makePage( 'FallbackLanguageTest-Full', 'ru' ); + $this->makePage( 'FallbackLanguageTest-Full', 'de' ); + + // Fallbacks where ab does not exist + $this->makePage( 'FallbackLanguageTest-Partial', 'ru' ); + $this->makePage( 'FallbackLanguageTest-Partial', 'de' ); + + // Fallback to the content language + $this->makePage( 'FallbackLanguageTest-ContLang', 'de' ); + + // Add customizations for an existing message. + $this->makePage( 'sunday', 'ru' ); + + // Full key tests -- always want russian + $this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' ); + $this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' ); + + // In content language -- get base if no derivative + $this->makePage( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none', false ); + } + + /** + * Helper function for addDBData -- adds a simple page to the database + * + * @param string $title Title of page to be created + * @param string $lang Language and content of the created page + * @param string|null $content Content of the created page, or null for a generic string + * @param bool $createSubPage Set to false if a root page should be created + */ + protected function makePage( $title, $lang, $content = null, $createSubPage = true ) { + global $wgContLang; + + if ( $content === null ) { + $content = $lang; + } + if ( $lang !== $wgContLang->getCode() || $createSubPage ) { + $title = "$title/$lang"; + } + + $title = Title::newFromText( $title, NS_MEDIAWIKI ); + $wikiPage = new WikiPage( $title ); + $contentHandler = ContentHandler::makeContent( $content, $title ); + $wikiPage->doEditContent( $contentHandler, "$lang translation test case" ); + } + + /** + * Test message fallbacks, bug #1495 + * + * @dataProvider provideMessagesForFallback + */ + public function testMessageFallbacks( $message, $lang, $expectedContent ) { + $result = MessageCache::singleton()->get( $message, true, $lang ); + $this->assertEquals( $expectedContent, $result, "Message fallback failed." ); + } + + function provideMessagesForFallback() { + return array( + array( 'FallbackLanguageTest-Full', 'ab', 'ab' ), + array( 'FallbackLanguageTest-Partial', 'ab', 'ru' ), + array( 'FallbackLanguageTest-ContLang', 'ab', 'de' ), + array( 'FallbackLanguageTest-None', 'ab', false ), + + // Existing message with customizations on the fallbacks + array( 'sunday', 'ab', 'амҽыш' ), + + // bug 46579 + array( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' ), + // UI language different from content language should only use de/none as last option + array( 'FallbackLanguageTest-NoDervContLang', 'fit', 'de/none' ), + ); + } + + /** + * There's a fallback case where the message key is given as fully qualified -- this + * should ignore the passed $lang and use the language from the key + * + * @dataProvider provideMessagesForFullKeys + */ + public function testFullKeyBehaviour( $message, $lang, $expectedContent ) { + $result = MessageCache::singleton()->get( $message, true, $lang, true ); + $this->assertEquals( $expectedContent, $result, "Full key message fallback failed." ); + } + + function provideMessagesForFullKeys() { + return array( + array( 'MessageCacheTest-FullKeyTest/ru', 'ru', 'ru' ), + array( 'MessageCacheTest-FullKeyTest/ru', 'ab', 'ru' ), + array( 'MessageCacheTest-FullKeyTest/ru/foo', 'ru', false ), + ); + } + +} diff --git a/tests/phpunit/includes/cache/RedisBloomCacheTest.php b/tests/phpunit/includes/cache/RedisBloomCacheTest.php new file mode 100644 index 00000000..3d491e90 --- /dev/null +++ b/tests/phpunit/includes/cache/RedisBloomCacheTest.php @@ -0,0 +1,71 @@ +delete( "unit-testing-" . self::$suffix ); + } else { + $this->markTestSkipped( 'The main bloom cache is not redis.' ); + } + } + + public function testBloomCache() { + $key = "unit-testing-" . self::$suffix; + $fcache = BloomCache::get( 'main' ); + $count = 1500; + + $this->assertTrue( $fcache->delete( $key ), "OK delete of filter '$key'." ); + $this->assertTrue( $fcache->init( $key, $count, .001 ), "OK init of filter '$key'." ); + + $members = array(); + for ( $i = 0; $i < $count; ++$i ) { + $members[] = "$i-value-$i"; + } + $this->assertTrue( $fcache->add( $key, $members ), "Addition of members to '$key' OK." ); + + for ( $i = 0; $i < $count; ++$i ) { + $this->assertTrue( $fcache->isHit( $key, "$i-value-$i" ), "Hit on member '$i-value-$i'." ); + } + + $falsePositives = array(); + for ( $i = $count; $i < 2 * $count; ++$i ) { + if ( $fcache->isHit( $key, "value$i" ) ) { + $falsePositives[] = "value$i"; + } + } + + $eFalsePositives = array( + 'value1763', + 'value2245', + 'value2353', + 'value2791', + 'value2898', + 'value2975' + ); + $this->assertEquals( $eFalsePositives, $falsePositives, "Correct number of false positives found." ); + } + + protected function tearDown() { + parent::tearDown(); + + $fcache = BloomCache::get( 'main' ); + if ( $fcache instanceof BloomCacheRedis ) { + $fcache->delete( "unit-testing-" . self::$suffix ); + } + } +} diff --git a/tests/phpunit/includes/changes/EnhancedChangesListTest.php b/tests/phpunit/includes/changes/EnhancedChangesListTest.php new file mode 100644 index 00000000..40a11d2d --- /dev/null +++ b/tests/phpunit/includes/changes/EnhancedChangesListTest.php @@ -0,0 +1,132 @@ + + */ +class EnhancedChangesListTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + public function testBeginRecentChangesList_styleModules() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $styleModules = $enhancedChangesList->getOutput()->getModuleStyles(); + + $this->assertContains( + 'mediawiki.special.changeslist', + $styleModules, + 'has mediawiki.special.changeslist' + ); + + $this->assertContains( + 'mediawiki.special.changeslist.enhanced', + $styleModules, + 'has mediawiki.special.changeslist.enhanced' + ); + } + + public function testBeginRecentChangesList_jsModules() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $modules = $enhancedChangesList->getOutput()->getModules(); + + $this->assertContains( 'jquery.makeCollapsible', $modules, 'has jquery.makeCollapsible' ); + $this->assertContains( 'mediawiki.icon', $modules, 'has mediawiki.icon' ); + } + + public function testBeginRecentChangesList_html() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $html = $enhancedChangesList->beginRecentChangesList(); + + $this->assertEquals( '
', $html ); + } + + /** + * @todo more tests + */ + public function testRecentChangesLine() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $recentChange = $this->getEditChange( '20131103092153' ); + $html = $enhancedChangesList->recentChangesLine( $recentChange, false ); + + $this->assertInternalType( 'string', $html ); + + $recentChange2 = $this->getEditChange( '20131103092253' ); + $html = $enhancedChangesList->recentChangesLine( $recentChange2, false ); + + $this->assertEquals( '', $html ); + } + + /** + * @todo more tests for actual formatting, this is more of a smoke test + */ + public function testEndRecentChangesList() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $recentChange = $this->getEditChange( '20131103092153' ); + $enhancedChangesList->recentChangesLine( $recentChange, false ); + + $recentChange2 = $this->getEditChange( '20131103092253' ); + $enhancedChangesList->recentChangesLine( $recentChange2, false ); + + $html = $enhancedChangesList->endRecentChangesList(); + + preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches ); + $this->assertCount( 2, $matches[0] ); + } + + /** + * @return EnhancedChangesList + */ + private function newEnhancedChangesList() { + $user = User::newFromId( 0 ); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + + return new EnhancedChangesList( $context ); + } + + /** + * @return RecentChange + */ + private function getEditChange( $timestamp ) { + $user = $this->getTestUser(); + $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( + $user, 'Cat', $timestamp, 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + /** + * @return User + */ + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + +} diff --git a/tests/phpunit/includes/changes/OldChangesListTest.php b/tests/phpunit/includes/changes/OldChangesListTest.php new file mode 100644 index 00000000..2ea9f33e --- /dev/null +++ b/tests/phpunit/includes/changes/OldChangesListTest.php @@ -0,0 +1,187 @@ + + */ +class OldChangesListTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1', + 'wgLang' => Language::factory( 'qqx' ) + ) ); + } + + /** + * @dataProvider recentChangesLine_CssForLineNumberProvider + */ + public function testRecentChangesLine_CssForLineNumber( $expected, $linenumber, $message ) { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, $linenumber ); + + $this->assertRegExp( $expected, $line, $message ); + } + + public function recentChangesLine_CssForLineNumberProvider() { + return array( + array( '/mw-line-odd/', 1, 'odd line number' ), + array( '/mw-line-even/', 2, 'even line number' ) + ); + } + + public function testRecentChangesLine_NotWatchedCssClass() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/mw-changeslist-line-not-watched/', $line ); + } + + public function testRecentChangesLine_WatchedCssClass() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, true, 1 ); + + $this->assertRegExp( '/mw-changeslist-line-watched/', $line ); + } + + public function testRecentChangesLine_LogTitle() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getLogChange( 'delete', 'delete' ); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/href="\/wiki\/Special:Log\/delete/', $line, 'link has href attribute' ); + $this->assertRegExp( '/title="Special:Log\/delete/', $line, 'link has title attribute' ); + $this->assertRegExp( "/dellogpage/", $line, 'link text' ); + } + + public function testRecentChangesLine_DiffHistLinks() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( + '/title=Cat&curid=20131103212153&diff=5&oldid=191/', + $line, + 'assert diff link' + ); + + $this->assertRegExp( '/tabindex="0"/', $line, 'assert tab index' ); + $this->assertRegExp( + '/title=Cat&curid=20131103212153&action=history"/', + $line, + 'assert history link' + ); + } + + public function testRecentChangesLine_Flags() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getNewBotEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertContains( + "(newpageletter)", + $line, + 'new page flag' + ); + + $this->assertContains( + "(boteditletter)", + $line, + 'bot flag' + ); + } + + public function testRecentChangesLine_Tags() { + $recentChange = $this->getEditChange(); + $recentChange->mAttribs['ts_tags'] = 'vandalism,newbie'; + + $oldChangesList = $this->getOldChangesList(); + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/
  • /', $line ); + $this->assertRegExp( '/
  • /', $line ); + } + + private function getNewBotEditChange() { + $user = $this->getTestUser(); + + $recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange( + $user, 'Abc', '20131103212153', 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + private function getLogChange( $logType, $logAction ) { + $user = $this->getTestUser(); + + $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( + $logType, $logAction, $user, 'Abc', '20131103212153', 0, 0 + ); + + return $recentChange; + } + + private function getEditChange() { + $user = $this->getTestUser(); + $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( + $user, 'Cat', '20131103212153', 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + private function getOldChangesList() { + $context = $this->getContext(); + return new OldChangesList( $context ); + } + + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + + private function getContext() { + $user = $this->getTestUser(); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + $context->setLanguage( Language::factory( 'qqx' ) ); + + return $context; + } + +} diff --git a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php new file mode 100644 index 00000000..ee1a4d0e --- /dev/null +++ b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php @@ -0,0 +1,331 @@ + + */ +class RCCacheEntryFactoryTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1' + ) ); + } + + /** + * @dataProvider editChangeProvider + */ + public function testNewFromRecentChange( $expected, $context, $messages, + $recentChange, $watched + ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], $cacheEntry->numberofWatchingusers, + 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + $this->assertTitleLink( 'Xyz', $cacheEntry ); + + $this->assertQueryLink( 'cur', $expected['cur'], $cacheEntry->curlink, 'cur link' ); + $this->assertQueryLink( 'prev', $expected['diff'], $cacheEntry->lastlink, 'prev link' ); + $this->assertQueryLink( 'diff', $expected['diff'], $cacheEntry->difflink, 'diff link' ); + } + + public function editChangeProvider() { + return array( + array( + array( + 'title' => 'Xyz', + 'user' => 'TestRecentChangesUser', + 'diff' => array( 'curid' => 5, 'diff' => 191, 'oldid' => 190 ), + 'cur' => array( 'curid' => 5, 'diff' => 0, 'oldid' => 191 ), + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeEditRecentChange( + $this->getTestUser(), + 'Xyz', + 5, // curid + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + /** + * @dataProvider deleteChangeProvider + */ + public function testNewForDeleteChange( $expected, $context, $messages, $recentChange, $watched ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], + $cacheEntry->numberofWatchingusers, 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertDeleteLogLink( $cacheEntry ); + $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + + $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' ); + $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' ); + $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); + } + + public function deleteChangeProvider() { + return array( + array( + array( + 'title' => 'Abc', + 'user' => 'TestRecentChangesUser', + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeLogRecentChange( + 'delete', + 'delete', + $this->getTestUser(), + 'Abc', + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + /** + * @dataProvider revUserDeleteProvider + */ + public function testNewForRevUserDeleteChange( $expected, $context, $messages, + $recentChange, $watched + ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], + $cacheEntry->numberofWatchingusers, 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertRevDel( $cacheEntry ); + $this->assertTitleLink( 'Zzz', $cacheEntry ); + + $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' ); + $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' ); + $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); + } + + public function revUserDeleteProvider() { + return array( + array( + array( + 'title' => 'Zzz', + 'user' => 'TestRecentChangesUser', + 'diff' => '', + 'cur' => '', + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeDeletedEditRecentChange( + $this->getTestUser(), + 'Zzz', + '20131103212153', + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + private function assertUserLinks( $user, $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'class' => 'new mw-userlink' + ), + 'content' => $user + ), + $cacheEntry->userlink, + 'verify user link' + ); + + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'mw-usertoollinks' + ), + 'child' => array( + 'tag' => 'a', + 'content' => 'Talk', + ) + ), + $cacheEntry->usertalklink, + 'verify user talk link' + ); + + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'mw-usertoollinks' + ), + 'child' => array( + 'tag' => 'a', + 'content' => 'contribs', + ) + ), + $cacheEntry->usertalklink, + 'verify user tool links' + ); + } + + private function assertDeleteLogLink( $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'href' => '/wiki/Special:Log/delete', + 'title' => 'Special:Log/delete' + ), + 'content' => 'Deletion log' + ), + $cacheEntry->link, + 'verify deletion log link' + ); + } + + private function assertRevDel( $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'history-deleted' + ), + 'content' => '(username removed)' + ), + $cacheEntry->userlink, + 'verify user link for change with deleted revision and user' + ); + } + + private function assertTitleLink( $title, $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'href' => '/wiki/' . $title, + 'title' => $title + ), + 'content' => $title + ), + $cacheEntry->link, + 'verify title link' + ); + } + + private function assertQueryLink( $content, $params, $link ) { + $this->assertTag( + array( + 'tag' => 'a', + 'content' => $content + ), + $link, + 'assert query link element' + ); + + foreach ( $params as $key => $value ) { + $this->assertRegExp( '/' . $key . '=' . $value . '/', $link, "verify $key link params" ); + } + } + + private function getMessages() { + return array( + 'cur' => 'cur', + 'diff' => 'diff', + 'hist' => 'hist', + 'enhancedrc-history' => 'history', + 'last' => 'prev', + 'blocklink' => 'block', + 'history' => 'Page history', + 'semicolon-separator' => '; ', + 'pipe-separator' => ' | ' + ); + } + + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + + private function getContext() { + $user = $this->getTestUser(); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + + $title = Title::newFromText( 'RecentChanges', NS_SPECIAL ); + $context->setTitle( $title ); + + return $context; + } +} diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php new file mode 100644 index 00000000..98903f1e --- /dev/null +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -0,0 +1,286 @@ +title = Title::newFromText( 'SomeTitle' ); + $this->target = Title::newFromText( 'TestTarget' ); + $this->user = User::newFromName( 'UserName' ); + + $this->user_comment = ''; + $this->context = RequestContext::newExtraneousContext( $this->title ); + } + + /** + * The testIrcMsgForAction* tests are supposed to cover the hacky + * LogFormatter::getIRCActionText / bug 34508 + * + * Third parties bots listen to those messages. They are clever enough + * to fetch the i18n messages from the wiki and then analyze the IRC feed + * to reverse engineer the $1, $2 messages. + * One thing bots can not detect is when MediaWiki change the meaning of + * a message like what happened when we deployed 1.19. $1 became the user + * performing the action which broke basically all bots around. + * + * Should cover the following log actions (which are most commonly used by bots): + * - block/block + * - block/unblock + * - delete/delete + * - delete/restore + * - newusers/create + * - newusers/create2 + * - newusers/autocreate + * - move/move + * - move/move_redir + * - protect/protect + * - protect/modifyprotect + * - protect/unprotect + * - upload/upload + * + * As well as the following Auto Edit Summaries: + * - blank + * - replace + * - rollback + * - undo + */ + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeBlock() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # block/block + $this->assertIRCComment( + $this->context->msg( 'blocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'block', 'block', + array(), + $this->user_comment + ); + # block/unblock + $this->assertIRCComment( + $this->context->msg( 'unblocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'block', 'unblock', + array(), + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeDelete() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # delete/delete + $this->assertIRCComment( + $this->context->msg( 'deletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'delete', 'delete', + array(), + $this->user_comment + ); + + # delete/restore + $this->assertIRCComment( + $this->context->msg( 'undeletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'delete', 'restore', + array(), + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeNewusers() { + $this->assertIRCComment( + 'New user account', + 'newusers', 'newusers', + array() + ); + $this->assertIRCComment( + 'New user account', + 'newusers', 'create', + array() + ); + $this->assertIRCComment( + 'created new account SomeTitle', + 'newusers', 'create2', + array() + ); + $this->assertIRCComment( + 'Account created automatically', + 'newusers', 'autocreate', + array() + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeMove() { + $move_params = array( + '4::target' => $this->target->getPrefixedText(), + '5::noredir' => 0, + ); + $sep = $this->context->msg( 'colon-separator' )->text(); + + # move/move + $this->assertIRCComment( + $this->context->msg( '1movedto2', 'SomeTitle', 'TestTarget' ) + ->plain() . $sep . $this->user_comment, + 'move', 'move', + $move_params, + $this->user_comment + ); + + # move/move_redir + $this->assertIRCComment( + $this->context->msg( '1movedto2_redir', 'SomeTitle', 'TestTarget' ) + ->plain() . $sep . $this->user_comment, + 'move', 'move_redir', + $move_params, + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypePatrol() { + # patrol/patrol + $this->assertIRCComment( + $this->context->msg( 'patrol-log-line', 'revision 777', '[[SomeTitle]]', '' )->plain(), + 'patrol', 'patrol', + array( + '4::curid' => '777', + '5::previd' => '666', + '6::auto' => 0, + ) + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeProtect() { + $protectParams = array( + '[edit=sysop] (indefinite) ‎[move=sysop] (indefinite)' + ); + $sep = $this->context->msg( 'colon-separator' )->text(); + + # protect/protect + $this->assertIRCComment( + $this->context->msg( 'protectedarticle', 'SomeTitle ' . $protectParams[0] ) + ->plain() . $sep . $this->user_comment, + 'protect', 'protect', + $protectParams, + $this->user_comment + ); + + # protect/unprotect + $this->assertIRCComment( + $this->context->msg( 'unprotectedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'protect', 'unprotect', + array(), + $this->user_comment + ); + + # protect/modify + $this->assertIRCComment( + $this->context->msg( 'modifiedarticleprotection', 'SomeTitle ' . $protectParams[0] ) + ->plain() . $sep . $this->user_comment, + 'protect', 'modify', + $protectParams, + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeUpload() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # upload/upload + $this->assertIRCComment( + $this->context->msg( 'uploadedimage', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'upload', 'upload', + array(), + $this->user_comment + ); + + # upload/overwrite + $this->assertIRCComment( + $this->context->msg( 'overwroteimage', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'upload', 'overwrite', + array(), + $this->user_comment + ); + } + + /** + * @todo Emulate these edits somehow and extract + * raw edit summary from RecentChange object + * -- + */ + /* + public function testIrcMsgForBlankingAES() { + // $this->context->msg( 'autosumm-blank', .. ); + } + + public function testIrcMsgForReplaceAES() { + // $this->context->msg( 'autosumm-replace', .. ); + } + + public function testIrcMsgForRollbackAES() { + // $this->context->msg( 'revertpage', .. ); + } + + public function testIrcMsgForUndoAES() { + // $this->context->msg( 'undo-summary', .. ); + } + */ + + /** + * @param string $expected Expected IRC text without colors codes + * @param string $type Log type (move, delete, suppress, patrol ...) + * @param string $action A log type action + * @param array $params + * @param string $comment (optional) A comment for the log action + * @param string $msg (optional) A message for PHPUnit :-) + */ + protected function assertIRCComment( $expected, $type, $action, $params, + $comment = null, $msg = '' + ) { + $logEntry = new ManualLogEntry( $type, $action ); + $logEntry->setPerformer( $this->user ); + $logEntry->setTarget( $this->title ); + if ( $comment !== null ) { + $logEntry->setComment( $comment ); + } + $logEntry->setParameters( $params ); + + $formatter = LogFormatter::newFromEntry( $logEntry ); + $formatter->setContext( $this->context ); + + // Apply the same transformation as done in IRCColourfulRCFeedFormatter::getLine for rc_comment + $ircRcComment = IRCColourfulRCFeedFormatter::cleanupForIRC( $formatter->getIRCActionComment() ); + + $this->assertEquals( + $expected, + $ircRcComment, + $msg + ); + } +} diff --git a/tests/phpunit/includes/changes/TestRecentChangesHelper.php b/tests/phpunit/includes/changes/TestRecentChangesHelper.php new file mode 100644 index 00000000..ad643274 --- /dev/null +++ b/tests/phpunit/includes/changes/TestRecentChangesHelper.php @@ -0,0 +1,137 @@ + + */ +class TestRecentChangesHelper { + + public function makeEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid, + $timestamp, $counter, $watchingUsers + ) { + + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid, + 'rc_cur_id' => $curid + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeLogRecentChange( $logType, $logAction, User $user, $titleText, $timestamp, $counter, + $watchingUsers + ) { + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_cur_id' => 0, + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => 0, + 'rc_last_oldid' => 0, + 'rc_old_len' => null, + 'rc_new_len' => null, + 'rc_type' => 3, + 'rc_logid' => 25, + 'rc_log_type' => $logType, + 'rc_log_action' => $logAction, + 'rc_source' => 'mw.log' + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeDeletedEditRecentChange( User $user, $titleText, $timestamp, $curid, + $thisid, $lastid, $counter, $watchingUsers + ) { + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_deleted' => 5, + 'rc_cur_id' => $curid, + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeNewBotEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid, + $timestamp, $counter, $watchingUsers + ) { + + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid, + 'rc_cur_id' => $curid, + 'rc_type' => 1, + 'rc_bot' => 1, + 'rc_source' => 'mw.new' + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + private function makeRecentChange( $attribs, $counter, $watchingUsers ) { + $change = new RecentChange(); + $change->setAttribs( $attribs ); + $change->counter = $counter; + $change->numberofWatchingusers = $watchingUsers; + + return $change; + } + + private function getDefaultAttributes( $titleText, $timestamp ) { + return array( + 'rc_id' => 545, + 'rc_user' => 0, + 'rc_user_text' => '127.0.0.1', + 'rc_ip' => '127.0.0.1', + 'rc_title' => $titleText, + 'rc_namespace' => 0, + 'rc_timestamp' => $timestamp, + 'rc_old_len' => 212, + 'rc_new_len' => 188, + 'rc_comment' => '', + 'rc_minor' => 0, + 'rc_bot' => 0, + 'rc_type' => 0, + 'rc_patrolled' => 1, + 'rc_deleted' => 0, + 'rc_logid' => 0, + 'rc_log_type' => null, + 'rc_log_action' => '', + 'rc_params' => '', + 'rc_source' => 'mw.edit' + ); + } + + public function getTestContext( User $user ) { + $context = new RequestContext(); + $context->setLanguage( Language::factory( 'en' ) ); + + $context->setUser( $user ); + + $title = Title::newFromText( 'RecentChanges', NS_SPECIAL ); + $context->setTitle( $title ); + + return $context; + } +} diff --git a/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php new file mode 100644 index 00000000..3f887dc0 --- /dev/null +++ b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php @@ -0,0 +1,161 @@ + + */ +class ComposerVersionNormalizerTest extends PHPUnit_Framework_TestCase { + + /** + * @dataProvider nonStringProvider + */ + public function testGivenNonString_normalizeThrowsInvalidArgumentException( $nonString ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->setExpectedException( 'InvalidArgumentException' ); + $normalizer->normalizeSuffix( $nonString ); + } + + public function nonStringProvider() { + return array( + array( null ), + array( 42 ), + array( array() ), + array( new stdClass() ), + array( true ), + ); + } + + /** + * @dataProvider simpleVersionProvider + */ + public function testGivenSimpleVersion_normalizeSuffixReturnsAsIs( $simpleVersion ) { + $this->assertRemainsUnchanged( $simpleVersion ); + } + + protected function assertRemainsUnchanged( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeSuffix( $version ) + ); + } + + public function simpleVersionProvider() { + return array( + array( '1.22.0' ), + array( '1.19.2' ), + array( '1.19.2.0' ), + array( '1.9' ), + array( '123.321.456.654' ), + ); + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithoutDash_normalizeSuffixAddsDash( + $withoutDash, $withDash + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $withDash, + $normalizer->normalizeSuffix( $withoutDash ) + ); + } + + public function complexVersionProvider() { + return array( + array( '1.22.0alpha', '1.22.0-alpha' ), + array( '1.22.0RC', '1.22.0-RC' ), + array( '1.19beta', '1.19-beta' ), + array( '1.9RC4', '1.9-RC4' ), + array( '1.9.1.2RC4', '1.9.1.2-RC4' ), + array( '1.9.1.2RC', '1.9.1.2-RC' ), + array( '123.321.456.654RC9001', '123.321.456.654-RC9001' ), + ); + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithDash_normalizeSuffixReturnsAsIs( + $withoutDash, $withDash + ) { + $this->assertRemainsUnchanged( $withDash ); + } + + /** + * @dataProvider fourLevelVersionsProvider + */ + public function testGivenFourLevels_levelCountNormalizationDoesNothing( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function fourLevelVersionsProvider() { + return array( + array( '1.22.0.0' ), + array( '1.19.2.4' ), + array( '1.19.2.0' ), + array( '1.9.0.1' ), + array( '123.321.456.654' ), + array( '123.321.456.654RC4' ), + array( '123.321.456.654-RC4' ), + ); + } + + /** + * @dataProvider levelNormalizationProvider + */ + public function testGivenFewerLevels_levelCountNormalizationEnsuresFourLevels( + $expected, $version + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $expected, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function levelNormalizationProvider() { + return array( + array( '1.22.0.0', '1.22' ), + array( '1.22.0.0', '1.22.0' ), + array( '1.19.2.0', '1.19.2' ), + array( '12345.0.0.0', '12345' ), + array( '12345.0.0.0-RC4', '12345-RC4' ), + array( '12345.0.0.0-alpha', '12345-alpha' ), + ); + } + + /** + * @dataProvider invalidVersionProvider + */ + public function testGivenInvalidVersion_normalizeSuffixReturnsAsIs( $invalidVersion ) { + $this->assertRemainsUnchanged( $invalidVersion ); + } + + public function invalidVersionProvider() { + return array( + array( '1.221-a' ), + array( '1.221-' ), + array( '1.22rc4a' ), + array( 'a1.22rc' ), + array( '.1.22rc' ), + array( 'a' ), + array( 'alpha42' ), + ); + } +} diff --git a/tests/phpunit/includes/config/ConfigFactoryTest.php b/tests/phpunit/includes/config/ConfigFactoryTest.php new file mode 100644 index 00000000..3902858d --- /dev/null +++ b/tests/phpunit/includes/config/ConfigFactoryTest.php @@ -0,0 +1,70 @@ +register( 'unittest', 'GlobalVarConfig::newInstance' ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( 'InvalidArgumentException' ); + $factory->register( 'invalid', 'Invalid callback' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfig() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( 'Config', $conf ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithNoBuilders() { + $factory = new ConfigFactory(); + $this->setExpectedException( 'ConfigException' ); + $factory->makeConfig( 'nobuilderregistered' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithInvalidCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', function () { + return true; // Not a Config object + } ); + $this->setExpectedException( 'UnexpectedValueException' ); + $factory->makeConfig( 'unittest' ); + } + + /** + * @covers ConfigFactory::getDefaultInstance + */ + public function testGetDefaultInstance() { + // Set $wgConfigRegistry, and check the default + // instance read from it + $this->setMwGlobals( 'wgConfigRegistry', array( + 'conf1' => 'GlobalVarConfig::newInstance', + 'conf2' => 'GlobalVarConfig::newInstance', + ) ); + ConfigFactory::destroyDefaultInstance(); + $factory = ConfigFactory::getDefaultInstance(); + $this->assertInstanceOf( 'Config', $factory->makeConfig( 'conf1' ) ); + $this->assertInstanceOf( 'Config', $factory->makeConfig( 'conf2' ) ); + $this->setExpectedException( 'ConfigException' ); + $factory->makeConfig( 'conf3' ); + } +} diff --git a/tests/phpunit/includes/config/GlobalVarConfigTest.php b/tests/phpunit/includes/config/GlobalVarConfigTest.php new file mode 100644 index 00000000..70b9e684 --- /dev/null +++ b/tests/phpunit/includes/config/GlobalVarConfigTest.php @@ -0,0 +1,120 @@ +assertInstanceOf( 'GlobalVarConfig', $config ); + $this->maybeStashGlobal( 'wgBaz' ); + $GLOBALS['wgBaz'] = 'somevalue'; + // Check prefix is set to 'wg' + $this->assertEquals( 'somevalue', $config->get( 'Baz' ) ); + } + + /** + * @covers GlobalVarConfig::__construct + * @dataProvider provideConstructor + */ + public function testConstructor( $prefix ) { + $var = $prefix . 'GlobalVarConfigTest'; + $rand = wfRandomString(); + $this->maybeStashGlobal( $var ); + $GLOBALS[$var] = $rand; + $config = new GlobalVarConfig( $prefix ); + $this->assertInstanceOf( 'GlobalVarConfig', $config ); + $this->assertEquals( $rand, $config->get( 'GlobalVarConfigTest' ) ); + } + + public static function provideConstructor() { + return array( + array( 'wg' ), + array( 'ef' ), + array( 'smw' ), + array( 'blahblahblahblah' ), + array( '' ), + ); + } + + /** + * @covers GlobalVarConfig::has + */ + public function testHas() { + $this->maybeStashGlobal( 'wgGlobalVarConfigTestHas' ); + $GLOBALS['wgGlobalVarConfigTestHas'] = wfRandomString(); + $this->maybeStashGlobal( 'wgGlobalVarConfigTestNotHas' ); + $config = new GlobalVarConfig(); + $this->assertTrue( $config->has( 'GlobalVarConfigTestHas' ) ); + $this->assertFalse( $config->has( 'GlobalVarConfigTestNotHas' ) ); + } + + public static function provideGet() { + $set = array( + 'wgSomething' => 'default1', + 'wgFoo' => 'default2', + 'efVariable' => 'default3', + 'BAR' => 'default4', + ); + + foreach ( $set as $var => $value ) { + $GLOBALS[$var] = $value; + } + + return array( + array( 'Something', 'wg', 'default1' ), + array( 'Foo', 'wg', 'default2' ), + array( 'Variable', 'ef', 'default3' ), + array( 'BAR', '', 'default4' ), + array( 'ThisGlobalWasNotSetAbove', 'wg', false ) + ); + } + + /** + * @param string $name + * @param string $prefix + * @param string $expected + * @dataProvider provideGet + * @covers GlobalVarConfig::get + * @covers GlobalVarConfig::getWithPrefix + */ + public function testGet( $name, $prefix, $expected ) { + $config = new GlobalVarConfig( $prefix ); + if ( $expected === false ) { + $this->setExpectedException( 'ConfigException', 'GlobalVarConfig::get: undefined option:' ); + } + $this->assertEquals( $config->get( $name ), $expected ); + } + + public static function provideSet() { + return array( + array( 'Foo', 'wg', 'wgFoo' ), + array( 'SomethingRandom', 'wg', 'wgSomethingRandom' ), + array( 'FromAnExtension', 'eg', 'egFromAnExtension' ), + array( 'NoPrefixHere', '', 'NoPrefixHere' ), + ); + } + + private function maybeStashGlobal( $var ) { + if ( array_key_exists( $var, $GLOBALS ) ) { + // Will be reset after this test is over + $this->stashMwGlobals( $var ); + } + } + + /** + * @dataProvider provideSet + * @covers GlobalVarConfig::set + * @covers GlobalVarConfig::setWithPrefix + */ + public function testSet( $name, $prefix, $var ) { + $this->hideDeprecated( 'GlobalVarConfig::set' ); + $this->maybeStashGlobal( $var ); + $config = new GlobalVarConfig( $prefix ); + $random = wfRandomString(); + $config->set( $name, $random ); + $this->assertArrayHasKey( $var, $GLOBALS ); + $this->assertEquals( $random, $GLOBALS[$var] ); + } +} diff --git a/tests/phpunit/includes/config/HashConfigTest.php b/tests/phpunit/includes/config/HashConfigTest.php new file mode 100644 index 00000000..3ad3bfbd --- /dev/null +++ b/tests/phpunit/includes/config/HashConfigTest.php @@ -0,0 +1,63 @@ +assertInstanceOf( 'HashConfig', $conf ); + } + + /** + * @covers HashConfig::__construct + */ + public function testConstructor() { + $conf = new HashConfig(); + $this->assertInstanceOf( 'HashConfig', $conf ); + + // Test passing arguments to the constructor + $conf2 = new HashConfig( array( + 'one' => '1', + ) ); + $this->assertEquals( '1', $conf2->get( 'one' ) ); + } + + /** + * @covers HashConfig::get + */ + public function testGet() { + $conf = new HashConfig( array( + 'one' => '1', + )); + $this->assertEquals( '1', $conf->get( 'one' ) ); + $this->setExpectedException( 'ConfigException', 'HashConfig::get: undefined option' ); + $conf->get( 'two' ); + } + + /** + * @covers HashConfig::has + */ + public function testHas() { + $conf = new HashConfig( array( + 'one' => '1', + ) ); + $this->assertTrue( $conf->has( 'one' ) ); + $this->assertFalse( $conf->has( 'two' ) ); + } + + /** + * @covers HashConfig::set + */ + public function testSet() { + $conf = new HashConfig( array( + 'one' => '1', + ) ); + $conf->set( 'two', '2' ); + $this->assertEquals( '2', $conf->get( 'two' ) ); + // Check that set overwrites + $conf->set( 'one', '3' ); + $this->assertEquals( '3', $conf->get( 'one' ) ); + } +} \ No newline at end of file diff --git a/tests/phpunit/includes/config/MultiConfigTest.php b/tests/phpunit/includes/config/MultiConfigTest.php new file mode 100644 index 00000000..158da466 --- /dev/null +++ b/tests/phpunit/includes/config/MultiConfigTest.php @@ -0,0 +1,38 @@ + 'bar' ) ), + new HashConfig( array( 'foo' => 'baz', 'bar' => 'foo' ) ), + new HashConfig( array( 'bar' => 'baz' ) ), + ) ); + + $this->assertEquals( 'bar', $multi->get( 'foo' ) ); + $this->assertEquals( 'foo', $multi->get( 'bar' ) ); + $this->setExpectedException( 'ConfigException', 'MultiConfig::get: undefined option:' ); + $multi->get( 'notset' ); + } + + /** + * @covers MultiConfig::has + */ + public function testHas() { + $conf = new MultiConfig( array( + new HashConfig( array( 'foo' => 'foo' ) ), + new HashConfig( array( 'something' => 'bleh' ) ), + new HashConfig( array( 'meh' => 'eh' ) ), + ) ); + + $this->assertTrue( $conf->has( 'foo' ) ); + $this->assertTrue( $conf->has( 'something' ) ); + $this->assertTrue( $conf->has( 'meh' ) ); + $this->assertFalse( $conf->has( 'what' ) ); + } +} diff --git a/tests/phpunit/includes/content/ContentHandlerTest.php b/tests/phpunit/includes/content/ContentHandlerTest.php new file mode 100644 index 00000000..f7449734 --- /dev/null +++ b/tests/phpunit/includes/content/ContentHandlerTest.php @@ -0,0 +1,525 @@ +setMwGlobals( array( + 'wgExtraNamespaces' => array( + 12312 => 'Dummy', + 12313 => 'Dummy_talk', + ), + // The below tests assume that namespaces not mentioned here (Help, User, MediaWiki, ..) + // default to CONTENT_MODEL_WIKITEXT. + 'wgNamespaceContentModels' => array( + 12312 => 'testing', + ), + 'wgContentHandlers' => array( + CONTENT_MODEL_WIKITEXT => 'WikitextContentHandler', + CONTENT_MODEL_JAVASCRIPT => 'JavaScriptContentHandler', + CONTENT_MODEL_CSS => 'CssContentHandler', + CONTENT_MODEL_TEXT => 'TextContentHandler', + 'testing' => 'DummyContentHandlerForTesting', + ), + ) ); + + // Reset namespace cache + MWNamespace::getCanonicalNamespaces( true ); + $wgContLang->resetNamespaces(); + } + + protected function tearDown() { + global $wgContLang; + + // Reset namespace cache + MWNamespace::getCanonicalNamespaces( true ); + $wgContLang->resetNamespaces(); + + parent::tearDown(); + } + + public static function dataGetDefaultModelFor() { + return array( + array( 'Help:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetDefaultModelFor + * @covers ContentHandler::getDefaultModelFor + */ + public function testGetDefaultModelFor( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, ContentHandler::getDefaultModelFor( $title ) ); + } + + /** + * @dataProvider dataGetDefaultModelFor + * @covers ContentHandler::getForTitle + */ + public function testGetForTitle( $title, $expectedContentModel ) { + $title = Title::newFromText( $title ); + $handler = ContentHandler::getForTitle( $title ); + $this->assertEquals( $expectedContentModel, $handler->getModelID() ); + } + + public static function dataGetLocalizedName() { + return array( + array( null, null ), + array( "xyzzy", null ), + + // XXX: depends on content language + array( CONTENT_MODEL_JAVASCRIPT, '/javascript/i' ), + ); + } + + /** + * @dataProvider dataGetLocalizedName + * @covers ContentHandler::getLocalizedName + */ + public function testGetLocalizedName( $id, $expected ) { + $name = ContentHandler::getLocalizedName( $id ); + + if ( $expected ) { + $this->assertNotNull( $name, "no name found for content model $id" ); + $this->assertTrue( preg_match( $expected, $name ) > 0, + "content model name for #$id did not match pattern $expected" + ); + } else { + $this->assertEquals( $id, $name, "localization of unknown model $id should have " + . "fallen back to use the model id directly." + ); + } + } + + public static function dataGetPageLanguage() { + global $wgLanguageCode; + + return array( + array( "Main", $wgLanguageCode ), + array( "Dummy:Foo", $wgLanguageCode ), + array( "MediaWiki:common.js", 'en' ), + array( "User:Foo/common.js", 'en' ), + array( "MediaWiki:common.css", 'en' ), + array( "User:Foo/common.css", 'en' ), + array( "User:Foo", $wgLanguageCode ), + + array( CONTENT_MODEL_JAVASCRIPT, 'javascript' ), + ); + } + + /** + * @dataProvider dataGetPageLanguage + * @covers ContentHandler::getPageLanguage + */ + public function testGetPageLanguage( $title, $expected ) { + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + + $expected = wfGetLangObj( $expected ); + + $handler = ContentHandler::getForTitle( $title ); + $lang = $handler->getPageLanguage( $title ); + + $this->assertEquals( $expected->getCode(), $lang->getCode() ); + } + + public static function dataGetContentText_Null() { + return array( + array( 'fail' ), + array( 'serialize' ), + array( 'ignore' ), + ); + } + + /** + * @dataProvider dataGetContentText_Null + * @covers ContentHandler::getContentText + */ + public function testGetContentText_Null( $contentHandlerTextFallback ) { + $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback ); + + $content = null; + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( '', $text ); + } + + public static function dataGetContentText_TextContent() { + return array( + array( 'fail' ), + array( 'serialize' ), + array( 'ignore' ), + ); + } + + /** + * @dataProvider dataGetContentText_TextContent + * @covers ContentHandler::getContentText + */ + public function testGetContentText_TextContent( $contentHandlerTextFallback ) { + $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback ); + + $content = new WikitextContent( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->getNativeData(), $text ); + } + + /** + * ContentHandler::getContentText should have thrown an exception for non-text Content object + * @expectedException MWException + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_fail() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'fail' ); + + $content = new DummyContentForTesting( "hello world" ); + + ContentHandler::getContentText( $content ); + } + + /** + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_serialize() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'serialize' ); + + $content = new DummyContentForTesting( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->serialize(), $text ); + } + + /** + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_ignore() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'ignore' ); + + $content = new DummyContentForTesting( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertNull( $text ); + } + + /* + public static function makeContent( $text, Title $title, $modelId = null, $format = null ) {} + */ + + public static function dataMakeContent() { + return array( + array( 'hallo', 'Help:Test', null, null, CONTENT_MODEL_WIKITEXT, 'hallo', false ), + array( 'hallo', 'MediaWiki:Test.js', null, null, CONTENT_MODEL_JAVASCRIPT, 'hallo', false ), + array( serialize( 'hallo' ), 'Dummy:Test', null, null, "testing", 'hallo', false ), + + array( + 'hallo', + 'Help:Test', + null, + CONTENT_FORMAT_WIKITEXT, + CONTENT_MODEL_WIKITEXT, + 'hallo', + false + ), + array( + 'hallo', + 'MediaWiki:Test.js', + null, + CONTENT_FORMAT_JAVASCRIPT, + CONTENT_MODEL_JAVASCRIPT, + 'hallo', + false + ), + array( serialize( 'hallo' ), 'Dummy:Test', null, "testing", "testing", 'hallo', false ), + + array( 'hallo', 'Help:Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, 'hallo', false ), + array( + 'hallo', + 'MediaWiki:Test.js', + CONTENT_MODEL_CSS, + null, + CONTENT_MODEL_CSS, + 'hallo', + false + ), + array( + serialize( 'hallo' ), + 'Dummy:Test', + CONTENT_MODEL_CSS, + null, + CONTENT_MODEL_CSS, + serialize( 'hallo' ), + false + ), + + array( 'hallo', 'Help:Test', CONTENT_MODEL_WIKITEXT, "testing", null, null, true ), + array( 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, "testing", null, null, true ), + array( 'hallo', 'Dummy:Test', CONTENT_MODEL_JAVASCRIPT, "testing", null, null, true ), + ); + } + + /** + * @dataProvider dataMakeContent + * @covers ContentHandler::makeContent + */ + public function testMakeContent( $data, $title, $modelId, $format, + $expectedModelId, $expectedNativeData, $shouldFail + ) { + $title = Title::newFromText( $title ); + + try { + $content = ContentHandler::makeContent( $data, $title, $modelId, $format ); + + if ( $shouldFail ) { + $this->fail( "ContentHandler::makeContent should have failed!" ); + } + + $this->assertEquals( $expectedModelId, $content->getModel(), 'bad model id' ); + $this->assertEquals( $expectedNativeData, $content->getNativeData(), 'bads native data' ); + } catch ( MWException $ex ) { + if ( !$shouldFail ) { + $this->fail( "ContentHandler::makeContent failed unexpectedly: " . $ex->getMessage() ); + } else { + // dummy, so we don't get the "test did not perform any assertions" message. + $this->assertTrue( true ); + } + } + } + + /* + * Test if we become a "Created blank page" summary from getAutoSummary if no Content added to + * page. + */ + public function testGetAutosummary() { + $content = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT ); + $title = Title::newFromText( 'Help:Test' ); + // Create a new content object with no content + $newContent = ContentHandler::makeContent( '', $title, null, null, CONTENT_MODEL_WIKITEXT ); + // first check, if we become a blank page created summary with the right bitmask + $autoSummary = $content->getAutosummary( null, $newContent, 97 ); + $this->assertEquals( $autoSummary, 'Created blank page' ); + // now check, what we become with another bitmask + $autoSummary = $content->getAutosummary( null, $newContent, 92 ); + $this->assertEquals( $autoSummary, '' ); + } + + /* + public function testSupportsSections() { + $this->markTestIncomplete( "not yet implemented" ); + } + */ + + /** + * @covers ContentHandler::runLegacyHooks + */ + public function testRunLegacyHooks() { + Hooks::register( 'testRunLegacyHooks', __CLASS__ . '::dummyHookHandler' ); + + $content = new WikitextContent( 'test text' ); + $ok = ContentHandler::runLegacyHooks( + 'testRunLegacyHooks', + array( 'foo', &$content, 'bar' ), + false + ); + + $this->assertTrue( $ok, "runLegacyHooks should have returned true" ); + $this->assertEquals( "TEST TEXT", $content->getNativeData() ); + } + + public static function dummyHookHandler( $foo, &$text, $bar ) { + if ( $text === null || $text === false ) { + return false; + } + + $text = strtoupper( $text ); + + return true; + } +} + +class DummyContentHandlerForTesting extends ContentHandler { + + public function __construct( $dataModel ) { + parent::__construct( $dataModel, array( "testing" ) ); + } + + /** + * @see ContentHandler::serializeContent + * + * @param Content $content + * @param string $format + * + * @return string + */ + public function serializeContent( Content $content, $format = null ) { + return $content->serialize(); + } + + /** + * @see ContentHandler::unserializeContent + * + * @param string $blob + * @param string $format Unused. + * + * @return Content + */ + public function unserializeContent( $blob, $format = null ) { + $d = unserialize( $blob ); + + return new DummyContentForTesting( $d ); + } + + /** + * Creates an empty Content object of the type supported by this ContentHandler. + * + */ + public function makeEmptyContent() { + return new DummyContentForTesting( '' ); + } +} + +class DummyContentForTesting extends AbstractContent { + + public function __construct( $data ) { + parent::__construct( "testing" ); + + $this->data = $data; + } + + public function serialize( $format = null ) { + return serialize( $this->data ); + } + + /** + * @return string A string representing the content in a way useful for + * building a full text search index. If no useful representation exists, + * this method returns an empty string. + */ + public function getTextForSearchIndex() { + return ''; + } + + /** + * @return string|bool The wikitext to include when another page includes this content, + * or false if the content is not includable in a wikitext page. + */ + public function getWikitextForTransclusion() { + return false; + } + + /** + * Returns a textual representation of the content suitable for use in edit + * summaries and log messages. + * + * @param int $maxlength Maximum length of the summary text. + * @return string The summary text. + */ + public function getTextForSummary( $maxlength = 250 ) { + return ''; + } + + /** + * Returns native represenation of the data. Interpretation depends on the data model used, + * as given by getDataModel(). + * + * @return mixed The native representation of the content. Could be a string, a nested array + * structure, an object, a binary blob... anything, really. + */ + public function getNativeData() { + return $this->data; + } + + /** + * returns the content's nominal size in bogo-bytes. + * + * @return int + */ + public function getSize() { + return strlen( $this->data ); + } + + /** + * Return a copy of this Content object. The following must be true for the object returned + * if $copy = $original->copy() + * + * * get_class($original) === get_class($copy) + * * $original->getModel() === $copy->getModel() + * * $original->equals( $copy ) + * + * If and only if the Content object is imutable, the copy() method can and should + * return $this. That is, $copy === $original may be true, but only for imutable content + * objects. + * + * @return Content A copy of this object + */ + public function copy() { + return $this; + } + + /** + * Returns true if this content is countable as a "real" wiki page, provided + * that it's also in a countable location (e.g. a current revision in the main namespace). + * + * @param bool $hasLinks If it is known whether this content contains links, + * provide this information here, to avoid redundant parsing to find out. + * @return bool + */ + public function isCountable( $hasLinks = null ) { + return false; + } + + /** + * @param Title $title + * @param int $revId Unused. + * @param null|ParserOptions $options + * @param bool $generateHtml Whether to generate Html (default: true). If false, the result + * of calling getText() on the ParserOutput object returned by this method is undefined. + * + * @return ParserOutput + */ + public function getParserOutput( Title $title, $revId = null, + ParserOptions $options = null, $generateHtml = true + ) { + return new ParserOutput( $this->getNativeData() ); + } + + /** + * @see AbstractContent::fillParserOutput() + * + * @param Title $title Context title for parsing + * @param int|null $revId Revision ID (for {{REVISIONID}}) + * @param ParserOptions $options Parser options + * @param bool $generateHtml Whether or not to generate HTML + * @param ParserOutput &$output The output object to fill (reference). + */ + protected function fillParserOutput( Title $title, $revId, + ParserOptions $options, $generateHtml, ParserOutput &$output ) { + $output = new ParserOutput( $this->getNativeData() ); + } +} diff --git a/tests/phpunit/includes/content/CssContentTest.php b/tests/phpunit/includes/content/CssContentTest.php new file mode 100644 index 00000000..40484d3a --- /dev/null +++ b/tests/phpunit/includes/content/CssContentTest.php @@ -0,0 +1,87 @@ +setName( '127.0.0.1' ); + + $this->setMwGlobals( array( + 'wgUser' => $user, + 'wgTextModelsToParse' => array( + CONTENT_MODEL_CSS, + ) + ) ); + } + + public function newContent( $text ) { + return new CssContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + 'MediaWiki:Test.css', + null, + "hello \n", + "
    \nhello <world>\n\n
    " + ), + array( + 'MediaWiki:Test.css', + null, + "/* hello [[world]] */\n", + "
    \n/* hello [[world]] */\n\n
    ", + array( + 'Links' => array( + array( 'World' => 0 ) + ) + ) + ), + + // TODO: more...? + ); + } + + /** + * @covers CssContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getModel() ); + } + + /** + * @covers CssContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getContentHandler()->getModelID() ); + } + + public static function dataEquals() { + return array( + array( new CssContent( 'hallo' ), null, false ), + array( new CssContent( 'hallo' ), new CssContent( 'hallo' ), true ), + array( new CssContent( 'hallo' ), new WikitextContent( 'hallo' ), false ), + array( new CssContent( 'hallo' ), new CssContent( 'HALLO' ), false ), + ); + } + + /** + * @dataProvider dataEquals + * @covers CssContent::equals + */ + public function testEquals( Content $a, Content $b = null, $equal = false ) { + $this->assertEquals( $equal, $a->equals( $b ) ); + } +} diff --git a/tests/phpunit/includes/content/JavaScriptContentTest.php b/tests/phpunit/includes/content/JavaScriptContentTest.php new file mode 100644 index 00000000..7193ec9f --- /dev/null +++ b/tests/phpunit/includes/content/JavaScriptContentTest.php @@ -0,0 +1,293 @@ +\n", + "
    \nhello <world>\n\n
    " + ), + array( + 'MediaWiki:Test.js', + null, + "hello(); // [[world]]\n", + "
    \nhello(); // [[world]]\n\n
    ", + array( + 'Links' => array( + array( 'World' => 0 ) + ) + ) + ), + + // TODO: more...? + ); + } + + // XXX: Unused function + public static function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + '0', + null + ), + array( WikitextContentTest::$sections, + '2', + null + ), + array( WikitextContentTest::$sections, + '8', + null + ), + ); + } + + // XXX: Unused function + public static function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + '0', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + '', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + '2', + "== TEST ==\nmore fun", + null, + null + ), + array( WikitextContentTest::$sections, + '8', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + 'new', + 'No more', + 'New', + null + ), + ); + } + + /** + * @covers JavaScriptContent::addSectionHeader + */ + public function testAddSectionHeader() { + $content = $this->newContent( 'hello world' ); + $c = $content->addSectionHeader( 'test' ); + + $this->assertTrue( $content->equals( $c ) ); + } + + // XXX: currently, preSaveTransform is applied to scripts. this may change or become optional. + public static function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is ~~~', + 'hello \'\'this\'\' is ~~~', + ), + array( " Foo \n ", + " Foo", + ), + ); + } + + public static function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + 'hello this is ~~~', + ), + array( 'hello \'\'this\'\' is foobar', + 'hello \'\'this\'\' is foobar', + ), + ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + null, + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @todo Test needs database! + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo Test needs database! + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + false + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + false + ), + array( 'Foo', + true, + 'link', + false + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + true + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + '[[hel...', + ), + ); + } + + /** + * @covers JavaScriptContent::matchMagicWord + */ + public function testMatchMagicWord() { + $mw = MagicWord::get( "staticredirect" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" ); + $this->assertFalse( + $content->matchMagicWord( $mw ), + "should not have matched magic word, since it's not wikitext" + ); + } + + /** + * @covers JavaScriptContent::updateRedirect + */ + public function testUpdateRedirect() { + $target = Title::newFromText( "testUpdateRedirect_target" ); + + $content = $this->newContent( "#REDIRECT [[Someplace]]" ); + $newContent = $content->updateRedirect( $target ); + + $this->assertTrue( + $content->equals( $newContent ), + "content should be unchanged since it's not wikitext" + ); + } + + /** + * @covers JavaScriptContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getModel() ); + } + + /** + * @covers JavaScriptContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getContentHandler()->getModelID() ); + } + + public static function dataEquals() { + return array( + array( new JavaScriptContent( "hallo" ), null, false ), + array( new JavaScriptContent( "hallo" ), new JavaScriptContent( "hallo" ), true ), + array( new JavaScriptContent( "hallo" ), new CssContent( "hallo" ), false ), + array( new JavaScriptContent( "hallo" ), new JavaScriptContent( "HALLO" ), false ), + ); + } +} diff --git a/tests/phpunit/includes/content/JsonContentTest.php b/tests/phpunit/includes/content/JsonContentTest.php new file mode 100644 index 00000000..77b542f4 --- /dev/null +++ b/tests/phpunit/includes/content/JsonContentTest.php @@ -0,0 +1,114 @@ +assertEquals( $isValid, $obj->isValid() ); + $this->assertEquals( $expected, $obj->getJsonData() ); + } + + public static function provideValidConstruction() { + return array( + array( 'foo', CONTENT_MODEL_JSON, false, null ), + array( FormatJson::encode( array() ), CONTENT_MODEL_JSON, true, array() ), + array( FormatJson::encode( array( 'foo' ) ), CONTENT_MODEL_JSON, true, array( 'foo' ) ), + ); + } + + /** + * @dataProvider provideDataToEncode + */ + public function testBeautifyUsesFormatJson( $data ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $this->assertEquals( FormatJson::encode( $data, true ), $obj->beautifyJSON() ); + } + + public static function provideDataToEncode() { + return array( + array( array() ), + array( array( 'foo' ) ), + array( array( 'foo', 'bar' ) ), + array( array( 'baz' => 'foo', 'bar' ) ), + array( array( 'baz' => 1000, 'bar' ) ), + ); + } + + /** + * @dataProvider provideDataToEncode + */ + public function testPreSaveTransform( $data ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $newObj = $obj->preSaveTransform( $this->getMockTitle(), $this->getMockUser(), $this->getMockParserOptions() ); + $this->assertTrue( $newObj->equals( new JsonContent( FormatJson::encode( $data, true ) ) ) ); + } + + private function getMockTitle() { + return $this->getMockBuilder( 'Title' ) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getMockUser() { + return $this->getMockBuilder( 'User' ) + ->disableOriginalConstructor() + ->getMock(); + } + private function getMockParserOptions() { + return $this->getMockBuilder( 'ParserOptions' ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @dataProvider provideDataAndParserText + */ + public function testFillParserOutput( $data, $expected ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $parserOutput = $obj->getParserOutput( $this->getMockTitle(), null, null, true ); + $this->assertInstanceOf( 'ParserOutput', $parserOutput ); + $this->assertEquals( $expected, $parserOutput->getText() ); + } + + public static function provideDataAndParserText() { + return array( + array( + array(), + '
    ' + ), + array( + array( 'foo' ), + '
    0"foo"
    ' + ), + array( + array( 'foo', 'bar' ), + '' . + "\n" . + '
    0"foo"
    1"bar"
    ' + ), + array( + array( 'baz' => 'foo', 'bar' ), + '' . + "\n" . + '
    baz"foo"
    0"bar"
    ' + ), + array( + array( 'baz' => 1000, 'bar' ), + '' . + "\n" . + '
    baz1000
    0"bar"
    ' + ), + array( + array( ''), + '
    0"<script>alert("evil!")</script>"
    ', + ), + ); + } +} diff --git a/tests/phpunit/includes/content/TextContentTest.php b/tests/phpunit/includes/content/TextContentTest.php new file mode 100644 index 00000000..2f811094 --- /dev/null +++ b/tests/phpunit/includes/content/TextContentTest.php @@ -0,0 +1,490 @@ +setName( '127.0.0.1' ); + + $this->context = new RequestContext( new FauxRequest() ); + $this->context->setTitle( Title::newFromText( 'Test' ) ); + $this->context->setUser( $user ); + + $this->setMwGlobals( array( + 'wgUser' => $user, + 'wgTextModelsToParse' => array( + CONTENT_MODEL_WIKITEXT, + CONTENT_MODEL_CSS, + CONTENT_MODEL_JAVASCRIPT, + ), + 'wgUseTidy' => false, + 'wgAlwaysUseTidy' => false, + 'wgCapitalLinks' => true, + ) ); + + // bypass hooks that force custom rendering + if ( isset( $wgHooks['ContentGetParserOutput'] ) ) { + $this->savedContentGetParserOutput = $wgHooks['ContentGetParserOutput']; + unset( $wgHooks['ContentGetParserOutput'] ); + } + } + + public function teardown() { + global $wgHooks; + + // restore hooks that force custom rendering + if ( $this->savedContentGetParserOutput !== null ) { + $wgHooks['ContentGetParserOutput'] = $this->savedContentGetParserOutput; + } + + parent::teardown(); + } + + public function newContent( $text ) { + return new TextContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + 'TextContentTest_testGetParserOutput', + CONTENT_MODEL_TEXT, + "hello ''world'' & [[stuff]]\n", "hello ''world'' & [[stuff]]", + array( + 'Links' => array() + ) + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetParserOutput + * @covers TextContent::getParserOutput + */ + public function testGetParserOutput( $title, $model, $text, $expectedHtml, + $expectedFields = null + ) { + $title = Title::newFromText( $title ); + $content = ContentHandler::makeContent( $text, $title, $model ); + + $po = $content->getParserOutput( $title ); + + $html = $po->getText(); + $html = preg_replace( '##sm', '', $html ); // strip comments + + $this->assertEquals( $expectedHtml, trim( $html ) ); + + if ( $expectedFields ) { + foreach ( $expectedFields as $field => $exp ) { + $f = 'get' . ucfirst( $field ); + $v = call_user_func( array( $po, $f ) ); + + if ( is_array( $exp ) ) { + $this->assertArrayEquals( $exp, $v ); + } else { + $this->assertEquals( $exp, $v ); + } + } + } + + // TODO: assert more properties + } + + public static function dataPreSaveTransform() { + return array( + array( + #0: no signature resolution + 'hello this is ~~~', + 'hello this is ~~~', + ), + array( + #1: rtrim + " Foo \n ", + ' Foo', + ), + ); + } + + /** + * @dataProvider dataPreSaveTransform + * @covers TextContent::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + global $wgContLang; + + $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preSaveTransform( + $this->context->getTitle(), + $this->context->getUser(), + $options + ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public static function dataPreloadTransform() { + return array( + array( + 'hello this is ~~~', + 'hello this is ~~~', + ), + ); + } + + /** + * @dataProvider dataPreloadTransform + * @covers TextContent::preloadTransform + */ + public function testPreloadTransform( $text, $expected ) { + global $wgContLang; + $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preloadTransform( $this->context->getTitle(), $options ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @dataProvider dataGetRedirectTarget + * @covers TextContent::getRedirectTarget + */ + public function testGetRedirectTarget( $text, $expected ) { + $content = $this->newContent( $text ); + $t = $content->getRedirectTarget(); + + if ( is_null( $expected ) ) { + $this->assertNull( $t, "text should not have generated a redirect target: $text" ); + } else { + $this->assertEquals( $expected, $t->getPrefixedText() ); + } + } + + /** + * @dataProvider dataGetRedirectTarget + * @covers TextContent::isRedirect + */ + public function testIsRedirect( $text, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( !is_null( $expected ), $content->isRedirect() ); + } + + /** + * @todo Test needs database! Should be done by a test class in the Database group. + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo Test needs database! Should be done by a test class in the Database group. + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + false + ), + ); + } + + /** + * @dataProvider dataIsCountable + * @group Database + * @covers TextContent::isCountable + */ + public function testIsCountable( $text, $hasLinks, $mode, $expected ) { + $this->setMwGlobals( 'wgArticleCountMethod', $mode ); + + $content = $this->newContent( $text ); + + $v = $content->isCountable( $hasLinks, $this->context->getTitle() ); + + $this->assertEquals( + $expected, + $v, + 'isCountable() returned unexpected value ' . var_export( $v, true ) + . ' instead of ' . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + '[[hel...', + ), + ); + } + + /** + * @dataProvider dataGetTextForSummary + * @covers TextContent::getTextForSummary + */ + public function testGetTextForSummary( $text, $maxlength, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $expected, $content->getTextForSummary( $maxlength ) ); + } + + /** + * @covers TextContent::getTextForSearchIndex + */ + public function testGetTextForSearchIndex() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getTextForSearchIndex() ); + } + + /** + * @covers TextContent::copy + */ + public function testCopy() { + $content = $this->newContent( 'hello world.' ); + $copy = $content->copy(); + + $this->assertTrue( $content->equals( $copy ), 'copy must be equal to original' ); + $this->assertEquals( 'hello world.', $copy->getNativeData() ); + } + + /** + * @covers TextContent::getSize + */ + public function testGetSize() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 12, $content->getSize() ); + } + + /** + * @covers TextContent::getNativeData + */ + public function testGetNativeData() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getNativeData() ); + } + + /** + * @covers TextContent::getWikitextForTransclusion + */ + public function testGetWikitextForTransclusion() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getWikitextForTransclusion() ); + } + + /** + * @covers TextContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_TEXT, $content->getModel() ); + } + + /** + * @covers TextContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_TEXT, $content->getContentHandler()->getModelID() ); + } + + public static function dataIsEmpty() { + return array( + array( '', true ), + array( ' ', false ), + array( '0', false ), + array( 'hallo welt.', false ), + ); + } + + /** + * @dataProvider dataIsEmpty + * @covers TextContent::isEmpty + */ + public function testIsEmpty( $text, $empty ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $empty, $content->isEmpty() ); + } + + public static function dataEquals() { + return array( + array( new TextContent( "hallo" ), null, false ), + array( new TextContent( "hallo" ), new TextContent( "hallo" ), true ), + array( new TextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ), + array( new TextContent( "hallo" ), new WikitextContent( "hallo" ), false ), + array( new TextContent( "hallo" ), new TextContent( "HALLO" ), false ), + ); + } + + /** + * @dataProvider dataEquals + * @covers TextContent::equals + */ + public function testEquals( Content $a, Content $b = null, $equal = false ) { + $this->assertEquals( $equal, $a->equals( $b ) ); + } + + public static function dataGetDeletionUpdates() { + return array( + array( "TextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_TEXT, "hello ''world''\n", + array() + ), + array( "TextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_TEXT, "hello [[world test 21344]]\n", + array() + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetDeletionUpdates + * @covers TextContent::getDeletionUpdates + */ + public function testDeletionUpdates( $title, $model, $text, $expectedStuff ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $page->doEditContent( $content, '' ); + + $updates = $content->getDeletionUpdates( $page ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[$class] = $update; + } + + if ( !$expectedStuff ) { + $this->assertTrue( true ); // make phpunit happy + return; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[$class]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + + $page->doDeleteArticle( '' ); + } + + public static function provideConvert() { + return array( + array( // #0 + 'Hallo Welt', + CONTENT_MODEL_WIKITEXT, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_WIKITEXT, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_CSS, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_JAVASCRIPT, + 'lossless', + 'Hallo Welt' + ), + ); + } + + /** + * @dataProvider provideConvert + * @covers TextContent::convert + */ + public function testConvert( $text, $model, $lossy, $expectedNative ) { + $content = $this->newContent( $text ); + + $converted = $content->convert( $model, $lossy ); + + if ( $expectedNative === false ) { + $this->assertFalse( $converted, "conversion to $model was expected to fail!" ); + } else { + $this->assertInstanceOf( 'Content', $converted ); + $this->assertEquals( $expectedNative, $converted->getNativeData() ); + } + } +} diff --git a/tests/phpunit/includes/content/WikitextContentHandlerTest.php b/tests/phpunit/includes/content/WikitextContentHandlerTest.php new file mode 100644 index 00000000..38fb5733 --- /dev/null +++ b/tests/phpunit/includes/content/WikitextContentHandlerTest.php @@ -0,0 +1,241 @@ +handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + } + + /** + * @covers WikitextContentHandler::serializeContent + */ + public function testSerializeContent() { + $content = new WikitextContent( 'hello world' ); + + $this->assertEquals( 'hello world', $this->handler->serializeContent( $content ) ); + $this->assertEquals( + 'hello world', + $this->handler->serializeContent( $content, CONTENT_FORMAT_WIKITEXT ) + ); + + try { + $this->handler->serializeContent( $content, 'dummy/foo' ); + $this->fail( "serializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + /** + * @covers WikitextContentHandler::unserializeContent + */ + public function testUnserializeContent() { + $content = $this->handler->unserializeContent( 'hello world' ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + $content = $this->handler->unserializeContent( 'hello world', CONTENT_FORMAT_WIKITEXT ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + try { + $this->handler->unserializeContent( 'hello world', 'dummy/foo' ); + $this->fail( "unserializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + /** + * @covers WikitextContentHandler::makeEmptyContent + */ + public function testMakeEmptyContent() { + $content = $this->handler->makeEmptyContent(); + + $this->assertTrue( $content->isEmpty() ); + $this->assertEquals( '', $content->getNativeData() ); + } + + public static function dataIsSupportedFormat() { + return array( + array( null, true ), + array( CONTENT_FORMAT_WIKITEXT, true ), + array( 99887766, false ), + ); + } + + /** + * @dataProvider provideMakeRedirectContent + * @param Title|string $title Title object or string for Title::newFromText() + * @param string $expected Serialized form of the content object built + * @covers WikitextContentHandler::makeRedirectContent + */ + public function testMakeRedirectContent( $title, $expected ) { + global $wgContLang; + $wgContLang->resetNamespaces(); + + MagicWord::clearCache(); + + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + $content = $this->handler->makeRedirectContent( $title ); + $this->assertEquals( $expected, $content->serialize() ); + } + + public static function provideMakeRedirectContent() { + return array( + array( 'Hello', '#REDIRECT [[Hello]]' ), + array( 'Template:Hello', '#REDIRECT [[Template:Hello]]' ), + array( 'Hello#section', '#REDIRECT [[Hello#section]]' ), + array( 'user:john_doe#section', '#REDIRECT [[User:John doe#section]]' ), + array( 'MEDIAWIKI:FOOBAR', '#REDIRECT [[MediaWiki:FOOBAR]]' ), + array( 'Category:Foo', '#REDIRECT [[:Category:Foo]]' ), + array( Title::makeTitle( NS_MAIN, 'en:Foo' ), '#REDIRECT [[en:Foo]]' ), + array( Title::makeTitle( NS_MAIN, 'Foo', '', 'en' ), '#REDIRECT [[:en:Foo]]' ), + array( + Title::makeTitle( NS_MAIN, 'Bar', 'fragment', 'google' ), + '#REDIRECT [[google:Bar#fragment]]' + ), + ); + } + + /** + * @dataProvider dataIsSupportedFormat + * @covers WikitextContentHandler::isSupportedFormat + */ + public function testIsSupportedFormat( $format, $supported ) { + $this->assertEquals( $supported, $this->handler->isSupportedFormat( $format ) ); + } + + public static function dataMerge3() { + return array( + array( + "first paragraph + + second paragraph\n", + + "FIRST paragraph + + second paragraph\n", + + "first paragraph + + SECOND paragraph\n", + + "FIRST paragraph + + SECOND paragraph\n", + ), + + array( "first paragraph + second paragraph\n", + + "Bla bla\n", + + "Blubberdibla\n", + + false, + ), + ); + } + + /** + * @dataProvider dataMerge3 + * @covers WikitextContentHandler::merge3 + */ + public function testMerge3( $old, $mine, $yours, $expected ) { + $this->checkHasDiff3(); + + // test merge + $oldContent = new WikitextContent( $old ); + $myContent = new WikitextContent( $mine ); + $yourContent = new WikitextContent( $yours ); + + $merged = $this->handler->merge3( $oldContent, $myContent, $yourContent ); + + $this->assertEquals( $expected, $merged ? $merged->getNativeData() : $merged ); + } + + public static function dataGetAutosummary() { + return array( + array( + 'Hello there, world!', + '#REDIRECT [[Foo]]', + 0, + '/^Redirected page .*Foo/' + ), + + array( + null, + 'Hello world!', + EDIT_NEW, + '/^Created page .*Hello/' + ), + + array( + 'Hello there, world!', + '', + 0, + '/^Blanked/' + ), + + array( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Hello world!', + 0, + '/^Replaced .*Hello/' + ), + + array( + 'foo', + 'bar', + 0, + '/^$/' + ), + ); + } + + /** + * @dataProvider dataGetAutosummary + * @covers WikitextContentHandler::getAutosummary + */ + public function testGetAutosummary( $old, $new, $flags, $expected ) { + $oldContent = is_null( $old ) ? null : new WikitextContent( $old ); + $newContent = is_null( $new ) ? null : new WikitextContent( $new ); + + $summary = $this->handler->getAutosummary( $oldContent, $newContent, $flags ); + + $this->assertTrue( + (bool)preg_match( $expected, $summary ), + "Autosummary didn't match expected pattern $expected: $summary" + ); + } + + /** + * @todo Text case requires database, should be done by a test class in the Database group + */ + /* + public function testGetAutoDeleteReason( Title $title, &$hasHistory ) {} + */ + + /** + * @todo Text case requires database, should be done by a test class in the Database group + */ + /* + public function testGetUndoContent( Revision $current, Revision $undo, + Revision $undoafter = null + ) { + } + */ +} diff --git a/tests/phpunit/includes/content/WikitextContentTest.php b/tests/phpunit/includes/content/WikitextContentTest.php new file mode 100644 index 00000000..7becd6f4 --- /dev/null +++ b/tests/phpunit/includes/content/WikitextContentTest.php @@ -0,0 +1,433 @@ +hello world\n

    " + ), + // TODO: more...? + ); + } + + public static function dataGetSecondaryDataUpdates() { + return array( + array( "WikitextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_WIKITEXT, "hello ''world''\n", + array( + 'LinksUpdate' => array( + 'mRecursive' => true, + 'mLinks' => array() + ) + ) + ), + array( "WikitextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n", + array( + 'LinksUpdate' => array( + 'mRecursive' => true, + 'mLinks' => array( + array( 'World_test_21344' => 0 ) + ) + ) + ) + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetSecondaryDataUpdates + * @group Database + * @covers WikitextContent::getSecondaryDataUpdates + */ + public function testGetSecondaryDataUpdates( $title, $model, $text, $expectedStuff ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $page->doEditContent( $content, '' ); + + $updates = $content->getSecondaryDataUpdates( $title ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[$class] = $update; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[$class]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + + $page->doDeleteArticle( '' ); + } + + public static function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "Intro" + ), + array( WikitextContentTest::$sections, + "2", + "== test == +just a test" + ), + array( WikitextContentTest::$sections, + "8", + false + ), + ); + } + + /** + * @dataProvider dataGetSection + * @covers WikitextContent::getSection + */ + public function testGetSection( $text, $sectionId, $expectedText ) { + $content = $this->newContent( $text ); + + $sectionContent = $content->getSection( $sectionId ); + if ( is_object( $sectionContent ) ) { + $sectionText = $sectionContent->getNativeData(); + } else { + $sectionText = $sectionContent; + } + + $this->assertEquals( $expectedText, $sectionText ); + } + + public static function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "No more", + null, + trim( preg_replace( '/^Intro/sm', 'No more', WikitextContentTest::$sections ) ) + ), + array( WikitextContentTest::$sections, + "", + "No more", + null, + "No more" + ), + array( WikitextContentTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + trim( preg_replace( + '/^== test ==.*== foo ==/sm', "== TEST ==\nmore fun\n\n== foo ==", + WikitextContentTest::$sections + ) ) + ), + array( WikitextContentTest::$sections, + "8", + "No more", + null, + WikitextContentTest::$sections + ), + array( WikitextContentTest::$sections, + "new", + "No more", + "New", + trim( WikitextContentTest::$sections ) . "\n\n\n== New ==\n\nNo more" + ), + ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikitextContent::replaceSection + */ + public function testReplaceSection( $text, $section, $with, $sectionTitle, $expected ) { + $content = $this->newContent( $text ); + $c = $content->replaceSection( $section, $this->newContent( $with ), $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : $c->getNativeData() ); + } + + /** + * @covers WikitextContent::addSectionHeader + */ + public function testAddSectionHeader() { + $content = $this->newContent( 'hello world' ); + $content = $content->addSectionHeader( 'test' ); + + $this->assertEquals( "== test ==\n\nhello world", $content->getNativeData() ); + } + + public static function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is ~~~', + 'hello \'\'this\'\' is ~~~', + ), + array( // rtrim + " Foo \n ", + " Foo", + ), + ); + } + + public static function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + "hello this is ~~~", + ), + array( 'hello \'\'this\'\' is foobar', + 'hello \'\'this\'\' is bar', + ), + ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + 'Test', + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + 'hel...', + ), + ); + } + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + true + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + true + ), + array( 'Foo', + true, + 'link', + true + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + /** + * @covers WikitextContent::matchMagicWord + */ + public function testMatchMagicWord() { + $mw = MagicWord::get( "staticredirect" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" ); + $this->assertTrue( $content->matchMagicWord( $mw ), "should have matched magic word" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]" ); + $this->assertFalse( $content->matchMagicWord( $mw ), "should not have matched magic word" ); + } + + /** + * @covers WikitextContent::updateRedirect + */ + public function testUpdateRedirect() { + $target = Title::newFromText( "testUpdateRedirect_target" ); + + // test with non-redirect page + $content = $this->newContent( "hello world." ); + $newContent = $content->updateRedirect( $target ); + + $this->assertTrue( $content->equals( $newContent ), "content should be unchanged" ); + + // test with actual redirect + $content = $this->newContent( "#REDIRECT [[Someplace]]" ); + $newContent = $content->updateRedirect( $target ); + + $this->assertFalse( $content->equals( $newContent ), "content should have changed" ); + $this->assertTrue( $newContent->isRedirect(), "new content should be a redirect" ); + + $this->assertEquals( $target->getFullText(), $newContent->getRedirectTarget()->getFullText() ); + } + + /** + * @covers WikitextContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getModel() ); + } + + /** + * @covers WikitextContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getContentHandler()->getModelID() ); + } + + public function testRedirectParserOption() { + $title = Title::newFromText( 'testRedirectParserOption' ); + + // Set up hook and its reporting variables + $wikitext = null; + $redirectTarget = null; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'InternalParseBeforeLinks' => array( + function ( &$parser, &$text, &$stripState ) use ( &$wikitext, &$redirectTarget ) { + $wikitext = $text; + $redirectTarget = $parser->getOptions()->getRedirectTarget(); + } + ) + ) ); + + // Test with non-redirect page + $wikitext = false; + $redirectTarget = false; + $content = $this->newContent( 'hello world.' ); + $options = $content->getContentHandler()->makeParserOptions( 'canonical' ); + $options->setRedirectTarget( $title ); + $content->getParserOutput( $title, null, $options ); + $this->assertEquals( 'hello world.', $wikitext, + 'Wikitext passed to hook was not as expected' + ); + $this->assertEquals( null, $redirectTarget, 'Redirect seen in hook was not null' ); + $this->assertEquals( $title, $options->getRedirectTarget(), + 'ParserOptions\' redirectTarget was changed' + ); + + // Test with a redirect page + $wikitext = false; + $redirectTarget = false; + $content = $this->newContent( "#REDIRECT [[TestRedirectParserOption/redir]]\nhello redirect." ); + $options = $content->getContentHandler()->makeParserOptions( 'canonical' ); + $content->getParserOutput( $title, null, $options ); + $this->assertEquals( 'hello redirect.', $wikitext, 'Wikitext passed to hook was not as expected' ); + $this->assertNotEquals( null, $redirectTarget, 'Redirect seen in hook was null' ); + $this->assertEquals( 'TestRedirectParserOption/redir', $redirectTarget->getFullText(), + 'Redirect seen in hook was not the expected title' + ); + $this->assertEquals( null, $options->getRedirectTarget(), + 'ParserOptions\' redirectTarget was changed' + ); + } + + public static function dataEquals() { + return array( + array( new WikitextContent( "hallo" ), null, false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "hallo" ), true ), + array( new WikitextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ), + array( new WikitextContent( "hallo" ), new TextContent( "hallo" ), false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "HALLO" ), false ), + ); + } + + public static function dataGetDeletionUpdates() { + return array( + array( "WikitextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_WIKITEXT, "hello ''world''\n", + array( 'LinksDeletionUpdate' => array() ) + ), + array( "WikitextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n", + array( 'LinksDeletionUpdate' => array() ) + ), + // @todo more...? + ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php new file mode 100644 index 00000000..55e48d13 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php @@ -0,0 +1,247 @@ +addIdentifierQuotes( $in ); + $this->assertEquals( $expected, $quoted ); + } + + /** + * Feeds testAddIdentifierQuotes + * + * Named per bug 20281 convention. + */ + function provideDiapers() { + return array( + // Format: expected, input + array( '``', '' ), + + // Yeah I really hate loosely typed PHP idiocies nowadays + array( '``', null ), + + // Dear codereviewer, guess what addIdentifierQuotes() + // will return with thoses: + array( '``', false ), + array( '`1`', true ), + + // We never know what could happen + array( '`0`', 0 ), + array( '`1`', 1 ), + + // Whatchout! Should probably use something more meaningful + array( "`'`", "'" ), # single quote + array( '`"`', '"' ), # double quote + array( '````', '`' ), # backtick + array( '`’`', '’' ), # apostrophe (look at your encyclopedia) + + // sneaky NUL bytes are lurking everywhere + array( '``', "\0" ), + array( '`xyzzy`', "\0x\0y\0z\0z\0y\0" ), + + // unicode chars + array( + self::createUnicodeString( '`\u0001a\uFFFFb`' ), + self::createUnicodeString( '\u0001a\uFFFFb' ) + ), + array( + self::createUnicodeString( '`\u0001\uFFFF`' ), + self::createUnicodeString( '\u0001\u0000\uFFFF\u0000' ) + ), + array( '`☃`', '☃' ), + array( '`メインページ`', 'メインページ' ), + array( '`Басты_бет`', 'Басты_бет' ), + + // Real world: + array( '`Alix`', 'Alix' ), # while( ! $recovered ) { sleep(); } + array( '`Backtick: ```', 'Backtick: `' ), + array( '`This is a test`', 'This is a test' ), + ); + } + + private static function createUnicodeString( $str ) { + return json_decode( '"' . $str . '"' ); + } + + function getMockForViews() { + $db = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->setMethods( array( 'fetchRow', 'query' ) ) + ->getMock(); + + $db->expects( $this->any() ) + ->method( 'query' ) + ->with( $this->anything() ) + ->will( + $this->returnValue( null ) + ); + + $db->expects( $this->any() ) + ->method( 'fetchRow' ) + ->with( $this->anything() ) + ->will( $this->onConsecutiveCalls( + array( 'Tables_in_' => 'view1' ), + array( 'Tables_in_' => 'view2' ), + array( 'Tables_in_' => 'myview' ), + false # no more rows + )); + return $db; + } + /** + * @covers DatabaseMysqlBase::listViews + */ + function testListviews() { + $db = $this->getMockForViews(); + + // The first call populate an internal cache of views + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews() ); + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews() ); + + // Prefix filtering + $this->assertEquals( array( 'view1', 'view2' ), + $db->listViews( 'view' ) ); + $this->assertEquals( array( 'myview' ), + $db->listViews( 'my' ) ); + $this->assertEquals( array(), + $db->listViews( 'UNUSED_PREFIX' ) ); + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews( '' ) ); + } + + /** + * @covers DatabaseMysqlBase::isView + * @dataProvider provideViewExistanceChecks + */ + function testIsView( $isView, $viewName ) { + $db = $this->getMockForViews(); + + switch ( $isView ) { + case true: + $this->assertTrue( $db->isView( $viewName ), + "$viewName should be considered a view" ); + break; + + case false: + $this->assertFalse( $db->isView( $viewName ), + "$viewName has not been defined as a view" ); + break; + } + + } + + function provideViewExistanceChecks() { + return array( + // format: whether it is a view, view name + array( true, 'view1' ), + array( true, 'view2' ), + array( true, 'myview' ), + + array( false, 'user' ), + + array( false, 'view10' ), + array( false, 'my' ), + array( false, 'OH_MY_GOD' ), # they killed kenny! + ); + } + +} diff --git a/tests/phpunit/includes/db/DatabaseSQLTest.php b/tests/phpunit/includes/db/DatabaseSQLTest.php new file mode 100644 index 00000000..5c2d4b70 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseSQLTest.php @@ -0,0 +1,725 @@ +database = new DatabaseTestHelper( __CLASS__ ); + } + + protected function assertLastSql( $sqlText ) { + $this->assertEquals( + $this->database->getLastSqls(), + $sqlText + ); + } + + /** + * @dataProvider provideSelect + * @covers DatabaseBase::select + */ + public function testSelect( $sql, $sqlText ) { + $this->database->select( + $sql['tables'], + $sql['fields'], + isset( $sql['conds'] ) ? $sql['conds'] : array(), + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array(), + isset( $sql['join_conds'] ) ? $sql['join_conds'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelect() { + return array( + array( + array( + 'tables' => 'table', + 'fields' => array( 'field', 'alias' => 'field2' ), + 'conds' => array( 'alias' => 'text' ), + ), + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ), + array( + array( + 'tables' => 'table', + 'fields' => array( 'field', 'alias' => 'field2' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'ORDER BY' => 'field' ), + ), + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'ORDER BY' => 'field' ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'GROUP BY' => 'field', 'HAVING' => 'COUNT(*) > 1' ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field HAVING COUNT(*) > 1 " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( + 'LIMIT' => 1, + 'GROUP BY' => array( 'field', 'field2' ), + 'HAVING' => array( 'COUNT(*) > 1', 'field' => 1 ) + ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field,field2 HAVING (COUNT(*) > 1) AND field = '1' " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table' ), + 'fields' => array( 'alias' => 'field' ), + 'conds' => array( 'alias' => array( 1, 2, 3, 4 ) ), + ), + "SELECT field AS alias " . + "FROM table " . + "WHERE alias IN ('1','2','3','4')" + ), + ); + } + + /** + * @dataProvider provideUpdate + * @covers DatabaseBase::update + */ + public function testUpdate( $sql, $sqlText ) { + $this->database->update( + $sql['table'], + $sql['values'], + $sql['conds'], + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpdate() { + return array( + array( + array( + 'table' => 'table', + 'values' => array( 'field' => 'text', 'field2' => 'text2' ), + 'conds' => array( 'alias' => 'text' ), + ), + "UPDATE table " . + "SET field = 'text'" . + ",field2 = 'text2' " . + "WHERE alias = 'text'" + ), + array( + array( + 'table' => 'table', + 'values' => array( 'field = other', 'field2' => 'text2' ), + 'conds' => array( 'id' => '1' ), + ), + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2' " . + "WHERE id = '1'" + ), + array( + array( + 'table' => 'table', + 'values' => array( 'field = other', 'field2' => 'text2' ), + 'conds' => '*', + ), + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2'" + ), + ); + } + + /** + * @dataProvider provideDelete + * @covers DatabaseBase::delete + */ + public function testDelete( $sql, $sqlText ) { + $this->database->delete( + $sql['table'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDelete() { + return array( + array( + array( + 'table' => 'table', + 'conds' => array( 'alias' => 'text' ), + ), + "DELETE FROM table " . + "WHERE alias = 'text'" + ), + array( + array( + 'table' => 'table', + 'conds' => '*', + ), + "DELETE FROM table" + ), + ); + } + + /** + * @dataProvider provideUpsert + * @covers DatabaseBase::upsert + */ + public function testUpsert( $sql, $sqlText ) { + $this->database->upsert( + $sql['table'], + $sql['rows'], + $sql['uniqueIndexes'], + $sql['set'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpsert() { + return array( + array( + array( + 'table' => 'upsert_table', + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + 'uniqueIndexes' => array( 'field' ), + 'set' => array( 'field' => 'set' ), + ), + "BEGIN; " . + "UPDATE upsert_table " . + "SET field = 'set' " . + "WHERE ((field = 'text')); " . + "INSERT IGNORE INTO upsert_table " . + "(field,field2) " . + "VALUES ('text','text2'); " . + "COMMIT" + ), + ); + } + + /** + * @dataProvider provideDeleteJoin + * @covers DatabaseBase::deleteJoin + */ + public function testDeleteJoin( $sql, $sqlText ) { + $this->database->deleteJoin( + $sql['delTable'], + $sql['joinTable'], + $sql['delVar'], + $sql['joinVar'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDeleteJoin() { + return array( + array( + array( + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => array( 'alias' => 'text' ), + ), + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join WHERE alias = 'text'" . + ")" + ), + array( + array( + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => '*', + ), + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join " . + ")" + ), + ); + } + + /** + * @dataProvider provideInsert + * @covers DatabaseBase::insert + */ + public function testInsert( $sql, $sqlText ) { + $this->database->insert( + $sql['table'], + $sql['rows'], + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsert() { + return array( + array( + array( + 'table' => 'table', + 'rows' => array( 'field' => 'text', 'field2' => 2 ), + ), + "INSERT INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ), + array( + array( + 'table' => 'table', + 'rows' => array( 'field' => 'text', 'field2' => 2 ), + 'options' => 'IGNORE', + ), + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ), + array( + array( + 'table' => 'table', + 'rows' => array( + array( 'field' => 'text', 'field2' => 2 ), + array( 'field' => 'multi', 'field2' => 3 ), + ), + 'options' => 'IGNORE', + ), + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES " . + "('text','2')," . + "('multi','3')" + ), + ); + } + + /** + * @dataProvider provideInsertSelect + * @covers DatabaseBase::insertSelect + */ + public function testInsertSelect( $sql, $sqlText ) { + $this->database->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : array(), + isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsertSelect() { + return array( + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => '*', + ), + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table" + ), + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => array( 'field' => 2 ), + ), + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2'" + ), + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => array( 'field' => 2 ), + 'insertOptions' => 'IGNORE', + 'selectOptions' => array( 'ORDER BY' => 'field' ), + ), + "INSERT IGNORE INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2' " . + "ORDER BY field" + ), + ); + } + + /** + * @dataProvider provideReplace + * @covers DatabaseBase::replace + */ + public function testReplace( $sql, $sqlText ) { + $this->database->replace( + $sql['table'], + $sql['uniqueIndexes'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideReplace() { + return array( + array( + array( + 'table' => 'replace_table', + 'uniqueIndexes' => array( 'field' ), + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + ), + "DELETE FROM replace_table " . + "WHERE ( field='text' ); " . + "INSERT INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( array( 'md_module', 'md_skin' ) ), + 'rows' => array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' AND md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( array( 'md_module', 'md_skin' ) ), + 'rows' => array( + array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), array( + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ), + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' AND md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE ( md_module='module2' AND md_skin='skin2' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( 'md_module', 'md_skin' ), + 'rows' => array( + array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), array( + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ), + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' ) OR ( md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE ( md_module='module2' ) OR ( md_skin='skin2' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array(), + 'rows' => array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), + ), + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps')" + ), + ); + } + + /** + * @dataProvider provideNativeReplace + * @covers DatabaseBase::nativeReplace + */ + public function testNativeReplace( $sql, $sqlText ) { + $this->database->nativeReplace( + $sql['table'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideNativeReplace() { + return array( + array( + array( + 'table' => 'replace_table', + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + ), + "REPLACE INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ), + ); + } + + /** + * @dataProvider provideConditional + * @covers DatabaseBase::conditional + */ + public function testConditional( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->conditional( + $sql['conds'], + $sql['true'], + $sql['false'] + ) ), $sqlText ); + } + + public static function provideConditional() { + return array( + array( + array( + 'conds' => array( 'field' => 'text' ), + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field = 'text' THEN 1 ELSE NULL END)" + ), + array( + array( + 'conds' => array( 'field' => 'text', 'field2' => 'anothertext' ), + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field = 'text' AND field2 = 'anothertext' THEN 1 ELSE NULL END)" + ), + array( + array( + 'conds' => 'field=1', + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field=1 THEN 1 ELSE NULL END)" + ), + ); + } + + /** + * @dataProvider provideBuildConcat + * @covers DatabaseBase::buildConcat + */ + public function testBuildConcat( $stringList, $sqlText ) { + $this->assertEquals( trim( $this->database->buildConcat( + $stringList + ) ), $sqlText ); + } + + public static function provideBuildConcat() { + return array( + array( + array( 'field', 'field2' ), + "CONCAT(field,field2)" + ), + array( + array( "'test'", 'field2' ), + "CONCAT('test',field2)" + ), + ); + } + + /** + * @dataProvider provideBuildLike + * @covers DatabaseBase::buildLike + */ + public function testBuildLike( $array, $sqlText ) { + $this->assertEquals( trim( $this->database->buildLike( + $array + ) ), $sqlText ); + } + + public static function provideBuildLike() { + return array( + array( + 'text', + "LIKE 'text'" + ), + array( + array( 'text', new LikeMatch( '%' ) ), + "LIKE 'text%'" + ), + array( + array( 'text', new LikeMatch( '%' ), 'text2' ), + "LIKE 'text%text2'" + ), + array( + array( 'text', new LikeMatch( '_' ) ), + "LIKE 'text_'" + ), + ); + } + + /** + * @dataProvider provideUnionQueries + * @covers DatabaseBase::unionQueries + */ + public function testUnionQueries( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->unionQueries( + $sql['sqls'], + $sql['all'] + ) ), $sqlText ); + } + + public static function provideUnionQueries() { + return array( + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL' ), + 'all' => true, + ), + "(RAW SQL) UNION ALL (RAW2SQL)" + ), + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL' ), + 'all' => false, + ), + "(RAW SQL) UNION (RAW2SQL)" + ), + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL', 'RAW3SQL' ), + 'all' => false, + ), + "(RAW SQL) UNION (RAW2SQL) UNION (RAW3SQL)" + ), + ); + } + + /** + * @covers DatabaseBase::commit + */ + public function testTransactionCommit() { + $this->database->begin( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + } + + /** + * @covers DatabaseBase::rollback + */ + public function testTransactionRollback() { + $this->database->begin( __METHOD__ ); + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers DatabaseBase::dropTable + */ + public function testDropTable() { + $this->database->setExistingTables( array( 'table' ) ); + $this->database->dropTable( 'table', __METHOD__ ); + $this->assertLastSql( 'DROP TABLE table' ); + } + + /** + * @covers DatabaseBase::dropTable + */ + public function testDropNonExistingTable() { + $this->assertFalse( + $this->database->dropTable( 'non_existing', __METHOD__ ) + ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php new file mode 100644 index 00000000..98b4ca04 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseSqliteTest.php @@ -0,0 +1,455 @@ +lastQuery = $sql; + + return true; + } + + /** + * Override parent visibility to public + */ + public function replaceVars( $s ) { + return parent::replaceVars( $s ); + } +} + +/** + * @group sqlite + * @group Database + * @group medium + */ +class DatabaseSqliteTest extends MediaWikiTestCase { + /** @var MockDatabaseSqlite */ + protected $db; + + protected function setUp() { + parent::setUp(); + + if ( !Sqlite::isPresent() ) { + $this->markTestSkipped( 'No SQLite support detected' ); + } + $this->db = new MockDatabaseSqlite(); + if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { + $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); + } + } + + private function replaceVars( $sql ) { + // normalize spacing to hide implementation details + return preg_replace( '/\s+/', ' ', $this->db->replaceVars( $sql ) ); + } + + private function assertResultIs( $expected, $res ) { + $this->assertNotNull( $res ); + $i = 0; + foreach ( $res as $row ) { + foreach ( $expected[$i] as $key => $value ) { + $this->assertTrue( isset( $row->$key ) ); + $this->assertEquals( $value, $row->$key ); + } + $i++; + } + $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); + } + + public static function provideAddQuotes() { + return array( + array( // #0: empty + '', "''" + ), + array( // #1: simple + 'foo bar', "'foo bar'" + ), + array( // #2: including quote + 'foo\'bar', "'foo''bar'" + ), + // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) + array( + "x\0y", + "x'780079'", + ), + array( // #4: blob object (must be represented as hex) + new Blob( "hello" ), + "x'68656c6c6f'", + ), + ); + } + + /** + * @dataProvider provideAddQuotes() + * @covers DatabaseSqlite::addQuotes + */ + public function testAddQuotes( $value, $expected ) { + // check quoting + $db = new DatabaseSqliteStandalone( ':memory:' ); + $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); + + // ok, quoting works as expected, now try a round trip. + $re = $db->query( 'select ' . $db->addQuotes( $value ) ); + + $this->assertTrue( $re !== false, 'query failed' ); + + if ( $row = $re->fetchRow() ) { + if ( $value instanceof Blob ) { + $value = $value->fetch(); + } + + $this->assertEquals( $value, $row[0], 'string mangled by the database' ); + } else { + $this->fail( 'query returned no result' ); + } + } + + /** + * @covers DatabaseSqlite::replaceVars + */ + public function testReplaceVars() { + $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); + + $this->assertEquals( + "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", + $this->replaceVars( + "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " + . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " + . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" + ) + ); + + $this->assertEquals( + "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", + $this->replaceVars( + "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" + ) + ); + + $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", + $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) + ); + + $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", + $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), + 'Table name changed' + ); + + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) + ); + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) + ); + + $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", + $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) + ); + + $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", + $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) + ); + + $this->assertEquals( "DROP INDEX foo", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) + ); + + $this->assertEquals( "DROP INDEX foo -- dropping index", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) + ); + $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", + $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) + ); + } + + /** + * @covers DatabaseSqlite::tableName + */ + public function testTableName() { + // @todo Moar! + $db = new DatabaseSqliteStandalone( ':memory:' ); + $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $db->tablePrefix( 'foo' ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $this->assertEquals( 'foobar', $db->tableName( 'bar' ) ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructure() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->query( 'CREATE TABLE foo(foo, barfoo)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'bar' ) ), + 'Normal table duplication' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', + $db->selectField( 'sqlite_temp_master', 'sql', array( 'name' => 'baz' ) ), + 'Creation of temporary duplicate' + ); + $this->assertEquals( 0, + $db->selectField( 'sqlite_master', 'COUNT(*)', array( 'name' => 'baz' ) ), + 'Create a temporary duplicate only' + ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructureVirtual() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + if ( $db->getFulltextSearchModule() != 'FTS3' ) { + $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); + } + $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'bar' ) ), + 'Duplication of virtual tables' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'baz' ) ), + "Can't create temporary virtual tables, should fall back to non-temporary duplication" + ); + } + + /** + * @covers DatabaseSqlite::deleteJoin + */ + public function testDeleteJoin() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); + $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); + $db->insert( 'a', array( + array( 'a_1' => 1 ), + array( 'a_1' => 2 ), + array( 'a_1' => 3 ), + ), + __METHOD__ + ); + $db->insert( 'b', array( + array( 'b_1' => 2, 'b_2' => 'a' ), + array( 'b_1' => 3, 'b_2' => 'b' ), + ), + __METHOD__ + ); + $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', array( 'b_2' => 'a' ), __METHOD__ ); + $res = $db->query( "SELECT * FROM a", __METHOD__ ); + $this->assertResultIs( array( + array( 'a_1' => 1 ), + array( 'a_1' => 3 ), + ), + $res + ); + } + + public function testEntireSchema() { + global $IP; + + $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); + if ( $result !== true ) { + $this->fail( $result ); + } + $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions + } + + /** + * Runs upgrades of older databases and compares results with current schema + * @todo Currently only checks list of tables + */ + public function testUpgrades() { + global $IP, $wgVersion, $wgProfileToDatabase; + + // Versions tested + $versions = array( + //'1.13', disabled for now, was totally screwed up + // SQLite wasn't included in 1.14 + '1.15', + '1.16', + '1.17', + '1.18', + ); + + // Mismatches for these columns we can safely ignore + $ignoredColumns = array( + 'user_newtalk.user_last_timestamp', // r84185 + ); + + $currentDB = new DatabaseSqliteStandalone( ':memory:' ); + $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); + if ( $wgProfileToDatabase ) { + $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); + } + $currentTables = $this->getTables( $currentDB ); + sort( $currentTables ); + + foreach ( $versions as $version ) { + $versions = "upgrading from $version to $wgVersion"; + $db = $this->prepareDB( $version ); + $tables = $this->getTables( $db ); + $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); + foreach ( $tables as $table ) { + $currentCols = $this->getColumns( $currentDB, $table ); + $cols = $this->getColumns( $db, $table ); + $this->assertEquals( + array_keys( $currentCols ), + array_keys( $cols ), + "Mismatching columns for table \"$table\" $versions" + ); + foreach ( $currentCols as $name => $column ) { + $fullName = "$table.$name"; + $this->assertEquals( + (bool)$column->pk, + (bool)$cols[$name]->pk, + "PRIMARY KEY status does not match for column $fullName $versions" + ); + if ( !in_array( $fullName, $ignoredColumns ) ) { + $this->assertEquals( + (bool)$column->notnull, + (bool)$cols[$name]->notnull, + "NOT NULL status does not match for column $fullName $versions" + ); + $this->assertEquals( + $column->dflt_value, + $cols[$name]->dflt_value, + "Default values does not match for column $fullName $versions" + ); + } + } + $currentIndexes = $this->getIndexes( $currentDB, $table ); + $indexes = $this->getIndexes( $db, $table ); + $this->assertEquals( + array_keys( $currentIndexes ), + array_keys( $indexes ), + "mismatching indexes for table \"$table\" $versions" + ); + } + $db->close(); + } + } + + /** + * @covers DatabaseSqlite::insertId + */ + public function testInsertIdType() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( 'ResultWrapper', $databaseCreation, "Database creation" ); + + $insertion = $db->insert( 'a', array( 'a_1' => 10 ), __METHOD__ ); + $this->assertTrue( $insertion, "Insertion worked" ); + + $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); + $this->assertTrue( $db->close(), "closing database" ); + } + + private function prepareDB( $version ) { + static $maint = null; + if ( $maint === null ) { + $maint = new FakeMaintenance(); + $maint->loadParamsAndArgs( null, array( 'quiet' => 1 ) ); + } + + global $IP; + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); + $updater = DatabaseUpdater::newForDB( $db, false, $maint ); + $updater->doUpdates( array( 'core' ) ); + + return $db; + } + + private function getTables( $db ) { + $list = array_flip( $db->listTables() ); + $excluded = array( + 'external_user', // removed from core in 1.22 + 'math', // moved out of core in 1.18 + 'trackbacks', // removed from core in 1.19 + 'searchindex', + 'searchindex_content', + 'searchindex_segments', + 'searchindex_segdir', + // FTS4 ready!!1 + 'searchindex_docsize', + 'searchindex_stat', + ); + foreach ( $excluded as $t ) { + unset( $list[$t] ); + } + $list = array_flip( $list ); + sort( $list ); + + return $list; + } + + private function getColumns( $db, $table ) { + $cols = array(); + $res = $db->query( "PRAGMA table_info($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $col ) { + $cols[$col->name] = $col; + } + ksort( $cols ); + + return $cols; + } + + private function getIndexes( $db, $table ) { + $indexes = array(); + $res = $db->query( "PRAGMA index_list($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $index ) { + $res2 = $db->query( "PRAGMA index_info({$index->name})" ); + $this->assertNotNull( $res2 ); + $index->columns = array(); + foreach ( $res2 as $col ) { + $index->columns[] = $col; + } + $indexes[$index->name] = $index; + } + ksort( $indexes ); + + return $indexes; + } + + public function testCaseInsensitiveLike() { + // TODO: Test this for all databases + $db = new DatabaseSqliteStandalone( ':memory:' ); + $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); + $row = $res->fetchRow(); + $this->assertFalse( (bool)$row['a'] ); + } + + /** + * @covers DatabaseSqlite::numFields + */ + public function testNumFields() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( 'ResultWrapper', $databaseCreation, "Failed to create table a" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); + $insertion = $db->insert( 'a', array( 'a_1' => 10 ), __METHOD__ ); + $this->assertTrue( $insertion, "Insertion failed" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); + + $this->assertTrue( $db->close(), "closing database" ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseTest.php b/tests/phpunit/includes/db/DatabaseTest.php new file mode 100644 index 00000000..7e704396 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseTest.php @@ -0,0 +1,237 @@ +db = wfGetDB( DB_MASTER ); + } + + protected function tearDown() { + parent::tearDown(); + if ( $this->functionTest ) { + $this->dropFunctions(); + $this->functionTest = false; + } + } + /** + * @covers DatabaseBase::dropTable + */ + public function testAddQuotesNull() { + $check = "NULL"; + if ( $this->db->getType() === 'sqlite' || $this->db->getType() === 'oracle' ) { + $check = "''"; + } + $this->assertEquals( $check, $this->db->addQuotes( null ) ); + } + + public function testAddQuotesInt() { + # returning just "1234" should be ok too, though... + # maybe + $this->assertEquals( + "'1234'", + $this->db->addQuotes( 1234 ) ); + } + + public function testAddQuotesFloat() { + # returning just "1234.5678" would be ok too, though + $this->assertEquals( + "'1234.5678'", + $this->db->addQuotes( 1234.5678 ) ); + } + + public function testAddQuotesString() { + $this->assertEquals( + "'string'", + $this->db->addQuotes( 'string' ) ); + } + + public function testAddQuotesStringQuote() { + $check = "'string''s cause trouble'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "'string\'s cause trouble'"; + } + $this->assertEquals( + $check, + $this->db->addQuotes( "string's cause trouble" ) ); + } + + private function getSharedTableName( $table, $database, $prefix, $format = 'quoted' ) { + global $wgSharedDB, $wgSharedTables, $wgSharedPrefix; + + $oldName = $wgSharedDB; + $oldTables = $wgSharedTables; + $oldPrefix = $wgSharedPrefix; + + $wgSharedDB = $database; + $wgSharedTables = array( $table ); + $wgSharedPrefix = $prefix; + + $ret = $this->db->tableName( $table, $format ); + + $wgSharedDB = $oldName; + $wgSharedTables = $oldTables; + $wgSharedPrefix = $oldPrefix; + + return $ret; + } + + private function prefixAndQuote( $table, $database = null, $prefix = null, $format = 'quoted' ) { + if ( $this->db->getType() === 'sqlite' || $format !== 'quoted' ) { + $quote = ''; + } elseif ( $this->db->getType() === 'mysql' ) { + $quote = '`'; + } elseif ( $this->db->getType() === 'oracle' ) { + $quote = '/*Q*/'; + } else { + $quote = '"'; + } + + if ( $database !== null ) { + if ( $this->db->getType() === 'oracle' ) { + $database = $quote . $database . '.'; + } else { + $database = $quote . $database . $quote . '.'; + } + } + + if ( $prefix === null ) { + $prefix = $this->dbPrefix(); + } + + if ( $this->db->getType() === 'oracle' ) { + return strtoupper( $database . $quote . $prefix . $table ); + } else { + return $database . $quote . $prefix . $table . $quote; + } + } + + public function testTableNameLocal() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename' ), + $this->db->tableName( 'tablename' ) + ); + } + + public function testTableNameRawLocal() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', null, null, 'raw' ), + $this->db->tableName( 'tablename', 'raw' ) + ); + } + + public function testTableNameShared() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', 'sh_' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', 'sh_' ) + ); + + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', null ), + $this->getSharedTableName( 'tablename', 'sharedatabase', null ) + ); + } + + public function testTableNameRawShared() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', 'sh_', 'raw' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', 'sh_', 'raw' ) + ); + + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', null, 'raw' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', null, 'raw' ) + ); + } + + public function testTableNameForeign() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'databasename', '' ), + $this->db->tableName( 'databasename.tablename' ) + ); + } + + public function testTableNameRawForeign() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'databasename', '', 'raw' ), + $this->db->tableName( 'databasename.tablename', 'raw' ) + ); + } + + public function testFillPreparedEmpty() { + $sql = $this->db->fillPrepared( + 'SELECT * FROM interwiki', array() ); + $this->assertEquals( + "SELECT * FROM interwiki", + $sql ); + } + + public function testFillPreparedQuestion() { + $sql = $this->db->fillPrepared( + 'SELECT * FROM cur WHERE cur_namespace=? AND cur_title=?', + array( 4, "Snicker's_paradox" ) ); + + $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker''s_paradox'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker\'s_paradox'"; + } + $this->assertEquals( $check, $sql ); + } + + public function testFillPreparedBang() { + $sql = $this->db->fillPrepared( + 'SELECT user_id FROM ! WHERE user_name=?', + array( '"user"', "Slash's Dot" ) ); + + $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash''s Dot'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash\'s Dot'"; + } + $this->assertEquals( $check, $sql ); + } + + public function testFillPreparedRaw() { + $sql = $this->db->fillPrepared( + "SELECT * FROM cur WHERE cur_title='This_\\&_that,_WTF\\?\\!'", + array( '"user"', "Slash's Dot" ) ); + $this->assertEquals( + "SELECT * FROM cur WHERE cur_title='This_&_that,_WTF?!'", + $sql ); + } + + public function testStoredFunctions() { + if ( !in_array( wfGetDB( DB_MASTER )->getType(), array( 'mysql', 'postgres' ) ) ) { + $this->markTestSkipped( 'MySQL or Postgres required' ); + } + global $IP; + $this->dropFunctions(); + $this->functionTest = true; + $this->assertTrue( + $this->db->sourceFile( "$IP/tests/phpunit/data/db/{$this->db->getType()}/functions.sql" ) + ); + $res = $this->db->query( 'SELECT mw_test_function() AS test', __METHOD__ ); + $this->assertEquals( 42, $res->fetchObject()->test ); + } + + private function dropFunctions() { + $this->db->query( 'DROP FUNCTION IF EXISTS mw_test_function' + . ( $this->db->getType() == 'postgres' ? '()' : '' ) + ); + } + + public function testUnknownTableCorruptsResults() { + $res = $this->db->select( 'page', '*', array( 'page_id' => 1 ) ); + $this->assertFalse( $this->db->tableExists( 'foobarbaz' ) ); + $this->assertInternalType( 'int', $res->numRows() ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseTestHelper.php b/tests/phpunit/includes/db/DatabaseTestHelper.php new file mode 100644 index 00000000..0c0b3902 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -0,0 +1,170 @@ +testName = $testName; + } + + /** + * Returns SQL queries grouped by '; ' + * Clear the list of queries that have been done so far. + */ + public function getLastSqls() { + $lastSqls = implode( '; ', $this->lastSqls ); + $this->lastSqls = array(); + + return $lastSqls; + } + + public function setExistingTables( $tablesExists ) { + $this->tablesExists = (array)$tablesExists; + } + + protected function addSql( $sql ) { + // clean up spaces before and after some words and the whole string + $this->lastSqls[] = trim( preg_replace( + '/\s{2,}(?=FROM|WHERE|GROUP BY|ORDER BY|LIMIT)|(?<=SELECT|INSERT|UPDATE)\s{2,}/', + ' ', $sql + ) ); + } + + protected function checkFunctionName( $fname ) { + if ( substr( $fname, 0, strlen( $this->testName ) ) !== $this->testName ) { + throw new MWException( 'function name does not start with test class. ' . + $fname . ' vs. ' . $this->testName . '. ' . + 'Please provide __METHOD__ to database methods.' ); + } + } + + function strencode( $s ) { + // Choose apos to avoid handling of escaping double quotes in quoted text + return str_replace( "'", "\'", $s ); + } + + public function addIdentifierQuotes( $s ) { + // no escaping to avoid handling of double quotes in quoted text + return $s; + } + + public function query( $sql, $fname = '', $tempIgnore = false ) { + $this->checkFunctionName( $fname ); + $this->addSql( $sql ); + + return parent::query( $sql, $fname, $tempIgnore ); + } + + public function tableExists( $table, $fname = __METHOD__ ) { + $this->checkFunctionName( $fname ); + + return in_array( $table, (array)$this->tablesExists ); + } + + // Redeclare parent method to make it public + public function nativeReplace( $table, $rows, $fname ) { + return parent::nativeReplace( $table, $rows, $fname ); + } + + function getType() { + return 'test'; + } + + function open( $server, $user, $password, $dbName ) { + return false; + } + + function fetchObject( $res ) { + return false; + } + + function fetchRow( $res ) { + return false; + } + + function numRows( $res ) { + return -1; + } + + function numFields( $res ) { + return -1; + } + + function fieldName( $res, $n ) { + return 'test'; + } + + function insertId() { + return -1; + } + + function dataSeek( $res, $row ) { + /* nop */ + } + + function lastErrno() { + return -1; + } + + function lastError() { + return 'test'; + } + + function fieldInfo( $table, $field ) { + return false; + } + + function indexInfo( $table, $index, $fname = 'DatabaseBase::indexInfo' ) { + return false; + } + + function affectedRows() { + return -1; + } + + function getSoftwareLink() { + return 'test'; + } + + function getServerVersion() { + return 'test'; + } + + function getServerInfo() { + return 'test'; + } + + function isOpen() { + return true; + } + + protected function closeConnection() { + return false; + } + + protected function doQuery( $sql ) { + return array(); + } +} diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php new file mode 100644 index 00000000..4c59f474 --- /dev/null +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -0,0 +1,61 @@ +getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + + $config = array( + 'class' => $deprecated, + 'connection' => $mockDB, + # Various other parameters required: + 'sectionsByDB' => array(), + 'sectionLoads' => array(), + 'serverTemplate' => array(), + ); + + $this->hideDeprecated( '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details' ); + $result = LBFactory::getLBFactoryClass( $config ); + + $this->assertEquals( $expected, $result ); + } + + public function getLBFactoryClassProvider() { + return array( + # Format: new class, old class + array( 'LBFactorySimple', 'LBFactory_Simple' ), + array( 'LBFactorySingle', 'LBFactory_Single' ), + array( 'LBFactoryMulti', 'LBFactory_Multi' ), + array( 'LBFactoryFake', 'LBFactory_Fake' ), + ); + } +} diff --git a/tests/phpunit/includes/db/ORMRowTest.php b/tests/phpunit/includes/db/ORMRowTest.php new file mode 100644 index 00000000..447bf219 --- /dev/null +++ b/tests/phpunit/includes/db/ORMRowTest.php @@ -0,0 +1,226 @@ + + */ +abstract class ORMRowTest extends \MediaWikiTestCase { + + /** + * @since 1.20 + * @return string + */ + abstract protected function getRowClass(); + + /** + * @since 1.20 + * @return IORMTable + */ + abstract protected function getTableInstance(); + + /** + * @since 1.20 + * @return array + */ + abstract public function constructorTestProvider(); + + /** + * @since 1.20 + * @param IORMRow $row + * @param array $data + */ + protected function verifyFields( IORMRow $row, array $data ) { + foreach ( array_keys( $data ) as $fieldName ) { + $this->assertEquals( $data[$fieldName], $row->getField( $fieldName ) ); + } + } + + /** + * @since 1.20 + * @param array $data + * @param bool $loadDefaults + * @return IORMRow + */ + protected function getRowInstance( array $data, $loadDefaults ) { + $class = $this->getRowClass(); + + return new $class( $this->getTableInstance(), $data, $loadDefaults ); + } + + /** + * @since 1.20 + * @return array + */ + protected function getMockValues() { + return array( + 'id' => 1, + 'str' => 'foobar4645645', + 'int' => 42, + 'float' => 4.2, + 'bool' => true, + 'array' => array( 42, 'foobar' ), + 'blob' => new stdClass() + ); + } + + /** + * @since 1.20 + * @return array + */ + protected function getMockFields() { + $mockValues = $this->getMockValues(); + $mockFields = array(); + + foreach ( $this->getTableInstance()->getFields() as $name => $type ) { + if ( $name !== 'id' ) { + $mockFields[$name] = $mockValues[$type]; + } + } + + return $mockFields; + } + + /** + * @since 1.20 + * @return array Array of IORMRow + */ + public function instanceProvider() { + $instances = array(); + + foreach ( $this->constructorTestProvider() as $arguments ) { + $instances[] = array( call_user_func_array( array( $this, 'getRowInstance' ), $arguments ) ); + } + + return $instances; + } + + /** + * @dataProvider constructorTestProvider + */ + public function testConstructor( array $data, $loadDefaults ) { + $this->verifyFields( $this->getRowInstance( $data, $loadDefaults ), $data ); + } + + /** + * @dataProvider constructorTestProvider + */ + public function testSaveAndRemove( array $data, $loadDefaults ) { + $item = $this->getRowInstance( $data, $loadDefaults ); + + $this->assertTrue( $item->save() ); + + $this->assertTrue( $item->hasIdField() ); + $this->assertTrue( is_integer( $item->getId() ) ); + + $id = $item->getId(); + + $this->assertTrue( $item->save() ); + + $this->assertEquals( $id, $item->getId() ); + + $this->verifyFields( $item, $data ); + + $this->assertTrue( $item->remove() ); + + $this->assertFalse( $item->hasIdField() ); + + $this->assertTrue( $item->save() ); + + $this->verifyFields( $item, $data ); + + $this->assertTrue( $item->remove() ); + + $this->assertFalse( $item->hasIdField() ); + + $this->verifyFields( $item, $data ); + } + + /** + * @dataProvider instanceProvider + */ + public function testSetField( IORMRow $item ) { + foreach ( $this->getMockFields() as $name => $value ) { + $item->setField( $name, $value ); + $this->assertEquals( $value, $item->getField( $name ) ); + } + } + + /** + * @since 1.20 + * @param array $expected + * @param IORMRow $item + */ + protected function assertFieldValues( array $expected, IORMRow $item ) { + foreach ( $expected as $name => $type ) { + if ( $name !== 'id' ) { + $this->assertEquals( $expected[$name], $item->getField( $name ) ); + } + } + } + + /** + * @dataProvider instanceProvider + */ + public function testSetFields( IORMRow $item ) { + $originalValues = $item->getFields(); + + $item->setFields( array(), false ); + + foreach ( $item->getTable()->getFields() as $name => $type ) { + $originalHas = array_key_exists( $name, $originalValues ); + $newHas = $item->hasField( $name ); + + $this->assertEquals( $originalHas, $newHas ); + + if ( $originalHas && $newHas ) { + $this->assertEquals( $originalValues[$name], $item->getField( $name ) ); + } + } + + $mockFields = $this->getMockFields(); + + $item->setFields( $mockFields, false ); + + $this->assertFieldValues( $originalValues, $item ); + + $item->setFields( $mockFields, true ); + + $this->assertFieldValues( $mockFields, $item ); + } + + // TODO: test all of the methods! + +} diff --git a/tests/phpunit/includes/db/ORMTableTest.php b/tests/phpunit/includes/db/ORMTableTest.php new file mode 100644 index 00000000..7171ee59 --- /dev/null +++ b/tests/phpunit/includes/db/ORMTableTest.php @@ -0,0 +1,150 @@ + + * @author Daniel Kinzler + */ + +/** + * @covers PageORMTableForTesting + */ +class ORMTableTest extends MediaWikiTestCase { + + /** + * @since 1.21 + * @return string + */ + protected function getTableClass() { + return 'PageORMTableForTesting'; + } + + /** + * @since 1.21 + * @return IORMTable + */ + public function getTable() { + $class = $this->getTableClass(); + + return $class::singleton(); + } + + /** + * @since 1.21 + * @return string + */ + public function getRowClass() { + return $this->getTable()->getRowClass(); + } + + /** + * @since 1.21 + */ + public function testSingleton() { + $class = $this->getTableClass(); + + $this->assertInstanceOf( $class, $class::singleton() ); + $this->assertTrue( $class::singleton() === $class::singleton() ); + } + + /** + * @since 1.21 + */ + public function testIgnoreErrorsOverride() { + $table = $this->getTable(); + + $db = $table->getReadDbConnection(); + $db->ignoreErrors( true ); + + try { + $table->rawSelect( "this is invalid" ); + $this->fail( "An invalid query should trigger a DBQueryError even if ignoreErrors is enabled." ); + } catch ( DBQueryError $ex ) { + $this->assertTrue( true, "just making phpunit happy" ); + } + + $db->ignoreErrors( false ); + } +} + +/** + * Dummy ORM table for testing, reading Title objects from the page table. + * + * @since 1.21 + */ + +class PageORMTableForTesting extends ORMTable { + + /** + * @see ORMTable::getName + * + * @return string + */ + public function getName() { + return 'page'; + } + + /** + * @see ORMTable::getRowClass + * + * @return string + */ + public function getRowClass() { + return 'Title'; + } + + /** + * @see ORMTable::newRow + * + * @return IORMRow + */ + public function newRow( array $data, $loadDefaults = false ) { + return Title::makeTitle( $data['namespace'], $data['title'] ); + } + + /** + * @see ORMTable::getFields + * + * @return array + */ + public function getFields() { + return array( + 'id' => 'int', + 'namespace' => 'int', + 'title' => 'str', + ); + } + + /** + * @see ORMTable::getFieldPrefix + * + * @return string + */ + protected function getFieldPrefix() { + return 'page_'; + } +} diff --git a/tests/phpunit/includes/db/TestORMRowTest.php b/tests/phpunit/includes/db/TestORMRowTest.php new file mode 100644 index 00000000..c9459c90 --- /dev/null +++ b/tests/phpunit/includes/db/TestORMRowTest.php @@ -0,0 +1,218 @@ + + */ +require_once __DIR__ . "/ORMRowTest.php"; + +/** + * @covers TestORMRow + */ +class TestORMRowTest extends ORMRowTest { + + /** + * @since 1.20 + * @return string + */ + protected function getRowClass() { + return 'TestORMRow'; + } + + /** + * @since 1.20 + * @return IORMTable + */ + protected function getTableInstance() { + return TestORMTable::singleton(); + } + + protected function setUp() { + parent::setUp(); + + $dbw = wfGetDB( DB_MASTER ); + + $isSqlite = $GLOBALS['wgDBtype'] === 'sqlite'; + $isPostgres = $GLOBALS['wgDBtype'] === 'postgres'; + + $idField = $isSqlite ? 'INTEGER' : 'INT unsigned'; + $primaryKey = $isSqlite ? 'PRIMARY KEY AUTOINCREMENT' : 'auto_increment PRIMARY KEY'; + + if ( $isPostgres ) { + $dbw->query( + 'CREATE TABLE IF NOT EXISTS ' . $dbw->tableName( 'orm_test' ) . "( + test_id serial PRIMARY KEY, + test_name TEXT NOT NULL DEFAULT '', + test_age INTEGER NOT NULL DEFAULT 0, + test_height REAL NOT NULL DEFAULT 0, + test_awesome INTEGER NOT NULL DEFAULT 0, + test_stuff BYTEA, + test_moarstuff BYTEA, + test_time TIMESTAMPTZ + );", + __METHOD__ + ); + } else { + $dbw->query( + 'CREATE TABLE IF NOT EXISTS ' . $dbw->tableName( 'orm_test' ) . '( + test_id ' . $idField . ' NOT NULL ' . $primaryKey . ', + test_name VARCHAR(255) NOT NULL, + test_age TINYINT unsigned NOT NULL, + test_height FLOAT NOT NULL, + test_awesome TINYINT unsigned NOT NULL, + test_stuff BLOB NOT NULL, + test_moarstuff BLOB NOT NULL, + test_time varbinary(14) NOT NULL + );', + __METHOD__ + ); + } + } + + protected function tearDown() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->dropTable( 'orm_test', __METHOD__ ); + + parent::tearDown(); + } + + public function constructorTestProvider() { + $dbw = wfGetDB( DB_MASTER ); + return array( + array( + array( + 'name' => 'Foobar', + 'time' => $dbw->timestamp( '20120101020202' ), + 'age' => 42, + 'height' => 9000.1, + 'awesome' => true, + 'stuff' => array( 13, 11, 7, 5, 3, 2 ), + 'moarstuff' => (object)array( 'foo' => 'bar', 'bar' => array( 4, 2 ), 'baz' => true ) + ), + true + ), + ); + } + + /** + * @since 1.21 + * @return array + */ + protected function getMockValues() { + return array( + 'id' => 1, + 'str' => 'foobar4645645', + 'int' => 42, + 'float' => 4.2, + 'bool' => '', + 'array' => array( 42, 'foobar' ), + 'blob' => new stdClass() + ); + } +} + +class TestORMRow extends ORMRow { +} + +class TestORMTable extends ORMTable { + + /** + * Returns the name of the database table objects of this type are stored in. + * + * @since 1.20 + * + * @return string + */ + public function getName() { + return 'orm_test'; + } + + /** + * Returns the name of a IORMRow implementing class that + * represents single rows in this table. + * + * @since 1.20 + * + * @return string + */ + public function getRowClass() { + return 'TestORMRow'; + } + + /** + * Returns an array with the fields and their types this object contains. + * This corresponds directly to the fields in the database, without prefix. + * + * field name => type + * + * Allowed types: + * * id + * * str + * * int + * * float + * * bool + * * array + * * blob + * + * @since 1.20 + * + * @return array + */ + public function getFields() { + return array( + 'id' => 'id', + 'name' => 'str', + 'age' => 'int', + 'height' => 'float', + 'awesome' => 'bool', + 'stuff' => 'array', + 'moarstuff' => 'blob', + 'time' => 'str', // TS_MW + ); + } + + /** + * Gets the db field prefix. + * + * @since 1.20 + * + * @return string + */ + protected function getFieldPrefix() { + return 'test_'; + } +} diff --git a/tests/phpunit/includes/debug/MWDebugTest.php b/tests/phpunit/includes/debug/MWDebugTest.php new file mode 100644 index 00000000..6e41de75 --- /dev/null +++ b/tests/phpunit/includes/debug/MWDebugTest.php @@ -0,0 +1,141 @@ +assertEquals( + array( array( + 'msg' => 'logging a string', + 'type' => 'log', + 'caller' => __METHOD__, + ) ), + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::warning + */ + public function testAddWarning() { + MWDebug::warning( 'Warning message' ); + $this->assertEquals( + array( array( + 'msg' => 'Warning message', + 'type' => 'warn', + 'caller' => 'MWDebugTest::testAddWarning', + ) ), + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 1, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidNonConsecutivesDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::warning( 'some warning' ); + MWDebug::log( 'we could have logged something too' ); + // Another deprecation + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 3, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::appendDebugInfoToApiResult + */ + public function testAppendDebugInfoToApiResultXmlFormat() { + $request = $this->newApiRequest( + array( 'action' => 'help', 'format' => 'xml' ), + '/api.php?action=help&format=xml' + ); + + $context = new RequestContext(); + $context->setRequest( $request ); + + $apiMain = new ApiMain( $context ); + + $result = new ApiResult( $apiMain ); + $result->setRawMode( true ); + + MWDebug::appendDebugInfoToApiResult( $context, $result ); + + $this->assertInstanceOf( 'ApiResult', $result ); + $data = $result->getData(); + + $expectedKeys = array( 'mwVersion', 'phpEngine', 'phpVersion', 'gitRevision', 'gitBranch', + 'gitViewUrl', 'time', 'log', 'debugLog', 'queries', 'request', 'memory', + 'memoryPeak', 'includes', 'profile', '_element' ); + + foreach ( $expectedKeys as $expectedKey ) { + $this->assertArrayHasKey( $expectedKey, $data['debuginfo'], "debuginfo has $expectedKey" ); + } + + $xml = ApiFormatXml::recXmlPrint( 'help', $data ); + + // exception not thrown + $this->assertInternalType( 'string', $xml ); + } + + /** + * @param string[] $params + * @param string $requestUrl + * + * @return FauxRequest + */ + private function newApiRequest( array $params, $requestUrl ) { + $request = $this->getMockBuilder( 'FauxRequest' ) + ->setMethods( array( 'getRequestURL' ) ) + ->setConstructorArgs( array( + $params + ) ) + ->getMock(); + + $request->expects( $this->any() ) + ->method( 'getRequestURL' ) + ->will( $this->returnValue( $requestUrl ) ); + + return $request; + } + +} diff --git a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php new file mode 100644 index 00000000..5348c854 --- /dev/null +++ b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php @@ -0,0 +1,38 @@ + 'deferred update 1', + '2' => 'deferred update 2', + '3' => 'deferred update 3', + '2-1' => 'deferred update 1 within deferred update 2', + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['1']; + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2-1']; + } + ); + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates[3]; + } + ); + + $this->expectOutputString( implode( '', $updates ) ); + + DeferredUpdates::doUpdates(); + } + +} diff --git a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php new file mode 100644 index 00000000..188ad3fd --- /dev/null +++ b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php @@ -0,0 +1,135 @@ +format( $input ); + $this->assertEquals( $expectedOutput, $output ); + } + + private function getMockDiff( $edits ) { + $diff = $this->getMockBuilder( 'Diff' ) + ->disableOriginalConstructor() + ->getMock(); + $diff->expects( $this->any() ) + ->method( 'getEdits' ) + ->will( $this->returnValue( $edits ) ); + return $diff; + } + + private function getMockDiffOp( $type = null, $orig = array(), $closing = array() ) { + $diffOp = $this->getMockBuilder( 'DiffOp' ) + ->disableOriginalConstructor() + ->getMock(); + $diffOp->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $type ) ); + $diffOp->expects( $this->any() ) + ->method( 'getOrig' ) + ->will( $this->returnValue( $orig ) ); + if ( $type === 'change' ) { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->with( $this->isType( 'integer' ) ) + ->will( $this->returnCallback( function () { + return 'mockLine'; + } ) ); + } else { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->will( $this->returnValue( $closing ) ); + } + return $diffOp; + } + + public function provideTestFormat() { + $emptyArrayTestCases = array( + $this->getMockDiff( array() ), + $this->getMockDiff( array( $this->getMockDiffOp( 'add' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'delete' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'change' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'copy' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'FOOBARBAZ' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'add', 'line' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array(), array( 'line' ) ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'copy', array(), array( 'line' ) ) ) ), + ); + + $otherTestCases = array(); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'add', array( ), array( 'a1' ) ) ) ), + array( array( 'action' => 'add', 'new' => 'a1', 'newline' => 1 ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'add', array( ), array( 'a1', 'a2' ) ) ) ), + array( + array( 'action' => 'add', 'new' => 'a1', 'newline' => 1 ), + array( 'action' => 'add', 'new' => 'a2', 'newline' => 2 ), + ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array( 'd1' ) ) ) ), + array( array( 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array( 'd1', 'd2' ) ) ) ), + array( + array( 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ), + array( 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ), + ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'change', array( 'd1' ), array( 'a1' ) ) ) ), + array( array( + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( + 'change', + array( 'd1', 'd2' ), + array( 'a1', 'a2' ) + ) ) ), + array( + array( + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ), + array( + 'action' => 'change', + 'old' => 'd2', + 'new' => 'mockLine', + 'newline' => 2, 'oldline' => 2 + ), + ), + ); + + $testCases = array(); + foreach ( $emptyArrayTestCases as $testCase ) { + $testCases[] = array( $testCase, array() ); + } + foreach ( $otherTestCases as $testCase ) { + $testCases[] = array( $testCase[0], $testCase[1] ); + } + return $testCases; + } + +} diff --git a/tests/phpunit/includes/diff/DiffOpTest.php b/tests/phpunit/includes/diff/DiffOpTest.php new file mode 100644 index 00000000..d89b89fe --- /dev/null +++ b/tests/phpunit/includes/diff/DiffOpTest.php @@ -0,0 +1,73 @@ +type = 'foo'; + $this->assertEquals( 'foo', $obj->getType() ); + } + + /** + * @covers DiffOp::getOrig + */ + public function testGetOrig() { + $obj = new FakeDiffOp(); + $obj->orig = array( 'foo' ); + $this->assertEquals( array( 'foo' ), $obj->getOrig() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosing() { + $obj = new FakeDiffOp(); + $obj->closing = array( 'foo' ); + $this->assertEquals( array( 'foo' ), $obj->getClosing() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosingWithParameter() { + $obj = new FakeDiffOp(); + $obj->closing = array( 'foo', 'bar', 'baz' ); + $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); + $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); + $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); + $this->assertEquals( null, $obj->getClosing( 3 ) ); + } + + /** + * @covers DiffOp::norig + */ + public function testNorig() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->norig() ); + $obj->orig = array( 'foo' ); + $this->assertEquals( 1, $obj->norig() ); + } + + /** + * @covers DiffOp::nclosing + */ + public function testNclosing() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->nclosing() ); + $obj->closing = array( 'foo' ); + $this->assertEquals( 1, $obj->nclosing() ); + } + +} diff --git a/tests/phpunit/includes/diff/DiffTest.php b/tests/phpunit/includes/diff/DiffTest.php new file mode 100644 index 00000000..1911c82a --- /dev/null +++ b/tests/phpunit/includes/diff/DiffTest.php @@ -0,0 +1,20 @@ +edits = 'FooBarBaz'; + $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); + } + +} diff --git a/tests/phpunit/includes/diff/DifferenceEngineTest.php b/tests/phpunit/includes/diff/DifferenceEngineTest.php new file mode 100644 index 00000000..5474b963 --- /dev/null +++ b/tests/phpunit/includes/diff/DifferenceEngineTest.php @@ -0,0 +1,121 @@ + + */ +class DifferenceEngineTest extends MediaWikiTestCase { + + protected $context; + + private static $revisions; + + protected function setUp() { + parent::setUp(); + + $title = $this->getTitle(); + + $this->context = new RequestContext(); + $this->context->setTitle( $title ); + + if ( !self::$revisions ) { + self::$revisions = $this->doEdits(); + } + } + + /** + * @return Title + */ + protected function getTitle() { + $namespace = $this->getDefaultWikitextNS(); + return Title::newFromText( 'Kitten', $namespace ); + } + + /** + * @return int[] Revision ids + */ + protected function doEdits() { + $title = $this->getTitle(); + $page = WikiPage::factory( $title ); + + $strings = array( "it is a kitten", "two kittens", "three kittens", "four kittens" ); + $revisions = array(); + + foreach ( $strings as $string ) { + $content = ContentHandler::makeContent( $string, $title ); + $page->doEditContent( $content, 'edit page' ); + $revisions[] = $page->getLatest(); + } + + return $revisions; + } + + public function testMapDiffPrevNext() { + $cases = $this->getMapDiffPrevNextCases(); + + foreach ( $cases as $case ) { + list( $expected, $old, $new, $message ) = $case; + + $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false ); + $diffMap = $diffEngine->mapDiffPrevNext( $old, $new ); + $this->assertEquals( $expected, $diffMap, $message ); + } + } + + private function getMapDiffPrevNextCases() { + $revs = self::$revisions; + + return array( + array( array( $revs[1], $revs[2] ), $revs[2], 'prev', 'diff=prev' ), + array( array( $revs[2], $revs[3] ), $revs[2], 'next', 'diff=next' ), + array( array( $revs[1], $revs[3] ), $revs[1], $revs[3], 'diff=' . $revs[3] ) + ); + } + + public function testLoadRevisionData() { + $cases = $this->getLoadRevisionDataCases(); + + foreach ( $cases as $case ) { + list( $expectedOld, $expectedNew, $old, $new, $message ) = $case; + + $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false ); + $diffEngine->loadRevisionData(); + + $this->assertEquals( $diffEngine->getOldid(), $expectedOld, $message ); + $this->assertEquals( $diffEngine->getNewid(), $expectedNew, $message ); + } + } + + private function getLoadRevisionDataCases() { + $revs = self::$revisions; + + return array( + array( $revs[2], $revs[3], $revs[3], 'prev', 'diff=prev' ), + array( $revs[2], $revs[3], $revs[2], 'next', 'diff=next' ), + array( $revs[1], $revs[3], $revs[1], $revs[3], 'diff=' . $revs[3] ), + array( $revs[1], $revs[3], $revs[1], 0, 'diff=0' ) + ); + } + + public function testGetOldid() { + $revs = self::$revisions; + + $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false ); + $this->assertEquals( $revs[1], $diffEngine->getOldid(), 'diff get old id' ); + } + + public function testGetNewid() { + $revs = self::$revisions; + + $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false ); + $this->assertEquals( $revs[2], $diffEngine->getNewid(), 'diff get new id' ); + } + +} diff --git a/tests/phpunit/includes/diff/FakeDiffOp.php b/tests/phpunit/includes/diff/FakeDiffOp.php new file mode 100644 index 00000000..70c8f64a --- /dev/null +++ b/tests/phpunit/includes/diff/FakeDiffOp.php @@ -0,0 +1,11 @@ +wgOut = clone $wgOut; + } + + protected function tearDown() { + parent::tearDown(); + global $wgOut; + $wgOut = $this->wgOut; + } + + public function testExceptionSetsStatusCode() { + global $wgOut; + $wgOut = $this->getMockWgOut(); + try { + throw new BadTitleError(); + } catch ( BadTitleError $e ) { + $e->report(); + $this->assertTrue( true ); + } + } + + private function getMockWgOut() { + $mock = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->once() ) + ->method( 'setStatusCode' ) + ->with( 400 ); + return $mock; + } + +} diff --git a/tests/phpunit/includes/exception/ErrorPageErrorTest.php b/tests/phpunit/includes/exception/ErrorPageErrorTest.php new file mode 100644 index 00000000..13dcf33b --- /dev/null +++ b/tests/phpunit/includes/exception/ErrorPageErrorTest.php @@ -0,0 +1,67 @@ +wgOut = clone $wgOut; + } + + protected function tearDown() { + global $wgOut; + $wgOut = $this->wgOut; + parent::tearDown(); + } + + private function getMockMessage() { + $mockMessage = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + $mockMessage->expects( $this->once() ) + ->method( 'inLanguage' ) + ->will( $this->returnValue( $mockMessage ) ); + $mockMessage->expects( $this->once() ) + ->method( 'useDatabase' ) + ->will( $this->returnValue( $mockMessage ) ); + return $mockMessage; + } + + public function testConstruction() { + $mockMessage = $this->getMockMessage(); + $title = 'Foo'; + $params = array( 'Baz' ); + $e = new ErrorPageError( $title, $mockMessage, $params ); + $this->assertEquals( $title, $e->title ); + $this->assertEquals( $mockMessage, $e->msg ); + $this->assertEquals( $params, $e->params ); + } + + public function testReport() { + $mockMessage = $this->getMockMessage(); + $title = 'Foo'; + $params = array( 'Baz' ); + + global $wgOut; + $wgOut = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $wgOut->expects( $this->once() ) + ->method( 'showErrorPage' ) + ->with( $title, $mockMessage, $params ); + $wgOut->expects( $this->once() ) + ->method( 'output' ); + + $e = new ErrorPageError( $title, $mockMessage, $params ); + $e->report(); + } + + + +} diff --git a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php new file mode 100644 index 00000000..dc5dc6aa --- /dev/null +++ b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php @@ -0,0 +1,74 @@ +getTrace(); + $hasObject = false; + $hasArray = false; + foreach ( $trace as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $hasObject = $hasObject || is_object( $arg ); + $hasArray = $hasArray || is_array( $arg ); + } + + if ( $hasObject && $hasArray ) { + break; + } + } + $this->assertTrue( $hasObject, + "The stacktrace must have a function having an object has parameter" ); + $this->assertTrue( $hasArray, + "The stacktrace must have a function having an array has parameter" ); + + # Now we redact the trace.. and make sure no function arguments are + # arrays or objects. + $redacted = MWExceptionHandler::getRedactedTrace( $e ); + + foreach ( $redacted as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $this->assertNotInternalType( 'array', $arg ); + $this->assertNotInternalType( 'object', $arg ); + } + } + + $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); + } + + /** + * Helper function for testExpandArgumentsInCall + * + * Pass it an object and an array, and something by reference :-) + * + * @throws Exception + */ + protected static function helperThrowAnException( $a, $b, &$c ) { + throw new Exception(); + } +} diff --git a/tests/phpunit/includes/exception/MWExceptionTest.php b/tests/phpunit/includes/exception/MWExceptionTest.php new file mode 100644 index 00000000..ef0f2a9e --- /dev/null +++ b/tests/phpunit/includes/exception/MWExceptionTest.php @@ -0,0 +1,241 @@ +setMwGlobals( array( + 'wgLang' => $wgLang, + 'wgFullyInitialised' => $wgFullyInitialised, + 'wgOut' => $wgOut, + ) ); + + $e = new MWException(); + $this->assertEquals( $expected, $e->useOutputPage() ); + } + + public function provideTextUseOutputPage() { + return array( + // expected, wgLang, wgFullyInitialised, wgOut + array( false, null, null, null ), + array( false, $this->getMockLanguage(), null, null ), + array( false, $this->getMockLanguage(), true, null ), + array( false, null, true, null ), + array( false, null, null, true ), + array( true, $this->getMockLanguage(), true, true ), + ); + } + + private function getMockLanguage() { + return $this->getMockBuilder( 'Language' ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @dataProvider provideUseMessageCache + * @covers MWException::useMessageCache + */ + public function testUseMessageCache( $expected, $wgLang ) { + $this->setMwGlobals( array( + 'wgLang' => $wgLang, + ) ); + $e = new MWException(); + $this->assertEquals( $expected, $e->useMessageCache() ); + } + + public function provideUseMessageCache() { + return array( + array( false, null ), + array( true, $this->getMockLanguage() ), + ); + } + + /** + * @covers MWException::isLoggable + */ + public function testIsLogable() { + $e = new MWException(); + $this->assertTrue( $e->isLoggable() ); + } + + /** + * @dataProvider provideRunHooks + * @covers MWException::runHooks + */ + public function testRunHooks( $wgExceptionHooks, $name, $args, $expectedReturn ) { + $this->setMwGlobals( array( + 'wgExceptionHooks' => $wgExceptionHooks, + ) ); + $e = new MWException(); + $this->assertEquals( $expectedReturn, $e->runHooks( $name, $args ) ); + } + + public static function provideRunHooks() { + return array( + array( null, null, null, null ), + array( array(), 'name', array(), null ), + array( array( 'name' => false ), 'name', array(), null ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array(), 'YAY.[]' + ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array( 'a' ), 'YAY.{"1":"a"}' + ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array( null ), null + ), + ); + } + + /** + * Used in conjunction with provideRunHooks and testRunHooks as a mock callback for a hook + */ + public static function mockHook() { + $args = func_get_args(); + if ( !$args[0] instanceof MWException ) { + return '$caller not instance of MWException'; + } + unset( $args[0] ); + if ( array_key_exists( 1, $args ) && $args[1] === null ) { + return null; + } + return 'YAY.' . json_encode( $args ); + } + + /** + * @dataProvider provideIsCommandLine + * @covers MWException::isCommandLine + */ + public function testisCommandLine( $expected, $wgCommandLineMode ) { + $this->setMwGlobals( array( + 'wgCommandLineMode' => $wgCommandLineMode, + ) ); + $e = new MWException(); + $this->assertEquals( $expected, $e->isCommandLine() ); + } + + public static function provideIsCommandLine() { + return array( + array( false, null ), + array( true, true ), + ); + } + + /** + * Verify the exception classes are JSON serializabe. + * + * @covers MWExceptionHandler::jsonSerializeException + * @dataProvider provideExceptionClasses + */ + public function testJsonSerializeExceptions( $exception_class ) { + $json = MWExceptionHandler::jsonSerializeException( + new $exception_class() + ); + $this->assertNotEquals( false, $json, + "The $exception_class exception should be JSON serializable, got false." ); + } + + public static function provideExceptionClasses() { + return array( + array( 'Exception' ), + array( 'MWException' ), + ); + } + + /** + * Lame JSON schema validation. + * + * @covers MWExceptionHandler::jsonSerializeException + * + * @param string $expectedKeyType Type expected as returned by gettype() + * @param string $exClass An exception class (ie: Exception, MWException) + * @param string $key Name of the key to validate in the serialized JSON + * @dataProvider provideJsonSerializedKeys + */ + public function testJsonserializeexceptionKeys( $expectedKeyType, $exClass, $key ) { + + # Make sure we log a backtrace: + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => true ) ); + + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new $exClass()) + ); + $this->assertObjectHasAttribute( $key, $json, + "JSON serialized exception is missing key '$key'" + ); + $this->assertInternalType( $expectedKeyType, $json->$key, + "JSON serialized key '$key' has type " . gettype( $json->$key ) + . " (expected: $expectedKeyType)." + ); + } + + /** + * Returns test cases: exception class, key name, gettype() + */ + public static function provideJsonSerializedKeys() { + $testCases = array(); + foreach ( array( 'Exception', 'MWException' ) as $exClass ) { + $exTests = array( + array( 'string', $exClass, 'id' ), + array( 'string', $exClass, 'file' ), + array( 'integer', $exClass, 'line' ), + array( 'string', $exClass, 'message' ), + array( 'null', $exClass, 'url' ), + # Backtrace only enabled with wgLogExceptionBacktrace = true + array( 'array', $exClass, 'backtrace' ), + ); + $testCases = array_merge( $testCases, $exTests ); + } + return $testCases; + } + + /** + * Given wgLogExceptionBacktrace is true + * then serialized exception SHOULD have a backtrace + * + * @covers MWExceptionHandler::jsonSerializeException + */ + public function testJsonserializeexceptionBacktracingEnabled() { + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => true ) ); + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new Exception() ) + ); + $this->assertObjectHasAttribute( 'backtrace', $json ); + } + + /** + * Given wgLogExceptionBacktrace is false + * then serialized exception SHOULD NOT have a backtrace + * + * @covers MWExceptionHandler::jsonSerializeException + */ + public function testJsonserializeexceptionBacktracingDisabled() { + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => false ) ); + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new Exception() ) + ); + $this->assertObjectNotHasAttribute( 'backtrace', $json ); + + } + +} diff --git a/tests/phpunit/includes/exception/ReadOnlyErrorTest.php b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php new file mode 100644 index 00000000..6f6aba47 --- /dev/null +++ b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php @@ -0,0 +1,16 @@ +assertEquals( 'readonly', $e->title ); + $this->assertEquals( 'readonlytext', $e->msg ); + $this->assertEquals( wfReadOnlyReason() ?: array(), $e->params ); + } + +} diff --git a/tests/phpunit/includes/exception/ThrottledErrorTest.php b/tests/phpunit/includes/exception/ThrottledErrorTest.php new file mode 100644 index 00000000..bdb143fa --- /dev/null +++ b/tests/phpunit/includes/exception/ThrottledErrorTest.php @@ -0,0 +1,44 @@ +wgOut = clone $wgOut; + } + + protected function tearDown() { + parent::tearDown(); + global $wgOut; + $wgOut = $this->wgOut; + } + + public function testExceptionSetsStatusCode() { + global $wgOut; + $wgOut = $this->getMockWgOut(); + try { + throw new ThrottledError(); + } catch ( ThrottledError $e ) { + $e->report(); + $this->assertTrue( true ); + } + } + + private function getMockWgOut() { + $mock = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->once() ) + ->method( 'setStatusCode' ) + ->with( 429 ); + return $mock; + } + +} diff --git a/tests/phpunit/includes/exception/UserNotLoggedInTest.php b/tests/phpunit/includes/exception/UserNotLoggedInTest.php new file mode 100644 index 00000000..591a0fa1 --- /dev/null +++ b/tests/phpunit/includes/exception/UserNotLoggedInTest.php @@ -0,0 +1,16 @@ +assertEquals( 'exception-nologin', $e->title ); + $this->assertEquals( 'exception-nologin-text', $e->msg ); + $this->assertEquals( array(), $e->params ); + } + +} diff --git a/tests/phpunit/includes/filebackend/FileBackendTest.php b/tests/phpunit/includes/filebackend/FileBackendTest.php new file mode 100644 index 00000000..9558cc7d --- /dev/null +++ b/tests/phpunit/includes/filebackend/FileBackendTest.php @@ -0,0 +1,2472 @@ +getCliArg( 'use-filebackend' ) ) { + if ( self::$backendToUse ) { + $this->singleBackend = self::$backendToUse; + } else { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + break; + } + } + $useConfig['name'] = 'localtesting'; // swap name + $useConfig['shardViaHashLevels'] = array( // test sharding + 'unittest-cont1' => array( 'levels' => 1, 'base' => 16, 'repeat' => 1 ) + ); + if ( isset( $useConfig['fileJournal'] ) ) { + $useConfig['fileJournal'] = FileJournal::factory( $useConfig['fileJournal'], $name ); + } + $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] ); + $class = $useConfig['class']; + self::$backendToUse = new $class( $useConfig ); + $this->singleBackend = self::$backendToUse; + } + } else { + $this->singleBackend = new FSFileBackend( array( + 'name' => 'localtesting', + 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ), + 'wikiId' => wfWikiID(), + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtesting-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtesting-cont2" ) + ) ); + } + $this->multiBackend = new FileBackendMultiWrite( array( + 'name' => 'localtesting', + 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ), + 'parallelize' => 'implicit', + 'wikiId' => wfWikiId() . $uniqueId, + 'backends' => array( + array( + 'name' => 'localmultitesting1', + 'class' => 'FSFileBackend', + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtestingmulti1-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtestingmulti1-cont2" ), + 'isMultiMaster' => false + ), + array( + 'name' => 'localmultitesting2', + 'class' => 'FSFileBackend', + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtestingmulti2-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtestingmulti2-cont2" ), + 'isMultiMaster' => true + ) + ) + ) ); + $this->filesToPrune = array(); + } + + private static function baseStorePath() { + return 'mwstore://localtesting'; + } + + private function backendClass() { + return get_class( $this->backend ); + } + + /** + * @dataProvider provider_testIsStoragePath + * @covers FileBackend::isStoragePath + */ + public function testIsStoragePath( $path, $isStorePath ) { + $this->assertEquals( $isStorePath, FileBackend::isStoragePath( $path ), + "FileBackend::isStoragePath on path '$path'" ); + } + + public static function provider_testIsStoragePath() { + return array( + array( 'mwstore://', true ), + array( 'mwstore://backend', true ), + array( 'mwstore://backend/container', true ), + array( 'mwstore://backend/container/', true ), + array( 'mwstore://backend/container/path', true ), + array( 'mwstore://backend//container/', true ), + array( 'mwstore://backend//container//', true ), + array( 'mwstore://backend//container//path', true ), + array( 'mwstore:///', true ), + array( 'mwstore:/', false ), + array( 'mwstore:', false ), + ); + } + + /** + * @dataProvider provider_testSplitStoragePath + * @covers FileBackend::splitStoragePath + */ + public function testSplitStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::splitStoragePath( $path ), + "FileBackend::splitStoragePath on path '$path'" ); + } + + public static function provider_testSplitStoragePath() { + return array( + array( 'mwstore://backend/container', array( 'backend', 'container', '' ) ), + array( 'mwstore://backend/container/', array( 'backend', 'container', '' ) ), + array( 'mwstore://backend/container/path', array( 'backend', 'container', 'path' ) ), + array( 'mwstore://backend/container//path', array( 'backend', 'container', '/path' ) ), + array( 'mwstore://backend//container/path', array( null, null, null ) ), + array( 'mwstore://backend//container//path', array( null, null, null ) ), + array( 'mwstore://', array( null, null, null ) ), + array( 'mwstore://backend', array( null, null, null ) ), + array( 'mwstore:///', array( null, null, null ) ), + array( 'mwstore:/', array( null, null, null ) ), + array( 'mwstore:', array( null, null, null ) ) + ); + } + + /** + * @dataProvider provider_normalizeStoragePath + * @covers FileBackend::normalizeStoragePath + */ + public function testNormalizeStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::normalizeStoragePath( $path ), + "FileBackend::normalizeStoragePath on path '$path'" ); + } + + public static function provider_normalizeStoragePath() { + return array( + array( 'mwstore://backend/container', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/path', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container//path', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container///path', 'mwstore://backend/container/path' ), + array( + 'mwstore://backend/container///path//to///obj', + 'mwstore://backend/container/path/to/obj' + ), + array( 'mwstore://', null ), + array( 'mwstore://backend', null ), + array( 'mwstore://backend//container/path', null ), + array( 'mwstore://backend//container//path', null ), + array( 'mwstore:///', null ), + array( 'mwstore:/', null ), + array( 'mwstore:', null ), + ); + } + + /** + * @dataProvider provider_testParentStoragePath + * @covers FileBackend::parentStoragePath + */ + public function testParentStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::parentStoragePath( $path ), + "FileBackend::parentStoragePath on path '$path'" ); + } + + public static function provider_testParentStoragePath() { + return array( + array( 'mwstore://backend/container/path/to/obj', 'mwstore://backend/container/path/to' ), + array( 'mwstore://backend/container/path/to', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container/path', 'mwstore://backend/container' ), + array( 'mwstore://backend/container', null ), + array( 'mwstore://backend/container/path/to/obj/', 'mwstore://backend/container/path/to' ), + array( 'mwstore://backend/container/path/to/', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container/path/', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/', null ), + ); + } + + /** + * @dataProvider provider_testExtensionFromPath + * @covers FileBackend::extensionFromPath + */ + public function testExtensionFromPath( $path, $res ) { + $this->assertEquals( $res, FileBackend::extensionFromPath( $path ), + "FileBackend::extensionFromPath on path '$path'" ); + } + + public static function provider_testExtensionFromPath() { + return array( + array( 'mwstore://backend/container/path.txt', 'txt' ), + array( 'mwstore://backend/container/path.svg.png', 'png' ), + array( 'mwstore://backend/container/path', '' ), + array( 'mwstore://backend/container/path.', '' ), + ); + } + + /** + * @dataProvider provider_testStore + */ + public function testStore( $op ) { + $this->filesToPrune[] = $op['src']; + + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStore( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStore( $op ); + $this->filesToPrune[] = $op['src']; # avoid file leaking + $this->tearDownFiles(); + } + + /** + * @covers FileBackend::doOperation + */ + private function doTestStore( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + file_put_contents( $source, "Unit test file" ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->store( $op ); + } + + $status = $this->backend->doOperation( $op ); + + $this->assertGoodStatus( $status, + "Store from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Store from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Store from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( true, file_exists( $source ), + "Source file $source still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists ($backendName)." ); + + $this->assertEquals( filesize( $source ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = FSFile::getPropsFromPath( $source ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( $props1, $props2, + "Source and destination have the same props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $dest ) ); + } + + public static function provider_testStore() { + $cases = array(); + + $tmpName = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + $toPath = self::baseStorePath() . '/unittest-cont1/e/fun/obj1.txt'; + $op = array( 'op' => 'store', 'src' => $tmpName, 'dst' => $toPath ); + $cases[] = array( + $op, // operation + $tmpName, // source + $toPath, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $tmpName, // source + $toPath, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $tmpName, // source + $toPath, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testCopy + * @covers FileBackend::doOperation + */ + public function testCopy( $op ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestCopy( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestCopy( $op ); + $this->tearDownFiles(); + } + + private function doTestCopy( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + if ( isset( $op['ignoreMissingSource'] ) ) { + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest does not exist ($backendName)." ); + + return; // done + } + + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->copy( $op ); + } + + $status = $this->backend->doOperation( $op ); + + $this->assertGoodStatus( $status, + "Copy from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Copy from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Copy from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after copy ($backendName)." ); + + $this->assertEquals( + $this->backend->getFileSize( array( 'src' => $source ) ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( $props1, $props2, + "Source and destination have the same props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source, $dest ) ); + } + + public static function provider_testCopy() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/file.txt'; + $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt'; + + $op = array( 'op' => 'copy', 'src' => $source, 'dst' => $dest ); + $cases[] = array( + $op, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source + $dest, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testMove + * @covers FileBackend::doOperation + */ + public function testMove( $op ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestMove( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestMove( $op ); + $this->tearDownFiles(); + } + + private function doTestMove( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + if ( isset( $op['ignoreMissingSource'] ) ) { + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest does not exist ($backendName)." ); + + return; // done + } + + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->copy( $op ); + } + + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Move from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after move ($backendName)." ); + + $this->assertNotEquals( + $this->backend->getFileSize( array( 'src' => $source ) ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( false, $props1['fileExists'], + "Source file does not exist accourding to props ($backendName)." ); + $this->assertEquals( true, $props2['fileExists'], + "Destination file exists accourding to props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source, $dest ) ); + } + + public static function provider_testMove() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/file.txt'; + $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt'; + + $op = array( 'op' => 'move', 'src' => $source, 'dst' => $dest ); + $cases[] = array( + $op, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source + $dest, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testDelete + * @covers FileBackend::doOperation + */ + public function testDelete( $op, $withSource, $okStatus ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDelete( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDelete( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + } + + private function doTestDelete( $op, $withSource, $okStatus ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + + if ( $withSource ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Deletion of file at $source succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Deletion of file at $source succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Deletion of file at $source has proper 'success' field in Status ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Deletion of file at $source failed ($backendName)." ); + } + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist after move ($backendName)." ); + + $this->assertFalse( + $this->backend->getFileSize( array( 'src' => $source ) ), + "Source file $source has correct size (false) ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $this->assertFalse( $props1['fileExists'], + "Source file $source does not exist according to props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source ) ); + } + + public static function provider_testDelete() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt'; + + $op = array( 'op' => 'delete', 'src' => $source ); + $cases[] = array( + $op, // operation + true, // with source + true // succeeds + ); + + $cases[] = array( + $op, // operation + false, // without source + false // fails + ); + + $op['ignoreMissingSource'] = true; + $cases[] = array( + $op, // operation + false, // without source + true // succeeds + ); + + $op['ignoreMissingSource'] = true; + $op['src'] = self::baseStorePath() . '/unittest-cont-bad/e/file.txt'; + $cases[] = array( + $op, // operation + false, // without source + true // succeeds + ); + + return $cases; + } + + /** + * @dataProvider provider_testDescribe + * @covers FileBackend::doOperation + */ + public function testDescribe( $op, $withSource, $okStatus ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDescribe( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDescribe( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + } + + private function doTestDescribe( $op, $withSource, $okStatus ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + + if ( $withSource ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source, + 'headers' => array( 'Content-Disposition' => 'xxx' ) ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertHasHeaders( array( 'Content-Disposition' => 'xxx' ), $attr ); + } + + $status = $this->backend->describe( array( 'src' => $source, + 'headers' => array( 'Content-Disposition' => '' ) ) ); // remove + $this->assertGoodStatus( $status, + "Removal of header for $source succeeded ($backendName)." ); + + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertFalse( isset( $attr['headers']['content-disposition'] ), + "File 'Content-Disposition' header removed." ); + } + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Describe of file at $source succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Describe of file at $source succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Describe of file at $source has proper 'success' field in Status ($backendName)." ); + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertHasHeaders( $op['headers'], $attr ); + } + } else { + $this->assertEquals( false, $status->isOK(), + "Describe of file at $source failed ($backendName)." ); + } + + $this->assertBackendPathsConsistent( array( $source ) ); + } + + private function assertHasHeaders( array $headers, array $attr ) { + foreach ( $headers as $n => $v ) { + if ( $n !== '' ) { + $this->assertTrue( isset( $attr['headers'][strtolower( $n )] ), + "File has '$n' header." ); + $this->assertEquals( $v, $attr['headers'][strtolower( $n )], + "File has '$n' header value." ); + } else { + $this->assertFalse( isset( $attr['headers'][strtolower( $n )] ), + "File does not have '$n' header." ); + } + } + } + + public static function provider_testDescribe() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt'; + + $op = array( 'op' => 'describe', 'src' => $source, + 'headers' => array( 'Content-Disposition' => 'inline' ), ); + $cases[] = array( + $op, // operation + true, // with source + true // succeeds + ); + + $cases[] = array( + $op, // operation + false, // without source + false // fails + ); + + return $cases; + } + + /** + * @dataProvider provider_testCreate + * @covers FileBackend::doOperation + */ + public function testCreate( $op, $alreadyExists, $okStatus, $newSize ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize ); + $this->tearDownFiles(); + } + + private function doTestCreate( $op, $alreadyExists, $okStatus, $newSize ) { + $backendName = $this->backendClass(); + + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + $oldText = 'blah...blah...waahwaah'; + if ( $alreadyExists ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $oldText, 'dst' => $dest ) ); + $this->assertGoodStatus( $status, + "Creation of file at $dest succeeded ($backendName)." ); + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Creation of file at $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of file at $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Creation of file at $dest has proper 'success' field in Status ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Creation of file at $dest failed ($backendName)." ); + } + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after creation ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( true, $props1['fileExists'], + "Destination file $dest exists according to props ($backendName)." ); + if ( $okStatus ) { // file content is what we saved + $this->assertEquals( $newSize, $props1['size'], + "Destination file $dest has expected size according to props ($backendName)." ); + $this->assertEquals( $newSize, + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + } else { // file content is some other previous text + $this->assertEquals( strlen( $oldText ), $props1['size'], + "Destination file $dest has original size according to props ($backendName)." ); + $this->assertEquals( strlen( $oldText ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has original size according to props ($backendName)." ); + } + + $this->assertBackendPathsConsistent( array( $dest ) ); + } + + /** + * @dataProvider provider_testCreate + */ + public static function provider_testCreate() { + $cases = array(); + + $dest = self::baseStorePath() . '/unittest-cont2/a/myspacefile.txt'; + + $op = array( 'op' => 'create', 'content' => 'test test testing', 'dst' => $dest ); + $cases[] = array( + $op, // operation + false, // no dest already exists + true, // succeeds + strlen( $op['content'] ) + ); + + $op2 = $op; + $op2['content'] = "\n"; + $cases[] = array( + $op2, // operation + false, // no dest already exists + true, // succeeds + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "fsf\n waf 3kt"; + $cases[] = array( + $op2, // operation + true, // dest already exists + false, // fails + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "egm'g gkpe gpqg eqwgwqg"; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + true, // dest already exists + true, // succeeds + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "39qjmg3-qg"; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + true, // dest already exists + false, // succeeds + strlen( $op2['content'] ) + ); + + return $cases; + } + + /** + * @covers FileBackend::doQuickOperations + */ + public function testDoQuickOperations() { + $this->backend = $this->singleBackend; + $this->doTestDoQuickOperations(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestDoQuickOperations(); + $this->tearDownFiles(); + } + + private function doTestDoQuickOperations() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $files = array( + "$base/unittest-cont1/e/fileA.a", + "$base/unittest-cont1/e/fileB.a", + "$base/unittest-cont1/e/fileC.a" + ); + $createOps = array(); + $purgeOps = array(); + foreach ( $files as $path ) { + $status = $this->prepare( array( 'dir' => dirname( $path ) ) ); + $this->assertGoodStatus( $status, + "Preparing $path succeeded without warnings ($backendName)." ); + $createOps[] = array( 'op' => 'create', 'dst' => $path, 'content' => mt_rand( 0, 50000 ) ); + $copyOps[] = array( 'op' => 'copy', 'src' => $path, 'dst' => "$path-2" ); + $moveOps[] = array( 'op' => 'move', 'src' => "$path-2", 'dst' => "$path-3" ); + $purgeOps[] = array( 'op' => 'delete', 'src' => $path ); + $purgeOps[] = array( 'op' => 'delete', 'src' => "$path-3" ); + } + $purgeOps[] = array( 'op' => 'null' ); + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $createOps ), + "Creation of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => $file ) ), + "File $file exists." ); + } + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $copyOps ), + "Quick copy of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => "$file-2" ) ), + "File $file-2 exists." ); + } + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $moveOps ), + "Quick move of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => "$file-3" ) ), + "File $file-3 move in." ); + $this->assertFalse( $this->backend->fileExists( array( 'src' => "$file-2" ) ), + "File $file-2 moved away." ); + } + + $this->assertGoodStatus( + $this->backend->quickCopy( array( 'src' => $files[0], 'dst' => $files[0] ) ), + "Copy of file {$files[0]} over itself succeeded ($backendName)." ); + $this->assertTrue( $this->backend->fileExists( array( 'src' => $files[0] ) ), + "File {$files[0]} still exists." ); + + $this->assertGoodStatus( + $this->backend->quickMove( array( 'src' => $files[0], 'dst' => $files[0] ) ), + "Move of file {$files[0]} over itself succeeded ($backendName)." ); + $this->assertTrue( $this->backend->fileExists( array( 'src' => $files[0] ) ), + "File {$files[0]} still exists." ); + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $purgeOps ), + "Quick deletion of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertFalse( $this->backend->fileExists( array( 'src' => $file ) ), + "File $file purged." ); + $this->assertFalse( $this->backend->fileExists( array( 'src' => "$file-3" ) ), + "File $file-3 purged." ); + } + } + + /** + * @dataProvider provider_testConcatenate + */ + public function testConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ) { + $this->filesToPrune[] = $op['dst']; + + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ); + $this->filesToPrune[] = $op['dst']; # avoid file leaking + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ); + $this->filesToPrune[] = $op['dst']; # avoid file leaking + $this->tearDownFiles(); + } + + private function doTestConcatenate( $params, $srcs, $srcsContent, $alreadyExists, $okStatus ) { + $backendName = $this->backendClass(); + + $expContent = ''; + // Create sources + $ops = array(); + foreach ( $srcs as $i => $source ) { + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $ops[] = array( + 'op' => 'create', // operation + 'dst' => $source, // source + 'content' => $srcsContent[$i] + ); + $expContent .= $srcsContent[$i]; + } + $status = $this->backend->doOperations( $ops ); + + $this->assertGoodStatus( $status, + "Creation of source files succeeded ($backendName)." ); + + $dest = $params['dst']; + if ( $alreadyExists ) { + $ok = file_put_contents( $dest, 'blah...blah...waahwaah' ) !== false; + $this->assertEquals( true, $ok, + "Creation of file at $dest succeeded ($backendName)." ); + } else { + $ok = file_put_contents( $dest, '' ) !== false; + $this->assertEquals( true, $ok, + "Creation of 0-byte file at $dest succeeded ($backendName)." ); + } + + // Combine the files into one + $status = $this->backend->concatenate( $params ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Creation of concat file at $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of concat file at $dest succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Creation of concat file at $dest failed ($backendName)." ); + } + + if ( $okStatus ) { + $this->assertEquals( true, is_file( $dest ), + "Dest concat file $dest exists after creation ($backendName)." ); + } else { + $this->assertEquals( true, is_file( $dest ), + "Dest concat file $dest exists after failed creation ($backendName)." ); + } + + $contents = file_get_contents( $dest ); + $this->assertNotEquals( false, $contents, "File at $dest exists ($backendName)." ); + + if ( $okStatus ) { + $this->assertEquals( $expContent, $contents, + "Concat file at $dest has correct contents ($backendName)." ); + } else { + $this->assertNotEquals( $expContent, $contents, + "Concat file at $dest has correct contents ($backendName)." ); + } + } + + public static function provider_testConcatenate() { + $cases = array(); + + $rand = mt_rand( 0, 2000000000 ) . time(); + $dest = wfTempDir() . "/randomfile!$rand.txt"; + $srcs = array( + self::baseStorePath() . '/unittest-cont1/e/file1.txt', + self::baseStorePath() . '/unittest-cont1/e/file2.txt', + self::baseStorePath() . '/unittest-cont1/e/file3.txt', + self::baseStorePath() . '/unittest-cont1/e/file4.txt', + self::baseStorePath() . '/unittest-cont1/e/file5.txt', + self::baseStorePath() . '/unittest-cont1/e/file6.txt', + self::baseStorePath() . '/unittest-cont1/e/file7.txt', + self::baseStorePath() . '/unittest-cont1/e/file8.txt', + self::baseStorePath() . '/unittest-cont1/e/file9.txt', + self::baseStorePath() . '/unittest-cont1/e/file10.txt' + ); + $content = array( + 'egfage', + 'ageageag', + 'rhokohlr', + 'shgmslkg', + 'kenga', + 'owagmal', + 'kgmae', + 'g eak;g', + 'lkaem;a', + 'legma' + ); + $params = array( 'srcs' => $srcs, 'dst' => $dest ); + + $cases[] = array( + $params, // operation + $srcs, // sources + $content, // content for each source + false, // no dest already exists + true, // succeeds + ); + + $cases[] = array( + $params, // operation + $srcs, // sources + $content, // content for each source + true, // dest already exists + false, // succeeds + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileStat + * @covers FileBackend::getFileStat + */ + public function testGetFileStat( $path, $content, $alreadyExists ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileStat( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileStat( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + } + + private function doTestGetFileStat( $path, $content, $alreadyExists ) { + $backendName = $this->backendClass(); + + if ( $alreadyExists ) { + $this->prepare( array( 'dir' => dirname( $path ) ) ); + $status = $this->create( array( 'dst' => $path, 'content' => $content ) ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + $time = $this->backend->getFileTimestamp( array( 'src' => $path ) ); + $stat = $this->backend->getFileStat( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10, + "Correct file timestamp of '$path'" ); + + $size = $stat['size']; + $time = $stat['mtime']; + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10, + "Correct file timestamp of '$path'" ); + + $this->backend->clearCache( array( $path ) ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + + $this->backend->preloadCache( array( $path ) ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + } else { + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + $time = $this->backend->getFileTimestamp( array( 'src' => $path ) ); + $stat = $this->backend->getFileStat( array( 'src' => $path ) ); + + $this->assertFalse( $size, "Correct file size of '$path'" ); + $this->assertFalse( $time, "Correct file timestamp of '$path'" ); + $this->assertFalse( $stat, "Correct file stat of '$path'" ); + } + } + + public static function provider_testGetFileStat() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents", true ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", "", true ); + $cases[] = array( "$base/unittest-cont1/e/b/some-diff_file.txt", null, false ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileStat + * @covers FileBackend::streamFile + */ + public function testStreamFile( $path, $content, $alreadyExists ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStreamFile( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + } + + private function doTestStreamFile( $path, $content ) { + $backendName = $this->backendClass(); + + // Test doStreamFile() directly to avoid header madness + $class = new ReflectionClass( $this->backend ); + $method = $class->getMethod( 'doStreamFile' ); + $method->setAccessible( true ); + + if ( $content !== null ) { + $this->prepare( array( 'dir' => dirname( $path ) ) ); + $status = $this->create( array( 'dst' => $path, 'content' => $content ) ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + ob_start(); + $method->invokeArgs( $this->backend, array( array( 'src' => $path ) ) ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( $content, $data, "Correct content streamed from '$path'" ); + } else { // 404 case + ob_start(); + $method->invokeArgs( $this->backend, array( array( 'src' => $path ) ) ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( '', $data, "Correct content streamed from '$path' ($backendName)" ); + } + } + + public static function provider_testStreamFile() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", null ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileContents + * @covers FileBackend::getFileContents + * @covers FileBackend::getFileContentsMulti + */ + public function testGetFileContents( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileContents( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileContents( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetFileContents( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $contents = $this->backend->getFileContentsMulti( array( 'srcs' => $source ) ); + foreach ( $contents as $path => $data ) { + $this->assertNotEquals( false, $data, "Contents of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $data, + "Contents of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $contents ), + "Contents in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $contents ), + "Contents array size correct ($backendName)." + ); + } else { + $data = $this->backend->getFileContents( array( 'src' => $source ) ); + $this->assertNotEquals( false, $data, "Contents of $source exists ($backendName)." ); + $this->assertEquals( $content[0], $data, "Contents of $source is correct ($backendName)." ); + } + } + + public static function provider_testGetFileContents() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", "more file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx", "contents xy", "contents xz" ) + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetLocalCopy + * @covers FileBackend::getLocalCopy + */ + public function testGetLocalCopy( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopy( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopy( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetLocalCopy( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $tmpFiles = $this->backend->getLocalCopyMulti( array( 'srcs' => $source ) ); + foreach ( $tmpFiles as $path => $tmpFile ) { + $this->assertNotNull( $tmpFile, + "Creation of local copy of $path succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local copy of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $contents, + "Local copy of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $tmpFiles ), + "Local copies in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $tmpFiles ), + "Local copies array size correct ($backendName)." + ); + } else { + $tmpFile = $this->backend->getLocalCopy( array( 'src' => $source ) ); + $this->assertNotNull( $tmpFile, + "Creation of local copy of $source succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local copy of $source exists ($backendName)." ); + $this->assertEquals( + $content[0], + $contents, + "Local copy of $source is correct ($backendName)." + ); + } + + $obj = new stdClass(); + $tmpFile->bind( $obj ); + } + + public static function provider_testGetLocalCopy() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx $", "contents xy 111", "contents xz" ) + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetLocalReference + * @covers FileBackend::getLocalReference + */ + public function testGetLocalReference( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalReference( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalReference( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetLocalReference( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $tmpFiles = $this->backend->getLocalReferenceMulti( array( 'srcs' => $source ) ); + foreach ( $tmpFiles as $path => $tmpFile ) { + $this->assertNotNull( $tmpFile, + "Creation of local copy of $path succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local ref of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $contents, + "Local ref of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $tmpFiles ), + "Local refs in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $tmpFiles ), + "Local refs array size correct ($backendName)." + ); + } else { + $tmpFile = $this->backend->getLocalReference( array( 'src' => $source ) ); + $this->assertNotNull( $tmpFile, + "Creation of local copy of $source succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local ref of $source exists ($backendName)." ); + $this->assertEquals( $content[0], $contents, "Local ref of $source is correct ($backendName)." ); + } + } + + public static function provider_testGetLocalReference() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx 1111", "contents xy %", "contents xz $" ) + ); + + return $cases; + } + + /** + * @covers FileBackend::getLocalCopy + * @covers FileBackend::getLocalReference + */ + public function testGetLocalCopyAndReference404() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopyAndReference404(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopyAndReference404(); + $this->tearDownFiles(); + } + + public function doTestGetLocalCopyAndReference404() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + + $tmpFile = $this->backend->getLocalCopy( array( + 'src' => "$base/unittest-cont1/not-there" ) ); + $this->assertEquals( null, $tmpFile, "Local copy of not existing file is null ($backendName)." ); + + $tmpFile = $this->backend->getLocalReference( array( + 'src' => "$base/unittest-cont1/not-there" ) ); + $this->assertEquals( null, $tmpFile, "Local ref of not existing file is null ($backendName)." ); + } + + /** + * @dataProvider provider_testGetFileHttpUrl + * @covers FileBackend::getFileHttpUrl + */ + public function testGetFileHttpUrl( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileHttpUrl( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileHttpUrl( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetFileHttpUrl( $source, $content ) { + $backendName = $this->backendClass(); + + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content, 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + $url = $this->backend->getFileHttpUrl( array( 'src' => $source ) ); + + if ( $url !== null ) { // supported + $data = Http::request( "GET", $url ); + $this->assertEquals( $content, $data, + "HTTP GET of URL has right contents ($backendName)." ); + } + } + + public static function provider_testGetFileHttpUrl() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + + return $cases; + } + + /** + * @dataProvider provider_testPrepareAndClean + * @covers FileBackend::prepare + * @covers FileBackend::clean + */ + public function testPrepareAndClean( $path, $isOK ) { + $this->backend = $this->singleBackend; + $this->doTestPrepareAndClean( $path, $isOK ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestPrepareAndClean( $path, $isOK ); + $this->tearDownFiles(); + } + + public static function provider_testPrepareAndClean() { + $base = self::baseStorePath(); + + return array( + array( "$base/unittest-cont1/e/a/z/some_file1.txt", true ), + array( "$base/unittest-cont2/a/z/some_file2.txt", true ), + # Specific to FS backend with no basePath field set + #array( "$base/unittest-cont3/a/z/some_file3.txt", false ), + ); + } + + private function doTestPrepareAndClean( $path, $isOK ) { + $backendName = $this->backendClass(); + + $status = $this->prepare( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Preparing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Preparing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Preparing dir $path failed ($backendName)." ); + } + + $status = $this->backend->secure( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Securing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Securing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Securing dir $path failed ($backendName)." ); + } + + $status = $this->backend->publish( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Publishing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Publishing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Publishing dir $path failed ($backendName)." ); + } + + $status = $this->backend->clean( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Cleaning dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Cleaning dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Cleaning dir $path failed ($backendName)." ); + } + } + + public function testRecursiveClean() { + $this->backend = $this->singleBackend; + $this->doTestRecursiveClean(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestRecursiveClean(); + $this->tearDownFiles(); + } + + /** + * @covers FileBackend::clean + */ + private function doTestRecursiveClean() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $dirs = array( + "$base/unittest-cont1", + "$base/unittest-cont1/e", + "$base/unittest-cont1/e/a", + "$base/unittest-cont1/e/a/b", + "$base/unittest-cont1/e/a/b/c", + "$base/unittest-cont1/e/a/b/c/d0", + "$base/unittest-cont1/e/a/b/c/d1", + "$base/unittest-cont1/e/a/b/c/d2", + "$base/unittest-cont1/e/a/b/c/d0/1", + "$base/unittest-cont1/e/a/b/c/d0/2", + "$base/unittest-cont1/e/a/b/c/d1/3", + "$base/unittest-cont1/e/a/b/c/d1/4", + "$base/unittest-cont1/e/a/b/c/d2/5", + "$base/unittest-cont1/e/a/b/c/d2/6" + ); + foreach ( $dirs as $dir ) { + $status = $this->prepare( array( 'dir' => $dir ) ); + $this->assertGoodStatus( $status, + "Preparing dir $dir succeeded without warnings ($backendName)." ); + } + + if ( $this->backend instanceof FSFileBackend ) { + foreach ( $dirs as $dir ) { + $this->assertEquals( true, $this->backend->directoryExists( array( 'dir' => $dir ) ), + "Dir $dir exists ($backendName)." ); + } + } + + $status = $this->backend->clean( + array( 'dir' => "$base/unittest-cont1", 'recursive' => 1 ) ); + $this->assertGoodStatus( $status, + "Recursive cleaning of dir $dir succeeded without warnings ($backendName)." ); + + foreach ( $dirs as $dir ) { + $this->assertEquals( false, $this->backend->directoryExists( array( 'dir' => $dir ) ), + "Dir $dir no longer exists ($backendName)." ); + } + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperations() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperations(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperations(); + $this->tearDownFiles(); + } + + private function doTestDoOperations() { + $base = self::baseStorePath(); + + $fileA = "$base/unittest-cont1/e/a/b/fileA.txt"; + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileB = "$base/unittest-cont1/e/a/b/fileB.txt"; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileC = "$base/unittest-cont1/e/a/b/fileC.txt"; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + $fileD = "$base/unittest-cont1/e/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->create( array( 'dst' => $fileB, 'content' => $fileBContents ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->create( array( 'dst' => $fileC, 'content' => $fileCContents ) ); + $this->prepare( array( 'dir' => dirname( $fileD ) ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'describe', 'src' => $fileA, + 'headers' => array( 'X-Content-Length' => '91.3' ), 'disposition' => 'inline' ), + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: (file:) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'null' ), + // Does nothing + ) ); + + $this->assertGoodStatus( $status, "Operation batch succeeded" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 14, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileC ) ), + "Correct file contents of $fileC" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileC ) ), + "Correct file size of $fileC" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileC ) ), + "Correct file SHA-1 of $fileC" ); + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperationsPipeline() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsPipeline(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsPipeline(); + $this->tearDownFiles(); + } + + // concurrency orientated + private function doTestDoOperationsPipeline() { + $base = self::baseStorePath(); + + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + + $tmpNameA = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameA, $fileAContents ); + $tmpNameB = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameB, $fileBContents ); + $tmpNameC = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameC, $fileCContents ); + + $this->filesToPrune[] = $tmpNameA; # avoid file leaking + $this->filesToPrune[] = $tmpNameB; # avoid file leaking + $this->filesToPrune[] = $tmpNameC; # avoid file leaking + + $fileA = "$base/unittest-cont1/e/a/b/fileA.txt"; + $fileB = "$base/unittest-cont1/e/a/b/fileB.txt"; + $fileC = "$base/unittest-cont1/e/a/b/fileC.txt"; + $fileD = "$base/unittest-cont1/e/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->prepare( array( 'dir' => dirname( $fileD ) ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'store', 'src' => $tmpNameA, 'dst' => $fileA, 'overwriteSame' => 1 ), + array( 'op' => 'store', 'src' => $tmpNameB, 'dst' => $fileB, 'overwrite' => 1 ), + array( 'op' => 'store', 'src' => $tmpNameC, 'dst' => $fileC, 'overwrite' => 1 ), + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: (file:) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'null' ), + // Does nothing + ) ); + + $this->assertGoodStatus( $status, "Operation batch succeeded" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 16, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileC ) ), + "Correct file contents of $fileC" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileC ) ), + "Correct file size of $fileC" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileC ) ), + "Correct file SHA-1 of $fileC" ); + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperationsFailing() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsFailing(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsFailing(); + $this->tearDownFiles(); + } + + private function doTestDoOperationsFailing() { + $base = self::baseStorePath(); + + $fileA = "$base/unittest-cont2/a/b/fileA.txt"; + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileB = "$base/unittest-cont2/a/b/fileB.txt"; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileC = "$base/unittest-cont2/a/b/fileC.txt"; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + $fileD = "$base/unittest-cont2/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->create( array( 'dst' => $fileB, 'content' => $fileBContents ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->create( array( 'dst' => $fileC, 'content' => $fileCContents ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: (file:) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'copy', 'src' => $fileB, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD ), + // Now: A:, B:, C:, D: (failed) + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:, B:, C:, D: (failed) + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:, B:, C:, D: + array( 'op' => 'delete', 'src' => $fileD ), + // Now: A:, B:, C:, D: + array( 'op' => 'null' ), + // Does nothing + ), array( 'force' => 1 ) ); + + $this->assertNotEquals( array(), $status->errors, "Operation had warnings" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 8, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileA ) ), + "Correct file contents of $fileA" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileA ) ), + "Correct file size of $fileA" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileA ) ), + "Correct file SHA-1 of $fileA" ); + } + + /** + * @covers FileBackend::getFileList + */ + public function testGetFileList() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileList(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileList(); + $this->tearDownFiles(); + } + + private function doTestGetFileList() { + $backendName = $this->backendClass(); + $base = self::baseStorePath(); + + // Should have no errors + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont-notexists" ) ); + + $files = array( + "$base/unittest-cont1/e/test1.txt", + "$base/unittest-cont1/e/test2.txt", + "$base/unittest-cont1/e/test3.txt", + "$base/unittest-cont1/e/subdir1/test1.txt", + "$base/unittest-cont1/e/subdir1/test2.txt", + "$base/unittest-cont1/e/subdir2/test3.txt", + "$base/unittest-cont1/e/subdir2/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test1.txt", + "$base/unittest-cont1/e/subdir2/subdir/test2.txt", + "$base/unittest-cont1/e/subdir2/subdir/test3.txt", + "$base/unittest-cont1/e/subdir2/subdir/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test5.txt", + "$base/unittest-cont1/e/subdir2/subdir/sub/test0.txt", + "$base/unittest-cont1/e/subdir2/subdir/sub/120-px-file.txt", + ); + + // Add the files + $ops = array(); + foreach ( $files as $file ) { + $this->prepare( array( 'dir' => dirname( $file ) ) ); + $ops[] = array( 'op' => 'create', 'content' => 'xxy', 'dst' => $file ); + } + $status = $this->backend->doQuickOperations( $ops ); + $this->assertGoodStatus( $status, + "Creation of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of files succeeded with OK status ($backendName)." ); + + // Expected listing at root + $expected = array( + "e/test1.txt", + "e/test2.txt", + "e/test3.txt", + "e/subdir1/test1.txt", + "e/subdir1/test2.txt", + "e/subdir2/test3.txt", + "e/subdir2/test4.txt", + "e/subdir2/subdir/test1.txt", + "e/subdir2/subdir/test2.txt", + "e/subdir2/subdir/test3.txt", + "e/subdir2/subdir/test4.txt", + "e/subdir2/subdir/test5.txt", + "e/subdir2/subdir/sub/test0.txt", + "e/subdir2/subdir/sub/120-px-file.txt", + ); + sort( $expected ); + + // Actual listing (no trailing slash) at root + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (no trailing slash) at root with advise + $iter = $this->backend->getFileList( array( + 'dir' => "$base/unittest-cont1", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (with trailing slash) at root + $list = array(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Expected listing at subdir + $expected = array( + "test1.txt", + "test2.txt", + "test3.txt", + "test4.txt", + "test5.txt", + "sub/test0.txt", + "sub/120-px-file.txt", + ); + sort( $expected ); + + // Actual listing (no trailing slash) at subdir + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (no trailing slash) at subdir with advise + $iter = $this->backend->getFileList( array( + 'dir' => "$base/unittest-cont1/e/subdir2/subdir", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (with trailing slash) at subdir + $list = array(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (using iterator second time) + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName), second iteration." ); + + // Actual listing (top files only) at root + $iter = $this->backend->getTopFileList( array( 'dir' => "$base/unittest-cont1" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( array(), $list, "Correct top file listing ($backendName)." ); + + // Expected listing (top files only) at subdir + $expected = array( + "test1.txt", + "test2.txt", + "test3.txt", + "test4.txt", + "test5.txt" + ); + sort( $expected ); + + // Actual listing (top files only) at subdir + $iter = $this->backend->getTopFileList( + array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) + ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." ); + + // Actual listing (top files only) at subdir with advise + $iter = $this->backend->getTopFileList( array( + 'dir' => "$base/unittest-cont1/e/subdir2/subdir", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." ); + + foreach ( $files as $file ) { // clean up + $this->backend->doOperation( array( 'op' => 'delete', 'src' => $file ) ); + } + + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/not/exists" ) ); + foreach ( $iter as $iter ) { + // no errors + } + } + + /** + * @covers FileBackend::getTopDirectoryList + * @covers FileBackend::getDirectoryList + */ + public function testGetDirectoryList() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetDirectoryList(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetDirectoryList(); + $this->tearDownFiles(); + } + + private function doTestGetDirectoryList() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $files = array( + "$base/unittest-cont1/e/test1.txt", + "$base/unittest-cont1/e/test2.txt", + "$base/unittest-cont1/e/test3.txt", + "$base/unittest-cont1/e/subdir1/test1.txt", + "$base/unittest-cont1/e/subdir1/test2.txt", + "$base/unittest-cont1/e/subdir2/test3.txt", + "$base/unittest-cont1/e/subdir2/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test1.txt", + "$base/unittest-cont1/e/subdir3/subdir/test2.txt", + "$base/unittest-cont1/e/subdir4/subdir/test3.txt", + "$base/unittest-cont1/e/subdir4/subdir/test4.txt", + "$base/unittest-cont1/e/subdir4/subdir/test5.txt", + "$base/unittest-cont1/e/subdir4/subdir/sub/test0.txt", + "$base/unittest-cont1/e/subdir4/subdir/sub/120-px-file.txt", + ); + + // Add the files + $ops = array(); + foreach ( $files as $file ) { + $this->prepare( array( 'dir' => dirname( $file ) ) ); + $ops[] = array( 'op' => 'create', 'content' => 'xxy', 'dst' => $file ); + } + $status = $this->backend->doQuickOperations( $ops ); + $this->assertGoodStatus( $status, + "Creation of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of files succeeded with OK status ($backendName)." ); + + $this->assertEquals( true, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir1" ) ), + "Directory exists in ($backendName)." ); + $this->assertEquals( true, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) ), + "Directory exists in ($backendName)." ); + $this->assertEquals( false, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir2/test1.txt" ) ), + "Directory does not exists in ($backendName)." ); + + // Expected listing + $expected = array( + "e", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Expected listing + $expected = array( + "subdir1", + "subdir2", + "subdir3", + "subdir4", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (with trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Expected listing + $expected = array( + "subdir", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir2" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (with trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( + array( 'dir' => "$base/unittest-cont1/e/subdir2/" ) + ); + + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (using iterator second time) + $list = array(); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( + $expected, + $list, + "Correct top dir listing ($backendName), second iteration." + ); + + // Expected listing (recursive) + $expected = array( + "e", + "e/subdir1", + "e/subdir2", + "e/subdir3", + "e/subdir4", + "e/subdir2/subdir", + "e/subdir3/subdir", + "e/subdir4/subdir", + "e/subdir4/subdir/sub", + ); + sort( $expected ); + + // Actual listing (recursive) + $list = array(); + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + // Expected listing (recursive) + $expected = array( + "subdir", + "subdir/sub", + ); + sort( $expected ); + + // Actual listing (recursive) + $list = array(); + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir4" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + // Actual listing (recursive, second time) + $list = array(); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir1" ) ); + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + + foreach ( $files as $file ) { // clean up + $this->backend->doOperation( array( 'op' => 'delete', 'src' => $file ) ); + } + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/not/exists" ) ); + foreach ( $iter as $file ) { + // no errors + } + + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/not/exists" ) ); + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + } + + /** + * @covers FileBackend::lockFiles + * @covers FileBackend::unlockFiles + */ + public function testLockCalls() { + $this->backend = $this->singleBackend; + $this->doTestLockCalls(); + } + + private function doTestLockCalls() { + $backendName = $this->backendClass(); + + $paths = array( + "test1.txt", + "test2.txt", + "test3.txt", + "subdir1", + "subdir1", // duplicate + "subdir1/test1.txt", + "subdir1/test2.txt", + "subdir2", + "subdir2", // duplicate + "subdir2/test3.txt", + "subdir2/test4.txt", + "subdir2/subdir", + "subdir2/subdir/test1.txt", + "subdir2/subdir/test2.txt", + "subdir2/subdir/test3.txt", + "subdir2/subdir/test4.txt", + "subdir2/subdir/test5.txt", + "subdir2/subdir/sub", + "subdir2/subdir/sub/test0.txt", + "subdir2/subdir/sub/120-px-file.txt", + ); + + for ( $i = 0; $i < 25; $i++ ) { + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName). ($i)" ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + ## Flip the acquire/release ordering around ## + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName). ($i)" ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + } + + $status = Status::newGood(); + $sl = $this->backend->getScopedFileLocks( $paths, LockManager::LOCK_EX, $status ); + $this->assertType( 'ScopedLock', $sl, + "Scoped locking of files succeeded ($backendName)." ); + $this->assertEquals( array(), $status->errors, + "Scoped locking of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Scoped locking of files succeeded with OK status ($backendName)." ); + + ScopedLock::release( $sl ); + $this->assertEquals( null, $sl, + "Scoped unlocking of files succeeded ($backendName)." ); + $this->assertEquals( array(), $status->errors, + "Scoped unlocking of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Scoped unlocking of files succeeded with OK status ($backendName)." ); + } + + // helper function + private function listToArray( $iter ) { + return is_array( $iter ) ? $iter : iterator_to_array( $iter ); + } + + // test helper wrapper for backend prepare() function + private function prepare( array $params ) { + return $this->backend->prepare( $params ); + } + + // test helper wrapper for backend prepare() function + private function create( array $params ) { + $params['op'] = 'create'; + + return $this->backend->doQuickOperations( array( $params ) ); + } + + function tearDownFiles() { + foreach ( $this->filesToPrune as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + $containers = array( 'unittest-cont1', 'unittest-cont2', 'unittest-cont-bad' ); + foreach ( $containers as $container ) { + $this->deleteFiles( $container ); + } + $this->filesToPrune = array(); + } + + private function deleteFiles( $container ) { + $base = self::baseStorePath(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/$container" ) ); + if ( $iter ) { + foreach ( $iter as $file ) { + $this->backend->quickDelete( array( 'src' => "$base/$container/$file" ) ); + } + // free the directory, to avoid Permission denied under windows on rmdir + unset( $iter ); + } + $this->backend->clean( array( 'dir' => "$base/$container", 'recursive' => 1 ) ); + } + + function assertBackendPathsConsistent( array $paths ) { + if ( $this->backend instanceof FileBackendMultiWrite ) { + $status = $this->backend->consistencyCheck( $paths ); + $this->assertGoodStatus( $status, "Files synced: " . implode( ',', $paths ) ); + } + } + + function assertGoodStatus( $status, $msg ) { + $this->assertEquals( print_r( array(), 1 ), print_r( $status->errors, 1 ), $msg ); + } +} diff --git a/tests/phpunit/includes/filerepo/FileRepoTest.php b/tests/phpunit/includes/filerepo/FileRepoTest.php new file mode 100644 index 00000000..a196dca8 --- /dev/null +++ b/tests/phpunit/includes/filerepo/FileRepoTest.php @@ -0,0 +1,55 @@ + 'foobar' + ) ); + } + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionNeedBackendKey() { + new FileRepo( array( + 'name' => 'foobar' + ) ); + } + + /** + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionWithRequiredOptions() { + $f = new FileRepo( array( + 'name' => 'FileRepoTestRepository', + 'backend' => new FSFileBackend( array( + 'name' => 'local-testing', + 'wikiId' => 'test_wiki', + 'containerPaths' => array() + ) ) + ) ); + $this->assertInstanceOf( 'FileRepo', $f ); + } +} diff --git a/tests/phpunit/includes/filerepo/RepoGroupTest.php b/tests/phpunit/includes/filerepo/RepoGroupTest.php new file mode 100644 index 00000000..5bdb7e7f --- /dev/null +++ b/tests/phpunit/includes/filerepo/RepoGroupTest.php @@ -0,0 +1,59 @@ +setMwGlobals( 'wgForeignFileRepos', array() ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + $this->assertFalse( RepoGroup::singleton()->hasForeignRepos() ); + } + + function testHasForeignRepoPositive() { + $this->setUpForeignRepo(); + $this->assertTrue( RepoGroup::singleton()->hasForeignRepos() ); + } + + function testForEachForeignRepo() { + $this->setUpForeignRepo(); + $fakeCallback = $this->getMock( 'RepoGroupTestHelper' ); + $fakeCallback->expects( $this->once() )->method( 'callback' ); + RepoGroup::singleton()->forEachForeignRepo( + array( $fakeCallback, 'callback' ), array( array() ) ); + } + + function testForEachForeignRepoNone() { + $this->setMwGlobals( 'wgForeignFileRepos', array() ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + $fakeCallback = $this->getMock( 'RepoGroupTestHelper' ); + $fakeCallback->expects( $this->never() )->method( 'callback' ); + RepoGroup::singleton()->forEachForeignRepo( + array( $fakeCallback, 'callback' ), array( array() ) ); + } + + private function setUpForeignRepo() { + global $wgUploadDirectory; + $this->setMwGlobals( 'wgForeignFileRepos', array( array( + 'class' => 'ForeignAPIRepo', + 'name' => 'wikimediacommons', + 'backend' => 'wikimediacommons-backend', + 'apibase' => 'https://commons.wikimedia.org/w/api.php', + 'hashLevels' => 2, + 'fetchDescription' => true, + 'descriptionCacheExpiry' => 43200, + 'apiThumbCacheExpiry' => 86400, + 'directory' => $wgUploadDirectory + ) ) ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + } +} + +/** + * Quick helper class to use as a mock callback for RepoGroup::singleton()->forEachForeignRepo. + */ +class RepoGroupTestHelper { + function callback( FileRepo $repo, array $foo ) { + return true; + } +} diff --git a/tests/phpunit/includes/filerepo/StoreBatchTest.php b/tests/phpunit/includes/filerepo/StoreBatchTest.php new file mode 100644 index 00000000..9cc2efbf --- /dev/null +++ b/tests/phpunit/includes/filerepo/StoreBatchTest.php @@ -0,0 +1,146 @@ +getCliArg( 'use-filebackend' ) ) { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + } + } + $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] ); + unset( $useConfig['fileJournal'] ); + $useConfig['name'] = 'local-testing'; // swap name + $class = $useConfig['class']; + $backend = new $class( $useConfig ); + } else { + $backend = new FSFileBackend( array( + 'name' => 'local-testing', + 'wikiId' => wfWikiID(), + 'containerPaths' => array( + 'unittests-public' => "{$tmpPrefix}-public", + 'unittests-thumb' => "{$tmpPrefix}-thumb", + 'unittests-temp' => "{$tmpPrefix}-temp", + 'unittests-deleted' => "{$tmpPrefix}-deleted", + ) + ) ); + } + $this->repo = new FileRepo( array( + 'name' => 'unittests', + 'backend' => $backend + ) ); + + $this->date = gmdate( "YmdHis" ); + $this->createdFiles = array(); + } + + protected function tearDown() { + $this->repo->cleanupBatch( $this->createdFiles ); // delete files + foreach ( $this->createdFiles as $tmp ) { // delete dirs + $tmp = $this->repo->resolveVirtualUrl( $tmp ); + while ( $tmp = FileBackend::parentStoragePath( $tmp ) ) { + $this->repo->getBackend()->clean( array( 'dir' => $tmp ) ); + } + } + parent::tearDown(); + } + + /** + * Store a file or virtual URL source into a media file name. + * + * @param string $originalName The title of the image + * @param string $srcPath The filepath or virtual URL + * @param int $flags Flags to pass into repo::store(). + * @return FileRepoStatus + */ + private function storeit( $originalName, $srcPath, $flags ) { + $hashPath = $this->repo->getHashPath( $originalName ); + $dstRel = "$hashPath{$this->date}!$originalName"; + $dstUrlRel = $hashPath . $this->date . '!' . rawurlencode( $originalName ); + + $result = $this->repo->store( $srcPath, 'temp', $dstRel, $flags ); + $result->value = $this->repo->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; + $this->createdFiles[] = $result->value; + + return $result; + } + + /** + * Test storing a file using different flags. + * + * @param string $fn The title of the image + * @param string $infn The name of the file (in the filesystem) + * @param string $otherfn The name of the different file (in the filesystem) + * @param bool $fromrepo 'true' if we want to copy from a virtual URL out of the Repo. + */ + private function storecohort( $fn, $infn, $otherfn, $fromrepo ) { + $f = $this->storeit( $fn, $infn, 0 ); + $this->assertTrue( $f->isOK(), 'failed to store a new file' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + if ( $fromrepo ) { + $f = $this->storeit( "Other-$fn", $infn, FileRepo::OVERWRITE ); + $infn = $f->value; + } + // This should work because we're allowed to overwrite + $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE ); + $this->assertTrue( $f->isOK(), 'We should be allowed to overwrite' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should fail because we're overwriting. + $f = $this->storeit( $fn, $infn, 0 ); + $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite' ); + $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should succeed because we're overwriting the same content. + $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE_SAME ); + $this->assertTrue( $f->isOK(), 'We should be able to overwrite the same content' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should fail because we're overwriting different content. + if ( $fromrepo ) { + $f = $this->storeit( "Other-$fn", $otherfn, FileRepo::OVERWRITE ); + $otherfn = $f->value; + } + $f = $this->storeit( $fn, $otherfn, FileRepo::OVERWRITE_SAME ); + $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite different content' ); + $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + } + + /** + * @covers FileRepo::store + */ + public function teststore() { + global $IP; + $this->storecohort( + "Test1.png", + "$IP/tests/phpunit/data/filerepo/wiki.png", + "$IP/tests/phpunit/data/filerepo/video.png", + false + ); + $this->storecohort( + "Test2.png", + "$IP/tests/phpunit/data/filerepo/wiki.png", + "$IP/tests/phpunit/data/filerepo/video.png", + true + ); + } +} diff --git a/tests/phpunit/includes/filerepo/file/FileTest.php b/tests/phpunit/includes/filerepo/file/FileTest.php new file mode 100644 index 00000000..8e8b8a9e --- /dev/null +++ b/tests/phpunit/includes/filerepo/file/FileTest.php @@ -0,0 +1,386 @@ +setMwGlobals( 'wgMaxAnimatedGifArea', 9000 ); + $file = $this->dataFile( $filename ); + $this->assertEquals( $file->canAnimateThumbIfAppropriate(), $expected ); + } + + function providerCanAnimate() { + return array( + array( 'nonanimated.gif', true ), + array( 'jpeg-comment-utf.jpg', true ), + array( 'test.tiff', true ), + array( 'Animated_PNG_example_bouncing_beach_ball.png', false ), + array( 'greyscale-png.png', true ), + array( 'Toll_Texas_1.svg', true ), + array( 'LoremIpsum.djvu', true ), + array( '80x60-2layers.xcf', true ), + array( 'Soccer_ball_animated.svg', false ), + array( 'Bishzilla_blink.gif', false ), + array( 'animated.gif', true ), + ); + } + + /** + * @dataProvider getThumbnailBucketProvider + * @covers File::getThumbnailBucket + */ + public function testGetThumbnailBucket( $data ) { + $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] ); + $this->setMwGlobals( 'wgThumbnailMinimumBucketDistance', $data['minimumBucketDistance'] ); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', false ) ) + ->setMethods( array( 'getWidth' ) ) + ->getMockForAbstractClass(); + + $fileMock->expects( $this->any() ) + ->method( 'getWidth' ) + ->will( $this->returnValue( $data['width'] ) ); + + $this->assertEquals( + $data['expectedBucket'], + $fileMock->getThumbnailBucket( $data['requestedWidth'] ), + $data['message'] ); + } + + public function getThumbnailBucketProvider() { + $defaultBuckets = array( 256, 512, 1024, 2048, 4096 ); + + return array( + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 120, + 'expectedBucket' => 256, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 300, + 'expectedBucket' => 512, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => 2048, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 2048, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because none is bigger than the requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 3500, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because requested size is bigger than original' + ) ), + array( array( + 'buckets' => array( 1024 ), + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because requested size equals biggest bucket' + ) ), + array( array( + 'buckets' => null, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because no buckets have been specified' + ) ), + array( array( + 'buckets' => array( 256, 512 ), + 'minimumBucketDistance' => 10, + 'width' => 3000, + 'requestedWidth' => 245, + 'expectedBucket' => 256, + 'message' => 'Requested width is distant enough from next bucket for it to be picked' + ) ), + array( array( + 'buckets' => array( 256, 512 ), + 'minimumBucketDistance' => 10, + 'width' => 3000, + 'requestedWidth' => 246, + 'expectedBucket' => 512, + 'message' => 'Requested width is too close to next bucket, picking next one' + ) ), + ); + } + + /** + * @dataProvider getThumbnailSourceProvider + * @covers File::getThumbnailSource + */ + public function testGetThumbnailSource( $data ) { + $backendMock = $this->getMockBuilder( 'FSFileBackend' ) + ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) ) + ->getMock(); + + $repoMock = $this->getMockBuilder( 'FileRepo' ) + ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) ) + ->setMethods( array( 'fileExists', 'getLocalReference' ) ) + ->getMock(); + + $fsFile = new FSFile( 'fsFilePath' ); + + $repoMock->expects( $this->any() ) + ->method( 'fileExists' ) + ->will( $this->returnValue( true ) ); + + $repoMock->expects( $this->any() ) + ->method( 'getLocalReference' ) + ->will( $this->returnValue( $fsFile ) ); + + $handlerMock = $this->getMock( 'BitmapHandler', array( 'supportsBucketing' ) ); + $handlerMock->expects( $this->any() ) + ->method( 'supportsBucketing' ) + ->will( $this->returnValue( $data['supportsBucketing'] ) ); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', $repoMock ) ) + ->setMethods( array( 'getThumbnailBucket', 'getLocalRefPath', 'getHandler' ) ) + ->getMockForAbstractClass(); + + $fileMock->expects( $this->any() ) + ->method( 'getThumbnailBucket' ) + ->will( $this->returnValue( $data['thumbnailBucket'] ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getLocalRefPath' ) + ->will( $this->returnValue( 'localRefPath' ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getHandler' ) + ->will( $this->returnValue( $handlerMock ) ); + + $reflection = new ReflectionClass( $fileMock ); + $reflection_property = $reflection->getProperty( 'handler' ); + $reflection_property->setAccessible( true ); + $reflection_property->setValue( $fileMock, $handlerMock ); + + if ( !is_null( $data['tmpBucketedThumbCache'] ) ) { + $reflection_property = $reflection->getProperty( 'tmpBucketedThumbCache' ); + $reflection_property->setAccessible( true ); + $reflection_property->setValue( $fileMock, $data['tmpBucketedThumbCache'] ); + } + + $result = $fileMock->getThumbnailSource( + array( 'physicalWidth' => $data['physicalWidth'] ) ); + + $this->assertEquals( $data['expectedPath'], $result['path'], $data['message'] ); + } + + public function getThumbnailSourceProvider() { + return array( + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'fsFilePath', + 'message' => 'Path downloaded from storage' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => array( 1024 => '/tmp/shouldnotexist' + rand() ), + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'fsFilePath', + 'message' => 'Path downloaded from storage because temp file is missing' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => array( 1024 => '/tmp' ), + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => '/tmp', + 'message' => 'Temporary path because temp file was found' + ) ), + array( array( + 'supportsBucketing' => false, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'localRefPath', + 'message' => 'Original file path because bucketing is unsupported by handler' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => false, + 'physicalWidth' => 2048, + 'expectedPath' => 'localRefPath', + 'message' => 'Original file path because no width provided' + ) ), + ); + } + + /** + * @dataProvider generateBucketsIfNeededProvider + * @covers File::generateBucketsIfNeeded + */ + public function testGenerateBucketsIfNeeded( $data ) { + $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] ); + + $backendMock = $this->getMockBuilder( 'FSFileBackend' ) + ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) ) + ->getMock(); + + $repoMock = $this->getMockBuilder( 'FileRepo' ) + ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) ) + ->setMethods( array( 'fileExists', 'getLocalReference' ) ) + ->getMock(); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', $repoMock ) ) + ->setMethods( array( 'getWidth', 'getBucketThumbPath', 'makeTransformTmpFile', + 'generateAndSaveThumb', 'getHandler' ) ) + ->getMockForAbstractClass(); + + $handlerMock = $this->getMock( 'JpegHandler', array( 'supportsBucketing' ) ); + $handlerMock->expects( $this->any() ) + ->method( 'supportsBucketing' ) + ->will( $this->returnValue( true ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getHandler' ) + ->will( $this->returnValue( $handlerMock ) ); + + $reflectionMethod = new ReflectionMethod( 'File', 'generateBucketsIfNeeded' ); + $reflectionMethod->setAccessible( true ); + + $fileMock->expects( $this->any() ) + ->method( 'getWidth' ) + ->will( $this->returnValue( $data['width'] ) ); + + $fileMock->expects( $data['expectedGetBucketThumbPathCalls'] ) + ->method( 'getBucketThumbPath' ); + + $repoMock->expects( $data['expectedFileExistsCalls'] ) + ->method( 'fileExists' ) + ->will( $this->returnValue( $data['fileExistsReturn'] ) ); + + $fileMock->expects( $data['expectedMakeTransformTmpFile'] ) + ->method( 'makeTransformTmpFile' ) + ->will( $this->returnValue( $data['makeTransformTmpFileReturn'] ) ); + + $fileMock->expects( $data['expectedGenerateAndSaveThumb'] ) + ->method( 'generateAndSaveThumb' ) + ->will( $this->returnValue( $data['generateAndSaveThumbReturn'] ) ); + + $this->assertEquals( $data['expectedResult'], + $reflectionMethod->invoke( + $fileMock, + array( + 'physicalWidth' => $data['physicalWidth'], + 'physicalHeight' => $data['physicalHeight'] ) + ), + $data['message'] ); + } + + public function generateBucketsIfNeededProvider() { + $defaultBuckets = array( 256, 512, 1024, 2048, 4096 ); + + return array( + array( array( + 'buckets' => $defaultBuckets, + 'width' => 256, + 'physicalWidth' => 256, + 'physicalHeight' => 100, + 'expectedGetBucketThumbPathCalls' => $this->never(), + 'expectedFileExistsCalls' => $this->never(), + 'fileExistsReturn' => null, + 'expectedMakeTransformTmpFile' => $this->never(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'No bucket found, nothing to generate' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => true, + 'expectedMakeTransformTmpFile' => $this->never(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'File already exists, no reason to generate buckets' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'Cannot generate temp file for bucket' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ), + 'expectedGenerateAndSaveThumb' => $this->once(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'Bucket image could not be generated' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ), + 'expectedGenerateAndSaveThumb' => $this->once(), + 'generateAndSaveThumbReturn' => new ThumbnailImage( false, 'bar', false, false ), + 'expectedResult' => true, + 'message' => 'Bucket image could not be generated' + ) ), + ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php new file mode 100644 index 00000000..2c7f50c9 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php @@ -0,0 +1,68 @@ + 'BGR', + 'Burkina Faso' => 'BFA', + 'Burundi' => 'BDI', + ); + + /** + * Verify that attempting to instantiate an HTMLAutoCompleteSelectField + * without providing any autocomplete options causes an exception to be + * thrown. + * + * @expectedException MWException + * @expectedExceptionMessage called without any autocompletions + */ + function testMissingAutocompletions() { + new HTMLAutoCompleteSelectField( array( 'fieldname' => 'Test' ) ); + } + + /** + * Verify that the autocomplete options are correctly encoded as + * the 'data-autocomplete' attribute of the field. + * + * @covers HTMLAutoCompleteSelectField::getAttributes + */ + function testGetAttributes() { + $field = new HTMLAutoCompleteSelectField( array( + 'fieldname' => 'Test', + 'autocomplete' => $this->options, + ) ); + + $attributes = $field->getAttributes( array() ); + $this->assertEquals( array_keys( $this->options ), + FormatJson::decode( $attributes['data-autocomplete'] ), + "The 'data-autocomplete' attribute encodes autocomplete option keys as a JSON array." + ); + } + + /** + * Test that the optional select dropdown is included or excluded based on + * the presence or absence of the 'options' parameter. + */ + function testOptionalSelectElement() { + $params = array( + 'fieldname' => 'Test', + 'autocomplete' => $this->options, + 'options' => $this->options, + ); + + $field = new HTMLAutoCompleteSelectField( $params ); + $html = $field->getInputHTML( false ); + $this->assertRegExp( '/select/', $html, + "When the 'options' parameter is set, the HTML includes a " ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php new file mode 100644 index 00000000..5a822f53 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php @@ -0,0 +1,105 @@ + array( 'r1', 'r2' ), + 'columns' => array( 'c1', 'c2' ), + 'fieldname' => 'test', + ); + + public function testPlainInstantiation() { + try { + new HTMLCheckMatrix( array() ); + } catch ( MWException $e ) { + $this->assertInstanceOf( 'HTMLFormFieldRequiredOptionsException', $e ); + return; + } + + $this->fail( 'Expected MWException indicating missing parameters but none was thrown.' ); + } + + public function testInstantiationWithMinimumRequiredParameters() { + new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( true ); // form instantiation must throw exception on failure + } + + public function testValidateCallsUserDefinedValidationCallback() { + $called = false; + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'validation-callback' => function () use ( &$called ) { + $called = true; + + return false; + }, + ) ); + $this->assertEquals( false, $this->validate( $field, array() ) ); + $this->assertTrue( $called ); + } + + public function testValidateRequiresArrayInput() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertEquals( false, $this->validate( $field, null ) ); + $this->assertEquals( false, $this->validate( $field, true ) ); + $this->assertEquals( false, $this->validate( $field, 'abc' ) ); + $this->assertEquals( false, $this->validate( $field, new stdClass ) ); + $this->assertEquals( true, $this->validate( $field, array() ) ); + } + + public function testValidateAllowsOnlyKnownTags() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertInternalType( 'string', $this->validate( $field, array( 'foo' ) ) ); + } + + public function testValidateAcceptsPartialTagList() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( $this->validate( $field, array() ) ); + $this->assertTrue( $this->validate( $field, array( 'c1-r1' ) ) ); + $this->assertTrue( $this->validate( $field, array( 'c1-r1', 'c1-r2', 'c2-r1', 'c2-r2' ) ) ); + } + + /** + * This form object actually has no visibility into what happens later on, but essentially + * if the data submitted by the user passes validate the following is run: + * foreach ( $field->filterDataForSubmit( $data ) as $k => $v ) { + * $user->setOption( $k, $v ); + * } + */ + public function testValuesForcedOnRemainOn() { + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'force-options-on' => array( 'c2-r1' ), + ) ); + $expected = array( + 'c1-r1' => false, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ); + $this->assertEquals( $expected, $field->filterDataForSubmit( array() ) ); + } + + public function testValuesForcedOffRemainOff() { + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'force-options-off' => array( 'c1-r2', 'c2-r2' ), + ) ); + $expected = array( + 'c1-r1' => true, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ); + // array_keys on the result simulates submitting all fields checked + $this->assertEquals( $expected, $field->filterDataForSubmit( array_keys( $expected ) ) ); + } + + protected function validate( HTMLFormField $field, $submitted ) { + return $field->validate( + $submitted, + array( self::$defaultOptions['fieldname'] => $submitted ) + ); + } + +} diff --git a/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/includes/installer/InstallDocFormatterTest.php new file mode 100644 index 00000000..064d5185 --- /dev/null +++ b/tests/phpunit/includes/installer/InstallDocFormatterTest.php @@ -0,0 +1,72 @@ +assertEquals( + $expected, + InstallDocFormatter::format( $unformattedText ), + $message + ); + } + + /** + * Provider for testFormat() + */ + public static function provideDocFormattingTests() { + # Format: (expected string, unformattedText string, optional message) + return array( + # Escape some wikitext + array( 'Install <tag>', 'Install ', 'Escaping <' ), + array( 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ), + array( 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ), + array( 'Install __TOC__', 'Install __TOC__', 'Escaping __' ), + array( 'Install ', "Install \r", 'Removing \r' ), + + # Transform \t{1,2} into :{1,2} + array( ':One indentation', "\tOne indentation", 'Replacing a single \t' ), + array( '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ), + + # Transform 'bug 123' links + array( + '[https://bugzilla.wikimedia.org/123 bug 123]', + 'bug 123', 'Testing bug 123 links' ), + array( + '([https://bugzilla.wikimedia.org/987654 bug 987654])', + '(bug 987654)', 'Testing (bug 987654) links' ), + + # "bug abc" shouldn't work + array( 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ), + array( 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ), + + # Transform '$wgFooBar' links + array( + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]', + '$wgFooBar', 'Testing basic $wgFooBar' ), + array( + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]', + '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ), + array( + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]', + '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ), + + # Icky variables that shouldn't link + array( + '$myAwesomeVariable', + '$myAwesomeVariable', + 'Testing $myAwesomeVariable (not starting with $wg)' + ), + array( '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ), + ); + } +} diff --git a/tests/phpunit/includes/installer/OracleInstallerTest.php b/tests/phpunit/includes/installer/OracleInstallerTest.php new file mode 100644 index 00000000..fdcecf9e --- /dev/null +++ b/tests/phpunit/includes/installer/OracleInstallerTest.php @@ -0,0 +1,52 @@ +assertEquals( $expected, + OracleInstaller::checkConnectStringFormat( $connectString ), + $msg + ); + } + + /** + * Provider to test OracleInstaller::checkConnectStringFormat() + */ + function provideOracleConnectStrings() { + // expected result, connectString[, message] + return array( + array( true, 'simple_01', 'Simple TNS name' ), + array( true, 'simple_01.world', 'TNS name with domain' ), + array( true, 'simple_01.domain.net', 'TNS name with domain' ), + array( true, 'host123', 'Host only' ), + array( true, 'host123.domain.net', 'FQDN only' ), + array( true, '//host123.domain.net', 'FQDN URL only' ), + array( true, '123.223.213.132', 'Host IP only' ), + array( true, 'host:1521', 'Host and port' ), + array( true, 'host:1521/service', 'Host, port and service' ), + array( true, 'host:1521/service:shared', 'Host, port, service and shared server type' ), + array( true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ), + array( true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ), + array( + true, + 'host:1521/service:shared/instance1', + 'Host, port, service, server type and instance' + ), + array( true, 'host:1521//instance1', 'Host, port and instance' ), + ); + } + +} diff --git a/tests/phpunit/includes/jobqueue/JobQueueTest.php b/tests/phpunit/includes/jobqueue/JobQueueTest.php new file mode 100644 index 00000000..69e40068 --- /dev/null +++ b/tests/phpunit/includes/jobqueue/JobQueueTest.php @@ -0,0 +1,344 @@ +tablesUsed[] = 'job'; + } + + protected function setUp() { + global $wgJobTypeConf; + parent::setUp(); + + $this->setMwGlobals( 'wgMemc', new HashBagOStuff() ); + + if ( $this->getCliArg( 'use-jobqueue' ) ) { + $name = $this->getCliArg( 'use-jobqueue' ); + if ( !isset( $wgJobTypeConf[$name] ) ) { + throw new MWException( "No \$wgJobTypeConf entry for '$name'." ); + } + $baseConfig = $wgJobTypeConf[$name]; + } else { + $baseConfig = array( 'class' => 'JobQueueDB' ); + } + $baseConfig['type'] = 'null'; + $baseConfig['wiki'] = wfWikiID(); + $variants = array( + 'queueRand' => array( 'order' => 'random', 'claimTTL' => 0 ), + 'queueRandTTL' => array( 'order' => 'random', 'claimTTL' => 10 ), + 'queueTimestamp' => array( 'order' => 'timestamp', 'claimTTL' => 0 ), + 'queueTimestampTTL' => array( 'order' => 'timestamp', 'claimTTL' => 10 ), + 'queueFifo' => array( 'order' => 'fifo', 'claimTTL' => 0 ), + 'queueFifoTTL' => array( 'order' => 'fifo', 'claimTTL' => 10 ), + ); + foreach ( $variants as $q => $settings ) { + try { + $this->$q = JobQueue::factory( $settings + $baseConfig ); + if ( !( $this->$q instanceof JobQueueDB ) ) { + $this->$q->setTestingPrefix( 'unittests-' . wfRandomString( 32 ) ); + } + } catch ( MWException $e ) { + // unsupported? + // @todo What if it was another error? + }; + } + } + + protected function tearDown() { + parent::tearDown(); + foreach ( + array( + 'queueRand', 'queueRandTTL', 'queueTimestamp', 'queueTimestampTTL', + 'queueFifo', 'queueFifoTTL' + ) as $q + ) { + if ( $this->$q ) { + $this->$q->delete(); + } + $this->$q = null; + } + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue::getWiki + */ + public function testGetWiki( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + $this->assertEquals( wfWikiID(), $queue->getWiki(), "Proper wiki ID ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue::getType + */ + public function testGetType( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + $this->assertEquals( 'null', $queue->getType(), "Proper job type ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testBasicOperations( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $this->assertNull( $queue->push( $this->newJob() ), "Push worked ($desc)" ); + $this->assertNull( $queue->batchPush( array( $this->newJob() ) ), "Push worked ($desc)" ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 2, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + $jobs = iterator_to_array( $queue->getAllQueuedJobs() ); + $this->assertEquals( 2, count( $jobs ), "Queue iterator size is correct ($desc)" ); + + $job1 = $queue->pop(); + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $job2 = $queue->pop(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 2, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job1 ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job2 ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + + $this->assertNull( $queue->batchPush( array( $this->newJob(), $this->newJob() ) ), + "Push worked ($desc)" ); + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->delete(); + $queue->flushCaches(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testBasicDeduplication( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $this->assertNull( + $queue->batchPush( + array( $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ) + ), + "Push worked ($desc)" ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $this->assertNull( + $queue->batchPush( + array( $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ) + ), + "Push worked ($desc)" + ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $job1 = $queue->pop(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job1 ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testRootDeduplication( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $id = wfRandomString( 32 ); + $root1 = Job::newRootJobParams( "nulljobspam:$id" ); // task ID/timestamp + for ( $i = 0; $i < 5; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( 0, $root1 ) ), "Push worked ($desc)" ); + } + $queue->deduplicateRootJob( $this->newJob( 0, $root1 ) ); + sleep( 1 ); // roo job timestamp will increase + $root2 = Job::newRootJobParams( "nulljobspam:$id" ); // task ID/timestamp + $this->assertNotEquals( $root1['rootJobTimestamp'], $root2['rootJobTimestamp'], + "Root job signatures have different timestamps." ); + for ( $i = 0; $i < 5; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( 0, $root2 ) ), "Push worked ($desc)" ); + } + $queue->deduplicateRootJob( $this->newJob( 0, $root2 ) ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 10, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $dupcount = 0; + $jobs = array(); + do { + $job = $queue->pop(); + if ( $job ) { + $jobs[] = $job; + $queue->ack( $job ); + } + if ( $job instanceof DuplicateJob ) { + ++$dupcount; + } + } while ( $job ); + + $this->assertEquals( 10, count( $jobs ), "Correct number of jobs popped ($desc)" ); + $this->assertEquals( 5, $dupcount, "Correct number of duplicate jobs popped ($desc)" ); + } + + /** + * @dataProvider provider_fifoQueueLists + * @covers JobQueue + */ + public function testJobOrder( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + for ( $i = 0; $i < 10; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( $i ) ), "Push worked ($desc)" ); + } + + for ( $i = 0; $i < 10; ++$i ) { + $job = $queue->pop(); + $this->assertTrue( $job instanceof Job, "Jobs popped from queue ($desc)" ); + $params = $job->getParams(); + $this->assertEquals( $i, $params['i'], "Job popped from queue is FIFO ($desc)" ); + $queue->ack( $job ); + } + + $this->assertFalse( $queue->pop(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + } + + public static function provider_queueLists() { + return array( + array( 'queueRand', false, 'Random queue without ack()' ), + array( 'queueRandTTL', true, 'Random queue with ack()' ), + array( 'queueTimestamp', false, 'Time ordered queue without ack()' ), + array( 'queueTimestampTTL', true, 'Time ordered queue with ack()' ), + array( 'queueFifo', false, 'FIFO ordered queue without ack()' ), + array( 'queueFifoTTL', true, 'FIFO ordered queue with ack()' ) + ); + } + + public static function provider_fifoQueueLists() { + return array( + array( 'queueFifo', false, 'Ordered queue without ack()' ), + array( 'queueFifoTTL', true, 'Ordered queue with ack()' ) + ); + } + + function newJob( $i = 0, $rootJob = array() ) { + return new NullJob( Title::newMainPage(), + array( 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ) + $rootJob ); + } + + function newDedupedJob( $i = 0, $rootJob = array() ) { + return new NullJob( Title::newMainPage(), + array( 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ) + $rootJob ); + } +} diff --git a/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php b/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php new file mode 100644 index 00000000..3e232a93 --- /dev/null +++ b/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php @@ -0,0 +1,112 @@ +tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'pagelinks'; + } + + /** + * @dataProvider provider_backlinks + */ + public function testRefreshLinks( $ns, $dbKey, $pages ) { + $title = Title::makeTitle( $ns, $dbKey ); + + foreach ( $pages as $page ) { + list( $bns, $bdbkey ) = $page; + $bpage = WikiPage::factory( Title::makeTitle( $bns, $bdbkey ) ); + $content = ContentHandler::makeContent( "[[{$title->getPrefixedText()}]]", $bpage->getTitle() ); + $bpage->doEditContent( $content, "test" ); + } + + $title->getBacklinkCache()->clear(); + $this->assertEquals( + 20, + $title->getBacklinkCache()->getNumLinks( 'pagelinks' ), + 'Correct number of backlinks' + ); + + $job = new RefreshLinksJob( $title, array( 'recursive' => true, 'table' => 'pagelinks' ) + + Job::newRootJobParams( "refreshlinks:pagelinks:{$title->getPrefixedText()}" ) ); + $extraParams = $job->getRootJobParams(); + $jobs = BacklinkJobUtils::partitionBacklinkJob( $job, 9, 1, array( 'params' => $extraParams ) ); + + $this->assertEquals( 10, count( $jobs ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[0], current( $jobs[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $pages[8], current( $jobs[8]->params['pages'] ), + 'Last leaf job is leaf job with proper title' ); + $this->assertEquals( true, isset( $jobs[9]->params['recursive'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( true, $jobs[9]->params['recursive'], + 'Last job is recursive sub-job' ); + $this->assertEquals( true, is_array( $jobs[9]->params['range'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( $title->getPrefixedText(), $jobs[0]->getTitle()->getPrefixedText(), + 'Base job title retainend in leaf job' ); + $this->assertEquals( $title->getPrefixedText(), $jobs[9]->getTitle()->getPrefixedText(), + 'Base job title retainend recursive sub-job' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs[9]->params['rootJobSignature'], + 'Recursive sub-job has root params' ); + + $jobs2 = BacklinkJobUtils::partitionBacklinkJob( + $jobs[9], + 9, + 1, + array( 'params' => $extraParams ) + ); + + $this->assertEquals( 10, count( $jobs2 ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[9], current( $jobs2[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $pages[17], current( $jobs2[8]->params['pages'] ), + 'Last leaf job is leaf job with proper title' ); + $this->assertEquals( true, isset( $jobs2[9]->params['recursive'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( true, $jobs2[9]->params['recursive'], + 'Last job is recursive sub-job' ); + $this->assertEquals( true, is_array( $jobs2[9]->params['range'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[9]->params['rootJobSignature'], + 'Recursive sub-job has root params' ); + + $jobs3 = BacklinkJobUtils::partitionBacklinkJob( + $jobs2[9], + 9, + 1, + array( 'params' => $extraParams ) + ); + + $this->assertEquals( 2, count( $jobs3 ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[18], current( $jobs3[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $pages[19], current( $jobs3[1]->params['pages'] ), + 'Last job is leaf job with proper title' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[1]->params['rootJobSignature'], + 'Last leaf job has root params' ); + } + + public static function provider_backlinks() { + $pages = array(); + for ( $i = 0; $i < 20; ++$i ) { + $pages[] = array( 0, "Page-$i" ); + } + return array( + array( 10, 'Bang', $pages ) + ); + } +} diff --git a/tests/phpunit/includes/json/FormatJsonTest.php b/tests/phpunit/includes/json/FormatJsonTest.php new file mode 100644 index 00000000..af68ab03 --- /dev/null +++ b/tests/phpunit/includes/json/FormatJsonTest.php @@ -0,0 +1,279 @@ + new stdClass, + 'emptyArray' => array(), + 'string' => 'foobar\\', + 'filledArray' => array( + array( + 123, + 456, + ), + // Nested json works without problems + '"7":["8",{"9":"10"}]', + // Whitespace clean up doesn't touch strings that look alike + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", + ), + ); + + // No trailing whitespace, no trailing linefeed + $json = '{ + "emptyObject": {}, + "emptyArray": [], + "string": "foobar\\\\", + "filledArray": [ + [ + 123, + 456 + ], + "\"7\":[\"8\",{\"9\":\"10\"}]", + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" + ] +}'; + + $json = str_replace( "\r", '', $json ); // Windows compat + $json = str_replace( "\t", $expectedIndent, $json ); + $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); + } + + public static function provideEncodeDefault() { + return self::getEncodeTestCases( array() ); + } + + /** + * @dataProvider provideEncodeDefault + */ + public function testEncodeDefault( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from ) ); + } + + public static function provideEncodeUtf8() { + return self::getEncodeTestCases( array( 'unicode' ) ); + } + + /** + * @dataProvider provideEncodeUtf8 + */ + public function testEncodeUtf8( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); + } + + public static function provideEncodeXmlMeta() { + return self::getEncodeTestCases( array( 'xmlmeta' ) ); + } + + /** + * @dataProvider provideEncodeXmlMeta + */ + public function testEncodeXmlMeta( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); + } + + public static function provideEncodeAllOk() { + return self::getEncodeTestCases( array( 'unicode', 'xmlmeta' ) ); + } + + /** + * @dataProvider provideEncodeAllOk + */ + public function testEncodeAllOk( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); + } + + public function testEncodePhpBug46944() { + $this->assertNotEquals( + '\ud840\udc00', + strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), + 'Test encoding an broken json_encode character (U+20000)' + ); + } + + public function testDecodeReturnType() { + $this->assertInternalType( + 'object', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), + 'Default to object' + ); + + $this->assertInternalType( + 'array', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), + 'Optional array' + ); + } + + public static function provideParse() { + return array( + array( null ), + array( true ), + array( false ), + array( 0 ), + array( 1 ), + array( 1.2 ), + array( '' ), + array( 'str' ), + array( array( 0, 1, 2 ) ), + array( array( 'a' => 'b' ) ), + array( array( 'a' => 'b' ) ), + array( array( 'a' => 'b', 'x' => array( 'c' => 'd' ) ) ), + ); + } + + /** + * Recursively convert arrays into stdClass + * @param array|string|bool|int|float|null $value + * @return stdClass|string|bool|int|float|null + */ + public static function toObject( $value ) { + return !is_array( $value ) ? $value : (object) array_map( __METHOD__, $value ); + } + + /** + * @dataProvider provideParse + * @param mixed $value + */ + public function testParse( $value ) { + $expected = self::toObject( $value ); + $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); + $this->assertJson( $json ); + + $st = FormatJson::parse( $json ); + $this->assertType( 'Status', $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expected, $st->getValue() ); + + $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); + $this->assertType( 'Status', $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $value, $st->getValue() ); + } + + public static function provideParseTryFixing() { + return array( + array( "[,]", '[]' ), + array( "[ , ]", '[]' ), + array( "[ , }", false ), + array( '[1],', false ), + array( "[1,]", '[1]' ), + array( "[1\n,]", '[1]' ), + array( "[1,\n]", '[1]' ), + array( "[1,]\n", '[1]' ), + array( "[1\n,\n]\n", '[1]' ), + array( '["a,",]', '["a,"]' ), + array( "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ), + array( '[[1,],[2,],[3,]]', false ), // I wish we could parse this, but would need quote parsing + array( '[1,,]', false ), + ); + } + + /** + * @dataProvider provideParseTryFixing + * @param string $value + * @param string|bool $expected + */ + public function testParseTryFixing( $value, $expected ) { + $st = FormatJson::parse( $value, FormatJson::TRY_FIXING ); + $this->assertType( 'Status', $st ); + if ( $expected === false ) { + $this->assertFalse( $st->isOK() ); + } else { + $this->assertFalse( $st->isGood() ); + $this->assertTrue( $st->isOK() ); + $val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK ); + $this->assertEquals( $expected, $val ); + } + } + + public static function provideParseErrors() { + return array( + array( 'aaa' ), + array( '{"j": 1 ] }' ), + ); + } + + /** + * @dataProvider provideParseErrors + * @param mixed $value + */ + public function testParseErrors( $value ) { + $st = FormatJson::parse( $value ); + $this->assertType( 'Status', $st ); + $this->assertFalse( $st->isOK() ); + } + + /** + * Generate a set of test cases for a particular combination of encoder options. + * + * @param array $unescapedGroups List of character groups to leave unescaped + * @return array Arrays of unencoded strings and corresponding encoded strings + */ + private static function getEncodeTestCases( array $unescapedGroups ) { + $groups = array( + 'always' => array( + // Forward slash (always unescaped) + '/' => '/', + + // Control characters + "\0" => '\u0000', + "\x08" => '\b', + "\t" => '\t', + "\n" => '\n', + "\r" => '\r', + "\f" => '\f', + "\x1f" => '\u001f', // representative example + + // Double quotes + '"' => '\"', + + // Backslashes + '\\' => '\\\\', + '\\\\' => '\\\\\\\\', + '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping + + // Line terminators + "\xe2\x80\xa8" => '\u2028', + "\xe2\x80\xa9" => '\u2029', + ), + 'unicode' => array( + "\xc3\xa9" => '\u00e9', + "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP + ), + 'xmlmeta' => array( + '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits + '>' => '\u003E', + '&' => '\u0026', + ), + ); + + $cases = array(); + foreach ( $groups as $name => $rules ) { + $leaveUnescaped = in_array( $name, $unescapedGroups ); + foreach ( $rules as $from => $to ) { + $cases[] = array( $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ); + } + } + + return $cases; + } +} diff --git a/tests/phpunit/includes/libs/CSSMinTest.php b/tests/phpunit/includes/libs/CSSMinTest.php new file mode 100644 index 00000000..43c50869 --- /dev/null +++ b/tests/phpunit/includes/libs/CSSMinTest.php @@ -0,0 +1,401 @@ +setMwGlobals( array( + 'wgServer' => $server, + 'wgCanonicalServer' => $server, + ) ); + } + + /** + * @dataProvider provideMinifyCases + * @covers CSSMin::minify + */ + public function testMinify( $code, $expectedOutput ) { + $minified = CSSMin::minify( $code ); + + $this->assertEquals( + $expectedOutput, + $minified, + 'Minified output should be in the form expected.' + ); + } + + public static function provideMinifyCases() { + return array( + // Whitespace + array( "\r\t\f \v\n\r", "" ), + array( "foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + + // Loose comments + array( "/* foo */", "" ), + array( "/*******\n foo\n *******/", "" ), + array( "/*!\n foo\n */", "" ), + + // Inline comments in various different places + array( "/* comment */foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo/* comment */, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo,/* comment */ bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar/* comment */ {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\t/* comment */prop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\tprop: /* comment */value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\tprop: value /* comment */;\n}", "foo,bar{prop:value }" ), + array( "foo, bar {\n\tprop: value; /* comment */\n}", "foo,bar{prop:value; }" ), + + // Keep track of things that aren't as minified as much as they + // could be (bug 35493) + array( 'foo { prop: value ;}', 'foo{prop:value }' ), + array( 'foo { prop : value; }', 'foo{prop :value}' ), + array( 'foo { prop: value ; }', 'foo{prop:value }' ), + array( 'foo { font-family: "foo" , "bar"; }', 'foo{font-family:"foo" ,"bar"}' ), + array( "foo { src:\n\turl('foo') ,\n\turl('bar') ; }", "foo{src:url('foo') ,url('bar') }" ), + + // Interesting cases with string values + // - Double quotes, single quotes + array( 'foo { content: ""; }', 'foo{content:""}' ), + array( "foo { content: ''; }", "foo{content:''}" ), + array( 'foo { content: "\'"; }', 'foo{content:"\'"}' ), + array( "foo { content: '\"'; }", "foo{content:'\"'}" ), + // - Whitespace in string values + array( 'foo { content: " "; }', 'foo{content:" "}' ), + ); + } + + /** + * This tests funky parameters to CSSMin::remap. testRemapRemapping tests + * the basic functionality. + * + * @dataProvider provideRemapCases + * @covers CSSMin::remap + */ + public function testRemap( $message, $params, $expectedOutput ) { + $remapped = call_user_func_array( 'CSSMin::remap', $params ); + + $messageAdd = " Case: $message"; + $this->assertEquals( + $expectedOutput, + $remapped, + 'CSSMin::remap should return the expected url form.' . $messageAdd + ); + } + + public static function provideRemapCases() { + // Parameter signature: + // CSSMin::remap( $code, $local, $remote, $embedData = true ) + return array( + array( + 'Simple case', + array( 'foo { prop: url(bar.png); }', false, 'http://example.org', false ), + 'foo { prop: url(http://example.org/bar.png); }', + ), + array( + 'Without trailing slash', + array( 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux', false ), + 'foo { prop: url(http://example.org/quux/../bar.png); }', + ), + array( + 'With trailing slash on remote (bug 27052)', + array( 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux/', false ), + 'foo { prop: url(http://example.org/quux/../bar.png); }', + ), + array( + 'Guard against stripping double slashes from query', + array( 'foo { prop: url(bar.png?corge=//grault); }', false, 'http://example.org/quux/', false ), + 'foo { prop: url(http://example.org/quux/bar.png?corge=//grault); }', + ), + array( + 'Expand absolute paths', + array( 'foo { prop: url(/w/skin/images/bar.png); }', false, 'http://example.org/quux', false ), + 'foo { prop: url(http://doc.example.org/w/skin/images/bar.png); }', + ), + ); + } + + /** + * This tests basic functionality of CSSMin::remap. testRemapRemapping tests funky parameters. + * + * @dataProvider provideRemapRemappingCases + * @covers CSSMin::remap + */ + public function testRemapRemapping( $message, $input, $expectedOutput ) { + $localPath = __DIR__ . '/../../data/cssmin/'; + $remotePath = 'http://localhost/w/'; + + $realOutput = CSSMin::remap( $input, $localPath, $remotePath ); + + $this->assertEquals( + $expectedOutput, + preg_replace( '/\d+-\d+-\d+T\d+:\d+:\d+Z/', 'timestamp', $realOutput ), + "CSSMin::remap: $message" + ); + } + + public static function provideRemapRemappingCases() { + // red.gif and green.gif are one-pixel 35-byte GIFs. + // large.png is a 35K PNG that should be non-embeddable. + // Full paths start with http://localhost/w/. + // Timestamps in output are replaced with 'timestamp'. + + // data: URIs for red.gif and green.gif + $red = 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs='; + $green = 'data:image/gif;base64,R0lGODlhAQABAIAAAACAADAAACwAAAAAAQABAAACAkQBADs='; + + return array( + array( + 'Regular file', + 'foo { background: url(red.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + 'Regular file (missing)', + 'foo { background: url(theColorOfHerHair.gif); }', + 'foo { background: url(http://localhost/w/theColorOfHerHair.gif); }', + ), + array( + 'Remote URL', + 'foo { background: url(http://example.org/w/foo.png); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Protocol-relative remote URL', + 'foo { background: url(//example.org/w/foo.png); }', + 'foo { background: url(//example.org/w/foo.png); }', + ), + array( + 'Remote URL with query', + 'foo { background: url(http://example.org/w/foo.png?query=yes); }', + 'foo { background: url(http://example.org/w/foo.png?query=yes); }', + ), + array( + 'Protocol-relative remote URL with query', + 'foo { background: url(//example.org/w/foo.png?query=yes); }', + 'foo { background: url(//example.org/w/foo.png?query=yes); }', + ), + array( + 'Domain-relative URL', + 'foo { background: url(/static/foo.png); }', + 'foo { background: url(http://doc.example.org/static/foo.png); }', + ), + array( + 'Domain-relative URL with query', + 'foo { background: url(/static/foo.png?query=yes); }', + 'foo { background: url(http://doc.example.org/static/foo.png?query=yes); }', + ), + array( + 'Remote URL (unnecessary quotes not preserved)', + 'foo { background: url("http://example.org/w/foo.png"); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Embedded file', + 'foo { /* @embed */ background: url(red.gif); }', + "foo { background: url($red); background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Embedded file, other comments before the rule', + "foo { /* Bar. */ /* @embed */ background: url(red.gif); }", + "foo { /* Bar. */ background: url($red); /* Bar. */ background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Can not re-embed data: URIs', + "foo { /* @embed */ background: url($red); }", + "foo { background: url($red); }", + ), + array( + 'Can not remap data: URIs', + "foo { background: url($red); }", + "foo { background: url($red); }", + ), + array( + 'Can not embed remote URLs', + 'foo { /* @embed */ background: url(http://example.org/w/foo.png); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Embedded file (inline @embed)', + 'foo { background: /* @embed */ url(red.gif); }', + "foo { background: url($red); " + . "background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Can not embed large files', + 'foo { /* @embed */ background: url(large.png); }', + "foo { background: url(http://localhost/w/large.png?timestamp); }", + ), + array( + 'Two regular files in one rule', + 'foo { background: url(red.gif), url(green.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp), ' + . 'url(http://localhost/w/green.gif?timestamp); }', + ), + array( + 'Two embedded files in one rule', + 'foo { /* @embed */ background: url(red.gif), url(green.gif); }', + "foo { background: url($red), url($green); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/green.gif?timestamp)!ie; }", + ), + array( + 'Two embedded files in one rule (inline @embed)', + 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(green.gif); }', + "foo { background: url($red), url($green); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/green.gif?timestamp)!ie; }", + ), + array( + 'Two embedded files in one rule (inline @embed), one too large', + 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(large.png); }', + "foo { background: url($red), url(http://localhost/w/large.png?timestamp); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/large.png?timestamp)!ie; }", + ), + array( + 'Practical example with some noise', + 'foo { /* @embed */ background: #f9f9f9 url(red.gif) 0 0 no-repeat; }', + "foo { background: #f9f9f9 url($red) 0 0 no-repeat; " + . "background: #f9f9f9 url(http://localhost/w/red.gif?timestamp) 0 0 no-repeat!ie; }", + ), + array( + 'Does not mess with other properties', + 'foo { color: red; background: url(red.gif); font-size: small; }', + 'foo { color: red; background: url(http://localhost/w/red.gif?timestamp); font-size: small; }', + ), + array( + 'Spacing and miscellanea not changed (1)', + 'foo { background: url(red.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + 'Spacing and miscellanea not changed (2)', + 'foo {background:url(red.gif)}', + 'foo {background:url(http://localhost/w/red.gif?timestamp)}', + ), + array( + 'Spaces within url() parentheses are ignored', + 'foo { background: url( red.gif ); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + '@import rule to local file (should we remap this?)', + '@import url(/styles.css)', + '@import url(http://doc.example.org/styles.css)', + ), + array( + '@import rule to URL (should we remap this?)', + '@import url(//localhost/styles.css?query=yes)', + '@import url(//localhost/styles.css?query=yes)', + ), + array( + 'Simple case with comments before url', + 'foo { prop: /* some {funny;} comment */ url(bar.png); }', + 'foo { prop: /* some {funny;} comment */ url(http://localhost/w/bar.png); }', + ), + array( + 'Simple case with comments after url', + 'foo { prop: url(red.gif)/* some {funny;} comment */ ; }', + 'foo { prop: url(http://localhost/w/red.gif?timestamp)/* some {funny;} comment */ ; }', + ), + array( + 'Embedded file with comment before url', + 'foo { /* @embed */ background: /* some {funny;} comment */ url(red.gif); }', + "foo { background: /* some {funny;} comment */ url($red); background: /* some {funny;} comment */ url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Embedded file with comments inside and outside the rule', + 'foo { /* @embed */ background: url(red.gif) /* some {foo;} comment */; /* some {bar;} comment */ }', + "foo { background: url($red) /* some {foo;} comment */; background: url(http://localhost/w/red.gif?timestamp) /* some {foo;} comment */!ie; /* some {bar;} comment */ }", + ), + array( + 'Embedded file with comment outside the rule', + 'foo { /* @embed */ background: url(red.gif); /* some {funny;} comment */ }', + "foo { background: url($red); background: url(http://localhost/w/red.gif?timestamp)!ie; /* some {funny;} comment */ }", + ), + array( + 'Rule with two urls, each with comments', + '{ background: /*asd*/ url(something.png); background: /*jkl*/ url(something.png); }', + '{ background: /*asd*/ url(http://localhost/w/something.png); background: /*jkl*/ url(http://localhost/w/something.png); }', + ), + array( + 'Sanity check for offending line from jquery.ui.theme.css (bug 60077)', + '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }', + '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(http://localhost/w/images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }', + ), + ); + } + + /** + * This tests basic functionality of CSSMin::buildUrlValue. + * + * @dataProvider provideBuildUrlValueCases + * @covers CSSMin::buildUrlValue + */ + public function testBuildUrlValue( $message, $input, $expectedOutput ) { + $this->assertEquals( + $expectedOutput, + CSSMin::buildUrlValue( $input ), + "CSSMin::buildUrlValue: $message" + ); + } + + public static function provideBuildUrlValueCases() { + return array( + array( + 'Full URL', + 'scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s', + 'url(scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s)', + ), + array( + 'data: URI', + 'data:image/png;base64,R0lGODlh/+==', + 'url(data:image/png;base64,R0lGODlh/+==)', + ), + array( + 'URL with quotes', + "https://en.wikipedia.org/wiki/Wendy's", + "url(\"https://en.wikipedia.org/wiki/Wendy's\")", + ), + array( + 'URL with parentheses', + 'https://en.wikipedia.org/wiki/Boston_(band)', + 'url("https://en.wikipedia.org/wiki/Boston_(band)")', + ), + ); + } + + /** + * Seperated because they are currently broken (bug 35492) + * + * @group Broken + * @dataProvider provideStringCases + * @covers CSSMin::remap + */ + public function testMinifyWithCSSStringValues( $code, $expectedOutput ) { + $this->testMinifyOutput( $code, $expectedOutput ); + } + + public static function provideStringCases() { + return array( + // String values should be respected + // - More than one space in a string value + array( 'foo { content: " "; }', 'foo{content:" "}' ), + // - Using a tab in a string value (turns into a space) + array( "foo { content: '\t'; }", "foo{content:'\t'}" ), + // - Using css-like syntax in string values + array( + 'foo::after { content: "{;}"; position: absolute; }', + 'foo::after{content:"{;}";position:absolute}' + ), + ); + } +} diff --git a/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/tests/phpunit/includes/libs/GenericArrayObjectTest.php new file mode 100644 index 00000000..4911f73a --- /dev/null +++ b/tests/phpunit/includes/libs/GenericArrayObjectTest.php @@ -0,0 +1,280 @@ + + */ +abstract class GenericArrayObjectTest extends MediaWikiTestCase { + + /** + * Returns objects that can serve as elements in the concrete + * GenericArrayObject deriving class being tested. + * + * @since 1.20 + * + * @return array + */ + abstract public function elementInstancesProvider(); + + /** + * Returns the name of the concrete class being tested. + * + * @since 1.20 + * + * @return string + */ + abstract public function getInstanceClass(); + + /** + * Provides instances of the concrete class being tested. + * + * @since 1.20 + * + * @return array + */ + public function instanceProvider() { + $instances = array(); + + foreach ( $this->elementInstancesProvider() as $elementInstances ) { + $instances[] = $this->getNew( $elementInstances[0] ); + } + + return $this->arrayWrap( $instances ); + } + + /** + * @since 1.20 + * + * @param array $elements + * + * @return GenericArrayObject + */ + protected function getNew( array $elements = array() ) { + $class = $this->getInstanceClass(); + + return new $class( $elements ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::__construct + */ + public function testConstructor( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( count( $elements ), $arrayObject->count() ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::isEmpty + */ + public function testIsEmpty( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( $elements === array(), $arrayObject->isEmpty() ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.20 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::offsetUnset + */ + public function testUnset( GenericArrayObject $list ) { + if ( $list->isEmpty() ) { + $this->assertTrue( true ); // We cannot test unset if there are no elements + } else { + $offset = $list->getIterator()->key(); + $count = $list->count(); + $list->offsetUnset( $offset ); + $this->assertEquals( $count - 1, $list->count() ); + } + + if ( !$list->isEmpty() ) { + $offset = $list->getIterator()->key(); + $count = $list->count(); + unset( $list[$offset] ); + $this->assertEquals( $count - 1, $list->count() ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::append + */ + public function testAppend( array $elements ) { + $list = $this->getNew(); + + $listSize = count( $elements ); + + foreach ( $elements as $element ) { + $list->append( $element ); + } + + $this->assertEquals( $listSize, $list->count() ); + + $list = $this->getNew(); + + foreach ( $elements as $element ) { + $list[] = $element; + } + + $this->assertEquals( $listSize, $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->append( $element ); + } ); + } + + /** + * @since 1.20 + * + * @param callable $function + * + * @covers GenericArrayObject::getObjectType + */ + protected function checkTypeChecks( $function ) { + $excption = null; + $list = $this->getNew(); + + $elementClass = $list->getObjectType(); + + foreach ( array( 42, 'foo', array(), new stdClass(), 4.2 ) as $element ) { + $validValid = $element instanceof $elementClass; + + try { + call_user_func( $function, $list, $element ); + $valid = true; + } catch ( InvalidArgumentException $exception ) { + $valid = false; + } + + $this->assertEquals( + $validValid, + $valid, + 'Object of invalid type got successfully added to a GenericArrayObject' + ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::offsetSet + */ + public function testOffsetSet( array $elements ) { + if ( $elements === array() ) { + $this->assertTrue( true ); + + return; + } + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 42, $element ); + $this->assertEquals( $element, $list->offsetGet( 42 ) ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list['oHai'] = $element; + $this->assertEquals( $element, $list['oHai'] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 9001, $element ); + $this->assertEquals( $element, $list[9001] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[0] ); + + $list = $this->getNew(); + $offset = 0; + + foreach ( $elements as $element ) { + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[$offset++] ); + } + + $this->assertEquals( count( $elements ), $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->offsetSet( mt_rand(), $element ); + } ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.21 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::getSerializationData + * @covers GenericArrayObject::serialize + * @covers GenericArrayObject::unserialize + */ + public function testSerialization( GenericArrayObject $list ) { + $serialization = serialize( $list ); + $copy = unserialize( $serialization ); + + $this->assertEquals( $serialization, serialize( $copy ) ); + $this->assertEquals( count( $list ), count( $copy ) ); + + $list = $list->getArrayCopy(); + $copy = $copy->getArrayCopy(); + + $this->assertArrayEquals( $list, $copy, true, true ); + } +} diff --git a/tests/phpunit/includes/libs/HashRingTest.php b/tests/phpunit/includes/libs/HashRingTest.php new file mode 100644 index 00000000..68dfea1f --- /dev/null +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -0,0 +1,56 @@ + 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ) ); + + $locations = array(); + for ( $i = 0; $i < 20; $i++ ) { + $locations[ "hello$i"] = $ring->getLocation( "hello$i" ); + } + $expectedLocations = array( + "hello0" => "s5", + "hello1" => "s6", + "hello2" => "s2", + "hello3" => "s5", + "hello4" => "s6", + "hello5" => "s4", + "hello6" => "s5", + "hello7" => "s4", + "hello8" => "s5", + "hello9" => "s5", + "hello10" => "s3", + "hello11" => "s6", + "hello12" => "s1", + "hello13" => "s3", + "hello14" => "s3", + "hello15" => "s5", + "hello16" => "s4", + "hello17" => "s6", + "hello18" => "s6", + "hello19" => "s3" + ); + + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + + $locations = array(); + for ( $i = 0; $i < 5; $i++ ) { + $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 ); + } + + $expectedLocations = array( + "hello0" => array( "s5", "s6" ), + "hello1" => array( "s6", "s4" ), + "hello2" => array( "s2", "s1" ), + "hello3" => array( "s5", "s6" ), + "hello4" => array( "s6", "s4" ), + ); + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + } +} diff --git a/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/tests/phpunit/includes/libs/IEUrlExtensionTest.php new file mode 100644 index 00000000..b7071230 --- /dev/null +++ b/tests/phpunit/includes/libs/IEUrlExtensionTest.php @@ -0,0 +1,173 @@ +assertEquals( + 'y', + IEUrlExtension::findIE6Extension( 'x.y' ), + 'Simple extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testSimpleNoExt() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'x' ), + 'No extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testEmpty() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '' ), + 'Empty string' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testQuestionMark() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '?' ), + 'Question mark only' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExtQuestionMark() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '.x?' ), + 'Extension then question mark' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testQuestionMarkExt() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '?.x' ), + 'Question mark then extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testInvalidChar() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '.x*' ), + 'Extension with invalid character' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testInvalidCharThenExtension() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '*.x' ), + 'Invalid character followed by an extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testMultipleQuestionMarks() { + $this->assertEquals( + 'c', + IEUrlExtension::findIE6Extension( 'a?b?.c?.d?e?f' ), + 'Multiple question marks' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExeException() { + $this->assertEquals( + 'd', + IEUrlExtension::findIE6Extension( 'a?b?.exe?.d?.e' ), + '.exe exception' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExeException2() { + $this->assertEquals( + 'exe', + IEUrlExtension::findIE6Extension( 'a?b?.exe' ), + '.exe exception 2' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testHash() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'a#b.c' ), + 'Hash character preceding extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testHash2() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'a?#b.c' ), + 'Hash character preceding extension 2' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testDotAtEnd() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '.' ), + 'Dot at end of string' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testTwoDots() { + $this->assertEquals( + 'z', + IEUrlExtension::findIE6Extension( 'x.y.z' ), + 'Two dots' + ); + } +} diff --git a/tests/phpunit/includes/libs/IPSetTest.php b/tests/phpunit/includes/libs/IPSetTest.php new file mode 100644 index 00000000..d4e5214a --- /dev/null +++ b/tests/phpunit/includes/libs/IPSetTest.php @@ -0,0 +1,252 @@ + expected (boolean) result against the config dataset. + */ + public static function provideIPSets() { + return array( + array( + 'old_list_subset', + array( + '208.80.152.162', + '10.64.0.123', + '10.64.0.124', + '10.64.0.125', + '10.64.0.126', + '10.64.0.127', + '10.64.0.128', + '10.64.0.129', + '10.64.32.104', + '10.64.32.105', + '10.64.32.106', + '10.64.32.107', + '91.198.174.45', + '91.198.174.46', + '91.198.174.47', + '91.198.174.57', + '2620:0:862:1:A6BA:DBFF:FE30:CFB3', + '91.198.174.58', + '2620:0:862:1:A6BA:DBFF:FE38:FFDA', + '208.80.152.16', + '208.80.152.17', + '208.80.152.18', + '208.80.152.19', + '91.198.174.102', + '91.198.174.103', + '91.198.174.104', + '91.198.174.105', + '91.198.174.106', + '91.198.174.107', + '91.198.174.81', + '2620:0:862:1:26B6:FDFF:FEF5:B2D4', + '91.198.174.82', + '2620:0:862:1:26B6:FDFF:FEF5:ABB4', + '10.20.0.113', + '2620:0:862:102:26B6:FDFF:FEF5:AD9C', + '10.20.0.114', + '2620:0:862:102:26B6:FDFF:FEF5:7C38', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.64.0.122' => false, + '10.64.0.123' => true, + '10.64.0.124' => true, + '10.64.0.129' => true, + '10.64.0.130' => false, + '91.198.174.81' => true, + '91.198.174.80' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:FFFF:FFFF:FFFF:FFFF' => false, + '2001:db8::1234' => false, + '2620:0:862:1:26b6:fdff:fef5:abb3' => false, + '2620:0:862:1:26b6:fdff:fef5:abb4' => true, + '2620:0:862:1:26b6:fdff:fef5:abb5' => false, + ), + ), + array( + 'new_cidr_set', + array( + '208.80.154.0/26', + '2620:0:861:1::/64', + '208.80.154.128/26', + '2620:0:861:2::/64', + '208.80.154.64/26', + '2620:0:861:3::/64', + '208.80.155.96/27', + '2620:0:861:4::/64', + '10.64.0.0/22', + '2620:0:861:101::/64', + '10.64.16.0/22', + '2620:0:861:102::/64', + '10.64.32.0/22', + '2620:0:861:103::/64', + '10.64.48.0/22', + '2620:0:861:107::/64', + '91.198.174.0/25', + '2620:0:862:1::/64', + '10.20.0.0/24', + '2620:0:862:102::/64', + '10.128.0.0/24', + '2620:0:863:101::/64', + '10.2.4.26', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.2.4.25' => false, + '10.2.4.26' => true, + '10.2.4.27' => false, + '10.20.0.255' => true, + '10.128.0.0' => true, + '10.64.17.55' => true, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => false, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => true, + ), + ), + array( + 'empty_set', + array(), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.2.4.25' => false, + '10.2.4.26' => false, + '10.2.4.27' => false, + '10.20.0.255' => false, + '10.128.0.0' => false, + '10.64.17.55' => false, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => false, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => false, + ), + ), + array( + 'edge_cases', + array( + '0.0.0.0', + '255.255.255.255', + '::', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', + '10.10.10.10/25', // host bits intentional + ), + array( + '0.0.0.0' => true, + '255.255.255.255' => true, + '10.2.4.25' => false, + '10.2.4.26' => false, + '10.2.4.27' => false, + '10.20.0.255' => false, + '10.128.0.0' => false, + '10.64.17.55' => false, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => true, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => false, + '10.10.9.255' => false, + '10.10.10.0' => true, + '10.10.10.1' => true, + '10.10.10.10' => true, + '10.10.10.126' => true, + '10.10.10.127' => true, + '10.10.10.128' => false, + '10.10.10.177' => false, + '10.10.10.255' => false, + '10.10.11.0' => false, + ), + ), + array( + 'exercise_optimizer', + array( + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffe:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffd:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffc:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffb:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffa:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:8000/113', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:0/113', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff8:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff7:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff6:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff5:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff4:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff3:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff2:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff1:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff0:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffef:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffee:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffec:0/111', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffeb:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffea:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe9:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe8:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe7:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe6:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe5:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe4:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe3:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe2:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe1:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe0:0/110', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffc0:0/107', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffa0:0/107', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '::' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ff9f:ffff' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffa0:0' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffc0:1234' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffed:ffff' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:fff4:4444' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:8080' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => true, + ), + ), + ); + } + + /** + * Validates IPSet loading and matching code + * + * @covers IPSet + * @dataProvider provideIPSets + */ + public function testIPSet( $desc, array $cfg, array $tests ) { + $ipset = new IPSet( $cfg ); + foreach ( $tests as $ip => $expected ) { + $result = $ipset->match( $ip ); + $this->assertEquals( $expected, $result, "Incorrect match() result for $ip in dataset $desc" ); + } + } +} diff --git a/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php new file mode 100644 index 00000000..c8795b2e --- /dev/null +++ b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php @@ -0,0 +1,204 @@ + bar", "" ), + array( "--> Foo", "" ), + array( "x --> y", "x-->y" ), + + // Semicolon insertion + array( "(function(){return\nx;})", "(function(){return\nx;})" ), + array( "throw\nx;", "throw\nx;" ), + array( "while(p){continue\nx;}", "while(p){continue\nx;}" ), + array( "while(p){break\nx;}", "while(p){break\nx;}" ), + array( "var\nx;", "var x;" ), + array( "x\ny;", "x\ny;" ), + array( "x\n++y;", "x\n++y;" ), + array( "x\n!y;", "x\n!y;" ), + array( "x\n{y}", "x\n{y}" ), + array( "x\n+y;", "x+y;" ), + array( "x\n(y);", "x(y);" ), + array( "5.\nx;", "5.\nx;" ), + array( "0xFF.\nx;", "0xFF.x;" ), + array( "5.3.\nx;", "5.3.x;" ), + + // Semicolon insertion between an expression having an inline + // comment after it, and a statement on the next line (bug 27046). + array( + "var a = this //foo bar \n for ( b = 0; c < d; b++ ) {}", + "var a=this\nfor(b=0;cparse( $minified, 'minify-test.js', 1 ); + + $this->assertEquals( + $expectedOutput, + $minified, + "Minified output should be in the form expected." + ); + } + + public static function provideBug32548() { + return array( + array( + // This one gets interpreted all together by the prior code; + // no break at the 'E' happens. + '1.23456789E55', + ), + array( + // This one breaks under the bad code; splits between 'E' and '+' + '1.23456789E+5', + ), + array( + // This one breaks under the bad code; splits between 'E' and '-' + '1.23456789E-5', + ), + ); + } + + /** + * @dataProvider provideBug32548 + * @covers JavaScriptMinifier::minify + * @todo give this test a real name explaining what is being tested here + */ + public function testBug32548Exponent( $num ) { + // Long line breaking was being incorrectly done between the base and + // exponent part of a number, causing a syntax error. The line should + // instead break at the start of the number. + $prefix = 'var longVarName' . str_repeat( '_', 973 ) . '='; + $suffix = ',shortVarName=0;'; + + $input = $prefix . $num . $suffix; + $expected = $prefix . "\n" . $num . $suffix; + + $minified = JavaScriptMinifier::minify( $input ); + + $this->assertEquals( $expected, $minified, "Line breaks must not occur in middle of exponent" ); + } +} diff --git a/tests/phpunit/includes/libs/MWMessagePackTest.php b/tests/phpunit/includes/libs/MWMessagePackTest.php new file mode 100644 index 00000000..f80f78df --- /dev/null +++ b/tests/phpunit/includes/libs/MWMessagePackTest.php @@ -0,0 +1,75 @@ +, which includes a + * serialization function. + */ + public static function providePacks() { + $tests = array( + array( 'nil', null, 'c0' ), + array( 'bool', true, 'c3' ), + array( 'bool', false, 'c2' ), + array( 'positive fixnum', 0, '00' ), + array( 'positive fixnum', 1, '01' ), + array( 'positive fixnum', 5, '05' ), + array( 'positive fixnum', 35, '23' ), + array( 'uint 8', 128, 'cc80' ), + array( 'uint 16', 1000, 'cd03e8' ), + array( 'uint 32', 100000, 'ce000186a0' ), + array( 'negative fixnum', -1, 'ff' ), + array( 'negative fixnum', -2, 'fe' ), + array( 'int 8', -128, 'd080' ), + array( 'int 8', -35, 'd0dd' ), + array( 'int 16', -1000, 'd1fc18' ), + array( 'int 32', -100000, 'd2fffe7960' ), + array( 'double', 0.1, 'cb3fb999999999999a' ), + array( 'double', 1.1, 'cb3ff199999999999a' ), + array( 'double', 123.456, 'cb405edd2f1a9fbe77' ), + array( 'fix raw', '', 'a0' ), + array( 'fix raw', 'foobar', 'a6666f6f626172' ), + array( + 'raw 16', + 'Lorem ipsum dolor sit amet amet.', + 'da00204c6f72656d20697073756d20646f6c6f722073697420616d657420616d65742e' + ), + array( + 'fix array', + array( 'abc', 'def', 'ghi' ), + '93a3616263a3646566a3676869' + ), + array( + 'fix map', + array( 'one' => 1, 'two' => 2 ), + '82a36f6e6501a374776f02' + ), + ); + + if ( PHP_INT_SIZE > 4 ) { + $tests[] = array( 'uint 64', 10000000000, 'cf00000002540be400' ); + $tests[] = array( 'int 64', -10000000000, 'd3fffffffdabf41c00' ); + $tests[] = array( 'int 64', -223372036854775807, 'd3fce66c50e2840001' ); + $tests[] = array( 'int 64', -9223372036854775807, 'd38000000000000001' ); + } + + return $tests; + } + + /** + * Verify that values are serialized correctly. + * @covers MWMessagePack::pack + * @dataProvider providePacks + */ + public function testPack( $type, $value, $expected ) { + $actual = bin2hex( MWMessagePack::pack( $value ) ); + $this->assertEquals( $expected, $actual, $type ); + } +} diff --git a/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php new file mode 100644 index 00000000..1a8a1e56 --- /dev/null +++ b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php @@ -0,0 +1,237 @@ +assertAttributeEquals( array(), 'cache', $cache, $msg ); + } + + /** + * Helper to fill a cache object passed by reference + */ + function fillCache( &$cache, $numEntries ) { + // Fill cache with three values + for ( $i = 1; $i <= $numEntries; $i++ ) { + $cache->set( "cache-key-$i", "prop-$i", "value-$i" ); + } + } + + /** + * Generates an array of what would be expected in cache for a given cache + * size and a number of entries filled in sequentially + */ + function getExpectedCache( $cacheMaxEntries, $entryToFill ) { + $expected = array(); + + if ( $entryToFill === 0 ) { + # The cache is empty! + return array(); + } elseif ( $entryToFill <= $cacheMaxEntries ) { + # Cache is not fully filled + $firstKey = 1; + } else { + # Cache overflowed + $firstKey = 1 + $entryToFill - $cacheMaxEntries; + } + + $lastKey = $entryToFill; + + for ( $i = $firstKey; $i <= $lastKey; $i++ ) { + $expected["cache-key-$i"] = array( "prop-$i" => "value-$i" ); + } + + return $expected; + } + + /** + * Highlight diff between assertEquals and assertNotSame + */ + public function testPhpUnitArrayEquality() { + $one = array( 'A' => 1, 'B' => 2 ); + $two = array( 'B' => 2, 'A' => 1 ); + $this->assertEquals( $one, $two ); // == + $this->assertNotSame( $one, $two ); // === + } + + /** + * @dataProvider provideInvalidConstructorArg + * @expectedException UnexpectedValueException + */ + public function testConstructorGivenInvalidValue( $maxSize ) { + new ProcessCacheLRUTestable( $maxSize ); + } + + /** + * Value which are forbidden by the constructor + */ + public static function provideInvalidConstructorArg() { + return array( + array( null ), + array( array() ), + array( new stdClass() ), + array( 0 ), + array( '5' ), + array( -1 ), + ); + } + + public function testAddAndGetAKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + // First set just one value + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $this->assertEquals( 1, $oneCache->getEntriesCount() ); + $this->assertTrue( $oneCache->has( 'cache-key', 'prop1' ) ); + $this->assertEquals( 'value1', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + public function testDeleteOldKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $oneCache->set( 'cache-key', 'prop1', 'value2' ); + $this->assertEquals( 'value2', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + /** + * This test that we properly overflow when filling a cache with + * a sequence of always different cache-keys. Meant to verify we correclty + * delete the older key. + * + * @dataProvider provideCacheFilling + * @param int $cacheMaxEntries Maximum entry the created cache will hold + * @param int $entryToFill Number of entries to insert in the created cache. + */ + public function testFillingCache( $cacheMaxEntries, $entryToFill, $msg = '' ) { + $cache = new ProcessCacheLRUTestable( $cacheMaxEntries ); + $this->fillCache( $cache, $entryToFill ); + + $this->assertSame( + $this->getExpectedCache( $cacheMaxEntries, $entryToFill ), + $cache->getCache(), + "Filling a $cacheMaxEntries entries cache with $entryToFill entries" + ); + } + + /** + * Provider for testFillingCache + */ + public static function provideCacheFilling() { + // ($cacheMaxEntries, $entryToFill, $msg='') + return array( + array( 1, 0 ), + array( 1, 1 ), + array( 1, 2 ), # overflow + array( 5, 33 ), # overflow + ); + } + + /** + * Create a cache with only one remaining entry then update + * the first inserted entry. Should bump it to the top. + */ + public function testReplaceExistingKeyShouldBumpEntryToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + // Fill cache leaving just one remaining slot + $this->fillCache( $cache, $maxEntries - 1 ); + + // Set an existing cache key + $cache->set( "cache-key-1", "prop-1", "new-value-for-1" ); + + $this->assertSame( + array( + 'cache-key-2' => array( 'prop-2' => 'value-2' ), + 'cache-key-1' => array( 'prop-1' => 'new-value-for-1' ), + ), + $cache->getCache() + ); + } + + public function testRecentlyAccessedKeyStickIn() { + $cache = new ProcessCacheLRUTestable( 2 ); + $cache->set( 'first', 'prop1', 'value1' ); + $cache->set( 'second', 'prop2', 'value2' ); + + // Get first + $cache->get( 'first', 'prop1' ); + // Cache a third value, should invalidate the least used one + $cache->set( 'third', 'prop3', 'value3' ); + + $this->assertFalse( $cache->has( 'second', 'prop2' ) ); + } + + /** + * This first create a full cache then update the value for the 2nd + * filled entry. + * Given a cache having 1,2,3 as key, updating 2 should bump 2 to + * the top of the queue with the new value: 1,3,2* (* = updated). + */ + public function testReplaceExistingKeyInAFullCacheShouldBumpToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + $this->fillCache( $cache, $maxEntries ); + + // Set an existing cache key + $cache->set( "cache-key-2", "prop-2", "new-value-for-2" ); + $this->assertSame( + array( + 'cache-key-1' => array( 'prop-1' => 'value-1' ), + 'cache-key-3' => array( 'prop-3' => 'value-3' ), + 'cache-key-2' => array( 'prop-2' => 'new-value-for-2' ), + ), + $cache->getCache() + ); + $this->assertEquals( 'new-value-for-2', + $cache->get( 'cache-key-2', 'prop-2' ) + ); + } + + public function testBumpExistingKeyToTop() { + $cache = new ProcessCacheLRUTestable( 3 ); + $this->fillCache( $cache, 3 ); + + // Set the very first cache key to a new value + $cache->set( "cache-key-1", "prop-1", "new value for 1" ); + $this->assertEquals( + array( + 'cache-key-2' => array( 'prop-2' => 'value-2' ), + 'cache-key-3' => array( 'prop-3' => 'value-3' ), + 'cache-key-1' => array( 'prop-1' => 'new value for 1' ), + ), + $cache->getCache() + ); + } +} + +/** + * Overrides some ProcessCacheLRU methods and properties accessibility. + */ +class ProcessCacheLRUTestable extends ProcessCacheLRU { + public $cache = array(); + + public function getCache() { + return $this->cache; + } + + public function getEntriesCount() { + return count( $this->cache ); + } +} diff --git a/tests/phpunit/includes/libs/RunningStatTest.php b/tests/phpunit/includes/libs/RunningStatTest.php new file mode 100644 index 00000000..dc5db82c --- /dev/null +++ b/tests/phpunit/includes/libs/RunningStatTest.php @@ -0,0 +1,79 @@ +points as $point ) { + $rstat->push( $point ); + } + + $mean = array_sum( $this->points ) / count( $this->points ); + $variance = array_sum( array_map( function ( $x ) use ( $mean ) { + return pow( $mean - $x, 2 ); + }, $this->points ) ) / ( count( $rstat ) - 1 ); + $stddev = sqrt( $variance ); + + $this->assertEquals( count( $rstat ), count( $this->points ) ); + $this->assertEquals( $rstat->min, min( $this->points ) ); + $this->assertEquals( $rstat->max, max( $this->points ) ); + $this->assertEquals( $rstat->getMean(), $mean ); + $this->assertEquals( $rstat->getVariance(), $variance ); + $this->assertEquals( $rstat->getStdDev(), $stddev ); + } + + /** + * When one RunningStat instance is merged into another, the state of the + * target RunningInstance should have the state that it would have had if + * all the data had been accumulated by it alone. + * @covers RunningStat::merge + * @covers RunningStat::count + */ + public function testRunningStatMerge() { + $expected = new RunningStat(); + + foreach( $this->points as $point ) { + $expected->push( $point ); + } + + // Split the data into two sets + $sets = array_chunk( $this->points, floor( count( $this->points ) / 2 ) ); + + // Accumulate the first half into one RunningStat object + $first = new RunningStat(); + foreach( $sets[0] as $point ) { + $first->push( $point ); + } + + // Accumulate the second half into another RunningStat object + $second = new RunningStat(); + foreach( $sets[1] as $point ) { + $second->push( $point ); + } + + // Merge the second RunningStat object into the first + $first->merge( $second ); + + $this->assertEquals( count( $first ), count( $this->points ) ); + $this->assertEquals( $first, $expected ); + } +} diff --git a/tests/phpunit/includes/logging/LogFormatterTest.php b/tests/phpunit/includes/logging/LogFormatterTest.php new file mode 100644 index 00000000..6210d098 --- /dev/null +++ b/tests/phpunit/includes/logging/LogFormatterTest.php @@ -0,0 +1,242 @@ +setMwGlobals( array( + 'wgLogTypes' => array( 'phpunit' ), + 'wgLogActionsHandlers' => array( 'phpunit/test' => 'LogFormatter', + 'phpunit/param' => 'LogFormatter' ), + 'wgUser' => User::newFromName( 'Testuser' ), + 'wgExtensionMessagesFiles' => array( 'LogTests' => __DIR__ . '/LogTests.i18n.php' ), + ) ); + + Language::getLocalisationCache()->recache( $wgLang->getCode() ); + + $this->user = User::newFromName( 'Testuser' ); + $this->title = Title::newMainPage(); + + $this->context = new RequestContext(); + $this->context->setUser( $this->user ); + $this->context->setTitle( $this->title ); + $this->context->setLanguage( $wgLang ); + } + + protected function tearDown() { + parent::tearDown(); + + global $wgLang; + Language::getLocalisationCache()->recache( $wgLang->getCode() ); + } + + public function newLogEntry( $action, $params ) { + $logEntry = new ManualLogEntry( 'phpunit', $action ); + $logEntry->setPerformer( $this->user ); + $logEntry->setTarget( $this->title ); + $logEntry->setComment( 'A very good reason' ); + + $logEntry->setParameters( $params ); + + return $logEntry; + } + + /** + * @covers LogFormatter::newFromEntry + */ + public function testNormalLogParams() { + $entry = $this->newLogEntry( 'test', array() ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $formatter->setShowUserToolLinks( false ); + $paramsWithoutTools = $formatter->getMessageParametersForTesting(); + unset( $formatter->parsedParameters ); + + $formatter->setShowUserToolLinks( true ); + $paramsWithTools = $formatter->getMessageParametersForTesting(); + + $userLink = Linker::userLink( + $this->user->getId(), + $this->user->getName() + ); + + $userTools = Linker::userToolLinksRedContribs( + $this->user->getId(), + $this->user->getName(), + $this->user->getEditCount() + ); + + $titleLink = Linker::link( $this->title, null, array(), array() ); + + // $paramsWithoutTools and $paramsWithTools should be only different + // in index 0 + $this->assertEquals( $paramsWithoutTools[1], $paramsWithTools[1] ); + $this->assertEquals( $paramsWithoutTools[2], $paramsWithTools[2] ); + + $this->assertEquals( $userLink, $paramsWithoutTools[0]['raw'] ); + $this->assertEquals( $userLink . $userTools, $paramsWithTools[0]['raw'] ); + + $this->assertEquals( $this->user->getName(), $paramsWithoutTools[1] ); + + $this->assertEquals( $titleLink, $paramsWithoutTools[2]['raw'] ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeRaw() { + $params = array( '4:raw:raw' => Linker::link( $this->title, null, array(), array() ) ); + $expected = Linker::link( $this->title, null, array(), array() ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeMsg() { + $params = array( '4:msg:msg' => 'log-description-phpunit' ); + $expected = wfMessage( 'log-description-phpunit' )->text(); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeMsgContent() { + $params = array( '4:msg-content:msgContent' => 'log-description-phpunit' ); + $expected = wfMessage( 'log-description-phpunit' )->inContentLanguage()->text(); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeNumber() { + global $wgLang; + + $params = array( '4:number:number' => 123456789 ); + $expected = $wgLang->formatNum( 123456789 ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeUserLink() { + $params = array( '4:user-link:userLink' => $this->user->getName() ); + $expected = Linker::userLink( + $this->user->getId(), + $this->user->getName() + ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeTitleLink() { + $params = array( '4:title-link:titleLink' => $this->title->getText() ); + $expected = Linker::link( $this->title, null, array(), array() ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypePlain() { + $params = array( '4:plain:plain' => 'Some plain text' ); + $expected = 'Some plain text'; + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getComment + */ + public function testLogComment() { + $entry = $this->newLogEntry( 'test', array() ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $comment = ltrim( Linker::commentBlock( $entry->getComment() ) ); + + $this->assertEquals( $comment, $formatter->getComment() ); + } +} diff --git a/tests/phpunit/includes/logging/LogTests.i18n.php b/tests/phpunit/includes/logging/LogTests.i18n.php new file mode 100644 index 00000000..78787ba1 --- /dev/null +++ b/tests/phpunit/includes/logging/LogTests.i18n.php @@ -0,0 +1,15 @@ + 'PHPUnit-log', + 'log-description-phpunit' => 'Log for PHPUnit-tests', + 'logentry-phpunit-test' => '$1 {{GENDER:$2|tests}} with page $3', + 'logentry-phpunit-param' => '$4', +); diff --git a/tests/phpunit/includes/mail/MailAddressTest.php b/tests/phpunit/includes/mail/MailAddressTest.php new file mode 100644 index 00000000..2d078120 --- /dev/null +++ b/tests/phpunit/includes/mail/MailAddressTest.php @@ -0,0 +1,63 @@ +assertInstanceOf( 'MailAddress', $ma ); + } + + /** + * @covers MailAddress::newFromUser + */ + public function testNewFromUser() { + $user = $this->getMock( 'User' ); + $user->expects( $this->any() )->method( 'getName' )->will( $this->returnValue( 'UserName' ) ); + $user->expects( $this->any() )->method( 'getEmail' )->will( $this->returnValue( 'foo@bar.baz' ) ); + $user->expects( $this->any() )->method( 'getRealName' )->will( $this->returnValue( 'Real name' ) ); + + $ma = MailAddress::newFromUser( $user ); + $this->assertInstanceOf( 'MailAddress', $ma ); + $this->setMwGlobals( 'wgEnotifUseRealName', true ); + $this->assertEquals( 'Real name ', $ma->toString() ); + $this->setMwGlobals( 'wgEnotifUseRealName', false ); + $this->assertEquals( 'UserName ', $ma->toString() ); + } + + /** + * @covers MailAddress::toString + * @dataProvider provideToString + */ + public function testToString( $useRealName, $address, $name, $realName, $expected ) { + if ( wfIsWindows() ) { + $this->markTestSkipped( 'This test only works on non-Windows platforms' ); + } + $this->setMwGlobals( 'wgEnotifUseRealName', $useRealName ); + $ma = new MailAddress( $address, $name, $realName ); + $this->assertEquals( $expected, $ma->toString() ); + } + + public static function provideToString() { + return array( + array( true, 'foo@bar.baz', 'FooBar', 'Foo Bar', 'Foo Bar ' ), + array( true, 'foo@bar.baz', 'UserName', null, 'UserName ' ), + array( true, 'foo@bar.baz', 'AUser', 'My real name', 'My real name ' ), + array( true, 'foo@bar.baz', 'A.user.name', 'my@real.name', '"my@real.name" ' ), + array( false, 'foo@bar.baz', 'AUserName', 'Some real name', 'AUserName ' ), + array( false, 'foo@bar.baz', '', '', 'foo@bar.baz' ), + array( true, 'foo@bar.baz', '', '', 'foo@bar.baz' ), + ); + } + + /** + * @covers MailAddress::__toString + */ + public function test__ToString() { + $ma = new MailAddress( 'some@email.com', 'UserName', 'A real name' ); + $this->assertEquals( $ma->toString(), (string)$ma ); + } + +} \ No newline at end of file diff --git a/tests/phpunit/includes/mail/UserMailerTest.php b/tests/phpunit/includes/mail/UserMailerTest.php new file mode 100644 index 00000000..dca8aeb9 --- /dev/null +++ b/tests/phpunit/includes/mail/UserMailerTest.php @@ -0,0 +1,14 @@ +assertEquals( + "=?UTF-8?Q?=C4=88u=20legebla=3F?=", + UserMailer::quotedPrintable( "\xc4\x88u legebla?", "UTF-8" ) ); + } + +} diff --git a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php new file mode 100644 index 00000000..c720d7b7 --- /dev/null +++ b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php @@ -0,0 +1,167 @@ +setMwGlobals( 'wgShowEXIF', false ); + + $this->filePath = __DIR__ . '/../../data/media/'; + } + + /** + * Test if having conflicting metadata values from different + * types of metadata, that the right one takes precedence. + * + * Basically the file has IPTC and XMP metadata, the + * IPTC should override the XMP, except for the multilingual + * translation (to en) where XMP should win. + * @covers BitmapMetadataHandler::Jpeg + */ + public function testMultilingualCascade() { + $this->checkPHPExtension( 'exif' ); + $this->checkPHPExtension( 'xml' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + '/Xmp-exif-multilingual_test.jpg' ); + + $expected = array( + 'x-default' => 'right(iptc)', + 'en' => 'right translation', + '_type' => 'lang' + ); + + $this->assertArrayHasKey( 'ImageDescription', $meta, + 'Did not extract any ImageDescription info?!' ); + + $this->assertEquals( $expected, $meta['ImageDescription'] ); + } + + /** + * Test for jpeg comments are being handled by + * BitmapMetadataHandler correctly. + * + * There's more extensive tests of comment extraction in + * JpegMetadataExtractorTests.php + * @covers BitmapMetadataHandler::Jpeg + */ + public function testJpegComment() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'jpeg-comment-utf.jpg' ); + + $this->assertEquals( 'UTF-8 JPEG Comment — ¼', + $meta['JPEGFileComment'][0] ); + } + + /** + * Make sure a bad iptc block doesn't stop the other metadata + * from being extracted. + * @covers BitmapMetadataHandler::Jpeg + */ + public function testBadIPTC() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-invalid-psir.jpg' ); + $this->assertEquals( 'Created with GIMP', $meta['JPEGFileComment'][0] ); + } + + /** + * @covers BitmapMetadataHandler::Jpeg + */ + public function testIPTCDates() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-timetest.jpg' ); + + $this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] ); + $this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] ); + } + + /** + * File has an invalid time (+ one valid but really weird time) + * that shouldn't be included + * @covers BitmapMetadataHandler::Jpeg + */ + public function testIPTCDatesInvalid() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-timetest-invalid.jpg' ); + + $this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] ); + $this->assertFalse( isset( $meta['DateTimeDigitized'] ) ); + } + + /** + * XMP data should take priority over iptc data + * when hash has been updated, but not when + * the hash is wrong. + * @covers BitmapMetadataHandler::addMetadata + * @covers BitmapMetadataHandler::getMetadataArray + */ + public function testMerging() { + $merger = new BitmapMetadataHandler(); + $merger->addMetadata( array( 'foo' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'bar' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'baz' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'fred' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'foo' => 'iptc (hash)' ), 'iptc-good-hash' ); + $merger->addMetadata( array( 'bar' => 'iptc (bad hash)' ), 'iptc-bad-hash' ); + $merger->addMetadata( array( 'baz' => 'iptc (bad hash)' ), 'iptc-bad-hash' ); + $merger->addMetadata( array( 'fred' => 'iptc (no hash)' ), 'iptc-no-hash' ); + $merger->addMetadata( array( 'baz' => 'exif' ), 'exif' ); + + $actual = $merger->getMetadataArray(); + $expected = array( + 'foo' => 'xmp', + 'bar' => 'iptc (bad hash)', + 'baz' => 'exif', + 'fred' => 'xmp', + ); + $this->assertEquals( $expected, $actual ); + } + + /** + * @covers BitmapMetadataHandler::png + */ + public function testPNGXMP() { + if ( !extension_loaded( 'xml' ) ) { + $this->markTestSkipped( "This test needs the xml extension." ); + } + $handler = new BitmapMetadataHandler(); + $result = $handler->png( $this->filePath . 'xmp.png' ); + $expected = array( + 'frameCount' => 0, + 'loopCount' => 1, + 'duration' => 0, + 'bitDepth' => 1, + 'colorType' => 'index-coloured', + 'metadata' => array( + 'SerialNumber' => '123456789', + '_MW_PNG_VERSION' => 1, + ), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @covers BitmapMetadataHandler::png + */ + public function testPNGNative() { + $handler = new BitmapMetadataHandler(); + $result = $handler->png( $this->filePath . 'Png-native-test.png' ); + $expected = 'http://example.com/url'; + $this->assertEquals( $expected, $result['metadata']['Identifier']['x-default'] ); + } + + /** + * @covers BitmapMetadataHandler::getTiffByteOrder + */ + public function testTiffByteOrder() { + $handler = new BitmapMetadataHandler(); + $res = $handler->getTiffByteOrder( $this->filePath . 'test.tiff' ); + $this->assertEquals( 'LE', $res ); + } +} diff --git a/tests/phpunit/includes/media/BitmapScalingTest.php b/tests/phpunit/includes/media/BitmapScalingTest.php new file mode 100644 index 00000000..1972c969 --- /dev/null +++ b/tests/phpunit/includes/media/BitmapScalingTest.php @@ -0,0 +1,140 @@ +setMwGlobals( array( + 'wgMaxImageArea' => 1.25e7, // 3500x3500 + 'wgCustomConvertCommand' => 'dummy', // Set so that we don't get client side rendering + ) ); + } + + /** + * @dataProvider provideNormaliseParams + * @covers BitmapHandler::normaliseParams + */ + public function testNormaliseParams( $fileDimensions, $expectedParams, $params, $msg ) { + $file = new FakeDimensionFile( $fileDimensions ); + $handler = new BitmapHandler; + $valid = $handler->normaliseParams( $file, $params ); + $this->assertTrue( $valid ); + $this->assertEquals( $expectedParams, $params, $msg ); + } + + public static function provideNormaliseParams() { + return array( + /* Regular resize operations */ + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 512 ), + 'Resizing with width set', + ), + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 512, 'height' => 768 ), + 'Resizing with height set too high', + ), + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 1024, 'height' => 384 ), + 'Resizing with height set', + ), + + /* Very tall images */ + array( + array( 1000, 100 ), + array( + 'width' => 5, 'height' => 1, + 'physicalWidth' => 5, 'physicalHeight' => 1, + 'page' => 1, + ), + array( 'width' => 5 ), + 'Very wide image', + ), + + array( + array( 100, 1000 ), + array( + 'width' => 1, 'height' => 10, + 'physicalWidth' => 1, 'physicalHeight' => 10, + 'page' => 1, + ), + array( 'width' => 1 ), + 'Very high image', + ), + array( + array( 100, 1000 ), + array( + 'width' => 1, 'height' => 5, + 'physicalWidth' => 1, 'physicalHeight' => 10, + 'page' => 1, + ), + array( 'width' => 10, 'height' => 5 ), + 'Very high image with height set', + ), + /* Max image area */ + array( + array( 4000, 4000 ), + array( + 'width' => 5000, 'height' => 5000, + 'physicalWidth' => 4000, 'physicalHeight' => 4000, + 'page' => 1, + ), + array( 'width' => 5000 ), + 'Bigger than max image size but doesn\'t need scaling', + ), + ); + } + + /** + * @covers BitmapHandler::doTransform + */ + public function testTooBigImage() { + $file = new FakeDimensionFile( array( 4000, 4000 ) ); + $handler = new BitmapHandler; + $params = array( 'width' => '3700' ); // Still bigger than max size. + $this->assertEquals( 'TransformParameterError', + get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) ); + } + + /** + * @covers BitmapHandler::doTransform + */ + public function testTooBigMustRenderImage() { + $file = new FakeDimensionFile( array( 4000, 4000 ) ); + $file->mustRender = true; + $handler = new BitmapHandler; + $params = array( 'width' => '5000' ); // Still bigger than max size. + $this->assertEquals( 'TransformParameterError', + get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) ); + } + + /** + * @covers BitmapHandler::getImageArea + */ + public function testImageArea() { + $file = new FakeDimensionFile( array( 7, 9 ) ); + $handler = new BitmapHandler; + $this->assertEquals( 63, $handler->getImageArea( $file ) ); + } +} diff --git a/tests/phpunit/includes/media/DjVuTest.php b/tests/phpunit/includes/media/DjVuTest.php new file mode 100644 index 00000000..c0871f19 --- /dev/null +++ b/tests/phpunit/includes/media/DjVuTest.php @@ -0,0 +1,69 @@ +isEnabled() ) { + $this->markTestSkipped( + 'This test needs the installation of the ddjvu, djvutoxml and djvudump tools' ); + } + + $this->handler = new DjVuHandler(); + } + + public function testGetImageSize() { + $this->assertArrayEquals( + array( 2480, 3508, 'DjVu', 'width="2480" height="3508"' ), + $this->handler->getImageSize( null, $this->filePath . '/LoremIpsum.djvu' ), + 'Test file LoremIpsum.djvu should have a size of 2480 * 3508' + ); + } + + public function testInvalidFile() { + $this->assertEquals( + 'a:1:{s:5:"error";s:25:"Error extracting metadata";}', + $this->handler->getMetadata( null, $this->filePath . '/some-nonexistent-file' ), + 'Getting metadata for an inexistent file should return false' + ); + } + + public function testPageCount() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertEquals( + 5, + $this->handler->pageCount( $file ), + 'Test file LoremIpsum.djvu should be detected as containing 5 pages' + ); + } + + public function testGetPageDimensions() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertArrayEquals( + array( 2480, 3508 ), + $this->handler->getPageDimensions( $file, 1 ), + 'Page 1 of test file LoremIpsum.djvu should have a size of 2480 * 3508' + ); + } + + public function testGetPageText() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertEquals( + "Lorem ipsum \n1 \n", + (string)$this->handler->getPageText( $file, 1 ), + "Text layer of page 1 of file LoremIpsum.djvu should be 'Lorem ipsum \n1 \n'" + ); + } +} diff --git a/tests/phpunit/includes/media/ExifBitmapTest.php b/tests/phpunit/includes/media/ExifBitmapTest.php new file mode 100644 index 00000000..41330f41 --- /dev/null +++ b/tests/phpunit/includes/media/ExifBitmapTest.php @@ -0,0 +1,146 @@ +checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new ExifBitmapHandler; + + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsOldBroken() { + $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::OLD_BROKEN_FILE ); + $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsBrokenFile() { + $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::BROKEN_FILE ); + $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsInvalid() { + $res = $this->handler->isMetadataValid( null, 'Something Invalid Here.' ); + $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testGoodMetadata() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsOldGood() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res ); + } + + /** + * Handle metadata from paged tiff handler (gotten via instant commons) gracefully. + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testPagedTiffHandledGracefully() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:6:{s:9:"page_data";a:1:{i:1;a:5:{s:5:"width";i:643;s:6:"height";i:448;s:5:"alpha";s:4:"true";s:4:"page";i:1;s:6:"pixels";i:288064;}}s:10:"page_count";i:1;s:10:"first_page";i:1;s:9:"last_page";i:1;s:4:"exif";a:9:{s:10:"ImageWidth";i:643;s:11:"ImageLength";i:448;s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:4;s:12:"RowsPerStrip";i:50;s:19:"PlanarConfiguration";i:1;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}s:21:"TIFF_METADATA_VERSION";s:3:"1.4";}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataLatest() { + $metadata = array( + 'foo' => array( 'First', 'Second', '_type' => 'ol' ), + 'MEDIAWIKI_EXIF_VERSION' => 2 + ); + $res = $this->handler->convertMetadataVersion( $metadata, 2 ); + $this->assertEquals( $metadata, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataToOld() { + $metadata = array( + 'foo' => array( 'First', 'Second', '_type' => 'ol' ), + 'bar' => array( 'First', 'Second', '_type' => 'ul' ), + 'baz' => array( 'First', 'Second' ), + 'fred' => 'Single', + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'foo' => "\n#First\n#Second", + 'bar' => "\n*First\n*Second", + 'baz' => "\n*First\n*Second", + 'fred' => 'Single', + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataSoftware() { + $metadata = array( + 'Software' => array( array( 'GIMP', '1.1' ) ), + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'Software' => 'GIMP (Version 1.1)', + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataSoftwareNormal() { + $metadata = array( + 'Software' => array( "GIMP 1.2", "vim" ), + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'Software' => "\n*GIMP 1.2\n*vim", + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } +} diff --git a/tests/phpunit/includes/media/ExifRotationTest.php b/tests/phpunit/includes/media/ExifRotationTest.php new file mode 100644 index 00000000..f0bd42a0 --- /dev/null +++ b/tests/phpunit/includes/media/ExifRotationTest.php @@ -0,0 +1,280 @@ +checkPHPExtension( 'exif' ); + + $this->handler = new BitmapHandler(); + + $this->setMwGlobals( array( + 'wgShowEXIF' => true, + 'wgEnableAutoRotation' => true, + ) ); + } + + /** + * Mark this test as creating thumbnail files. + */ + protected function createsThumbnails() { + return true; + } + + /** + * @dataProvider provideFiles + */ + public function testMetadata( $name, $type, $info ) { + if ( !$this->handler->canRotate() ) { + $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." ); + } + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * Same as before, but with auto-rotation set to auto. + * + * This sets scaler to image magick, which we should detect as + * supporting rotation. + * @dataProvider provideFiles + */ + public function testMetadataAutoRotate( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', null ); + $this->setMwGlobals( 'wgUseImageMagick', true ); + $this->setMwGlobals( 'wgUseImageResize', true ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * + * @dataProvider provideFiles + */ + public function testRotationRendering( $name, $type, $info, $thumbs ) { + if ( !$this->handler->canRotate() ) { + $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." ); + } + foreach ( $thumbs as $size => $out ) { + if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + ); + } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + 'height' => $matches[2] + ); + } else { + throw new MWException( 'bogus test data format ' . $size ); + } + + $file = $this->dataFile( $name, $type ); + $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE ); + + $this->assertEquals( + $out[0], + $thumb->getWidth(), + "$name: thumb reported width check for $size" + ); + $this->assertEquals( + $out[1], + $thumb->getHeight(), + "$name: thumb reported height check for $size" + ); + + $gis = getimagesize( $thumb->getLocalCopyPath() ); + if ( $out[0] > $info['width'] ) { + // Physical image won't be scaled bigger than the original. + $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" ); + } else { + $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" ); + } + } + } + + public static function provideFiles() { + return array( + array( + 'landscape-plain.jpg', + 'image/jpeg', + array( + 'width' => 1024, + 'height' => 768, + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ), + array( + 'portrait-rotated.jpg', + 'image/jpeg', + array( + 'width' => 768, // as rotated + 'height' => 1024, // as rotated + ), + array( + '800x600px' => array( 450, 600 ), + '9999x800px' => array( 600, 800 ), + '800px' => array( 800, 1067 ), + '600px' => array( 600, 800 ), + ) + ) + ); + } + + /** + * Same as before, but with auto-rotation disabled. + * @dataProvider provideFilesNoAutoRotate + */ + public function testMetadataNoAutoRotate( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', false ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * Same as before, but with auto-rotation set to auto and an image scaler that doesn't support it. + * @dataProvider provideFilesNoAutoRotate + */ + public function testMetadataAutoRotateUnsupported( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', null ); + $this->setMwGlobals( 'wgUseImageResize', false ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * + * @dataProvider provideFilesNoAutoRotate + */ + public function testRotationRenderingNoAutoRotate( $name, $type, $info, $thumbs ) { + $this->setMwGlobals( 'wgEnableAutoRotation', false ); + + foreach ( $thumbs as $size => $out ) { + if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + ); + } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + 'height' => $matches[2] + ); + } else { + throw new MWException( 'bogus test data format ' . $size ); + } + + $file = $this->dataFile( $name, $type ); + $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE ); + + $this->assertEquals( + $out[0], + $thumb->getWidth(), + "$name: thumb reported width check for $size" + ); + $this->assertEquals( + $out[1], + $thumb->getHeight(), + "$name: thumb reported height check for $size" + ); + + $gis = getimagesize( $thumb->getLocalCopyPath() ); + if ( $out[0] > $info['width'] ) { + // Physical image won't be scaled bigger than the original. + $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" ); + } else { + $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" ); + } + } + } + + public static function provideFilesNoAutoRotate() { + return array( + array( + 'landscape-plain.jpg', + 'image/jpeg', + array( + 'width' => 1024, + 'height' => 768, + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ), + array( + 'portrait-rotated.jpg', + 'image/jpeg', + array( + 'width' => 1024, // since not rotated + 'height' => 768, // since not rotated + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ) + ); + } + + const TEST_WIDTH = 100; + const TEST_HEIGHT = 200; + + /** + * @dataProvider provideBitmapExtractPreRotationDimensions + */ + public function testBitmapExtractPreRotationDimensions( $rotation, $expected ) { + $result = $this->handler->extractPreRotationDimensions( array( + 'physicalWidth' => self::TEST_WIDTH, + 'physicalHeight' => self::TEST_HEIGHT, + ), $rotation ); + $this->assertEquals( $expected, $result ); + } + + public static function provideBitmapExtractPreRotationDimensions() { + return array( + array( + 0, + array( self::TEST_WIDTH, self::TEST_HEIGHT ) + ), + array( + 90, + array( self::TEST_HEIGHT, self::TEST_WIDTH ) + ), + array( + 180, + array( self::TEST_WIDTH, self::TEST_HEIGHT ) + ), + array( + 270, + array( self::TEST_HEIGHT, self::TEST_WIDTH ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/ExifTest.php b/tests/phpunit/includes/media/ExifTest.php new file mode 100644 index 00000000..f3c05fb1 --- /dev/null +++ b/tests/phpunit/includes/media/ExifTest.php @@ -0,0 +1,47 @@ +checkPHPExtension( 'exif' ); + + $this->mediaPath = __DIR__ . '/../../data/media/'; + + $this->setMwGlobals( 'wgShowEXIF', true ); + } + + public function testGPSExtraction() { + $filename = $this->mediaPath . 'exif-gps.jpg'; + $seg = JpegMetadataExtractor::segmentSplitter( $filename ); + $exif = new Exif( $filename, $seg['byteOrder'] ); + $data = $exif->getFilteredData(); + $expected = array( + 'GPSLatitude' => 88.5180555556, + 'GPSLongitude' => -21.12357, + 'GPSAltitude' => -3.141592653, + 'GPSDOP' => '5/1', + 'GPSVersionID' => '2.2.0.0', + ); + $this->assertEquals( $expected, $data, '', 0.0000000001 ); + } + + public function testUnicodeUserComment() { + $filename = $this->mediaPath . 'exif-user-comment.jpg'; + $seg = JpegMetadataExtractor::segmentSplitter( $filename ); + $exif = new Exif( $filename, $seg['byteOrder'] ); + $data = $exif->getFilteredData(); + + $expected = array( + 'UserComment' => 'test⁔comment' + ); + $this->assertEquals( $expected, $data ); + } +} diff --git a/tests/phpunit/includes/media/FakeDimensionFile.php b/tests/phpunit/includes/media/FakeDimensionFile.php new file mode 100644 index 00000000..4b8f213e --- /dev/null +++ b/tests/phpunit/includes/media/FakeDimensionFile.php @@ -0,0 +1,31 @@ +dimensions = $dimensions; + } + + public function getWidth( $page = 1 ) { + return $this->dimensions[0]; + } + + public function getHeight( $page = 1 ) { + return $this->dimensions[1]; + } + + public function mustRender() { + return $this->mustRender; + } + + public function getPath() { + return ''; + } +} diff --git a/tests/phpunit/includes/media/FormatMetadataTest.php b/tests/phpunit/includes/media/FormatMetadataTest.php new file mode 100644 index 00000000..002e2cb9 --- /dev/null +++ b/tests/phpunit/includes/media/FormatMetadataTest.php @@ -0,0 +1,71 @@ +checkPHPExtension( 'exif' ); + $this->setMwGlobals( 'wgShowEXIF', true ); + } + + /** + * @covers File::formatMetadata + */ + public function testInvalidDate() { + $file = $this->dataFile( 'broken_exif_date.jpg', 'image/jpeg' ); + + // Throws an error if bug hit + $meta = $file->formatMetadata(); + $this->assertNotEquals( false, $meta, 'Valid metadata extracted' ); + + // Find date exif entry + $this->assertArrayHasKey( 'visible', $meta ); + $dateIndex = null; + foreach ( $meta['visible'] as $i => $data ) { + if ( $data['id'] == 'exif-datetimeoriginal' ) { + $dateIndex = $i; + } + } + $this->assertNotNull( $dateIndex, 'Date entry exists in metadata' ); + $this->assertEquals( '0000:01:00 00:02:27', + $meta['visible'][$dateIndex]['value'], + 'File with invalid date metadata (bug 29471)' ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideFlattenArray + * @covers FormatMetadata::flattenArray + */ + public function testFlattenArray( $vals, $type, $noHtml, $ctx, $expected ) { + $actual = FormatMetadata::flattenArray( $vals, $type, $noHtml, $ctx ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideFlattenArray() { + return array( + array( + array( 1, 2, 3 ), 'ul', false, false, + "
    • 1
    • \n
    • 2
    • \n
    • 3
    ", + ), + array( + array( 1, 2, 3 ), 'ol', false, false, + "
    1. 1
    2. \n
    3. 2
    4. \n
    5. 3
    ", + ), + array( + array( 1, 2, 3 ), 'ul', true, false, + "\n*1\n*2\n*3", + ), + array( + array( 1, 2, 3 ), 'ol', true, false, + "\n#1\n#2\n#3", + ), + // TODO: more test cases + ); + } +} diff --git a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php new file mode 100644 index 00000000..6aecd8b1 --- /dev/null +++ b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php @@ -0,0 +1,111 @@ +mediaPath = __DIR__ . '/../../data/media/'; + } + + /** + * Put in a file, and see if the metadata coming out is as expected. + * @param string $filename + * @param array $expected The extracted metadata. + * @dataProvider provideGetMetadata + * @covers GIFMetadataExtractor::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + + $xmpNugget = << + + + + + The interwebs + + + + Bawolff + + + A file to test GIF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +EOF; + $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat + + return array( + array( + 'nonanimated.gif', + array( + 'comment' => array( 'GIF test file ⁕ Created with GIMP' ), + 'duration' => 0.1, + 'frameCount' => 1, + 'looped' => false, + 'xmp' => '', + ) + ), + array( + 'animated.gif', + array( + 'comment' => array( 'GIF test file . Created with GIMP' ), + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'xmp' => '', + ) + ), + + array( + 'animated-xmp.gif', + array( + 'xmp' => $xmpNugget, + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'comment' => array( 'GIƒ·test·file' ), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/GIFTest.php b/tests/phpunit/includes/media/GIFTest.php new file mode 100644 index 00000000..87ffd995 --- /dev/null +++ b/tests/phpunit/includes/media/GIFTest.php @@ -0,0 +1,142 @@ +handler = new GIFHandler(); + } + + /** + * @covers GIFHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . '/README' ); + $this->assertEquals( GIFHandler::BROKEN_FILE, $res ); + } + + /** + * @param string $filename Basename of the file to check + * @param bool $expected Expected result. + * @dataProvider provideIsAnimated + * @covers GIFHandler::isAnimatedImage + */ + public function testIsAnimanted( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->isAnimatedImage( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsAnimated() { + return array( + array( 'animated.gif', true ), + array( 'nonanimated.gif', false ), + ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideGetImageArea + * @covers GIFHandler::getImageArea + */ + public function testGetImageArea( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetImageArea() { + return array( + array( 'animated.gif', 5400 ), + array( 'nonanimated.gif', 1350 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of GIFHandler + * @dataProvider provideIsMetadataValid + * @covers GIFHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( GIFHandler::BROKEN_FILE, GIFHandler::METADATA_GOOD ), + array( '', GIFHandler::METADATA_BAD ), + array( null, GIFHandler::METADATA_BAD ), + array( 'Something invalid!', GIFHandler::METADATA_BAD ), + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}', GIFHandler::METADATA_GOOD ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers GIFHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); + $this->assertEquals( unserialize( $expected ), unserialize( $actual ) ); + } + + public static function provideGetMetadata() { + return array( + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'nonanimated.gif', 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}' ), + array( 'animated-xmp.gif', 'a:4:{s:10:"frameCount";i:4;s:6:"looped";b:1;s:8:"duration";d:2.399999999999999911182158029987476766109466552734375;s:8:"metadata";a:5:{s:6:"Artist";s:7:"Bawolff";s:16:"ImageDescription";a:2:{s:9:"x-default";s:18:"A file to test GIF";s:5:"_type";s:4:"lang";}s:15:"SublocationDest";s:13:"The interwebs";s:14:"GIFFileComment";a:1:{i:0;s:16:"GIƒ·test·file";}s:15:"_MW_GIF_VERSION";i:1;}}' ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetIndependentMetaArray + * @covers GIFHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getCommonMetaArray( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetIndependentMetaArray() { + return array( + array( 'nonanimated.gif', array( + 'GIFFileComment' => array( + 'GIF test file ⁕ Created with GIMP', + ), + ) ), + array( 'animated-xmp.gif', + array( + 'Artist' => 'Bawolff', + 'ImageDescription' => array( + 'x-default' => 'A file to test GIF', + '_type' => 'lang', + ), + 'SublocationDest' => 'The interwebs', + 'GIFFileComment' => + array( + 'GIƒ·test·file', + ), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/IPTCTest.php b/tests/phpunit/includes/media/IPTCTest.php new file mode 100644 index 00000000..06542cfe --- /dev/null +++ b/tests/phpunit/includes/media/IPTCTest.php @@ -0,0 +1,85 @@ +assertEquals( 'UTF-8', $res ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharset88591() { + // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 + // This data doesn't specify a charset. We're supposed to guess + // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharset88591b() { + /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ + /* \xC3 = Ã, \xB8 = ¸ */ + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( 'ÃÃø' ), $res['Keywords'] ); + } + + /** + * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. + * What should happen is the first "\xC3\xC3" should be dropped as invalid, + * leaving \xC3\xB8, which is ø + * @covers IPTC::Parse + */ + public function testIPTCParseForcedUTFButInvalid() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" + . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( 'ø' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharsetUTF8() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } + + /** + * Testing something that has 2 values for keyword + * @covers IPTC::Parse + */ + public function testIPTCParseMulti() { + $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" + /* length */ . "\0\0\0\0\0\x0D" + . "\x1c\x02\x19" . "\x00\x01" . "\xBC" + . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼', '¼½' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseUTF8() { + // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. + $iptcData = + "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } +} diff --git a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php new file mode 100644 index 00000000..7c977d5a --- /dev/null +++ b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php @@ -0,0 +1,111 @@ +filePath = __DIR__ . '/../../data/media/'; + } + + /** + * We also use this test to test padding bytes don't + * screw stuff up + * + * @param string $file Filename + * + * @dataProvider provideUtf8Comment + */ + public function testUtf8Comment( $file ) { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . $file ); + $this->assertEquals( array( 'UTF-8 JPEG Comment — ¼' ), $res['COM'] ); + } + + public static function provideUtf8Comment() { + return array( + array( 'jpeg-comment-utf.jpg' ), + array( 'jpeg-padding-even.jpg' ), + array( 'jpeg-padding-odd.jpg' ), + ); + } + + /** The file is iso-8859-1, but it should get auto converted */ + public function testIso88591Comment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-iso8859-1.jpg' ); + $this->assertEquals( array( 'ISO-8859-1 JPEG Comment - ¼' ), $res['COM'] ); + } + + /** Comment values that are non-textual (random binary junk) should not be shown. + * The example test file has a comment with a 0x5 byte in it which is a control character + * and considered binary junk for our purposes. + */ + public function testBinaryCommentStripped() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-binary.jpg' ); + $this->assertEmpty( $res['COM'] ); + } + + /* Very rarely a file can have multiple comments. + * Order of comments is based on order inside the file. + */ + public function testMultipleComment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-multiple.jpg' ); + $this->assertEquals( array( 'foo', 'bar' ), $res['COM'] ); + } + + public function testXMPExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testPSIRExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = '50686f746f73686f7020332e30003842494d04040000000' + . '000181c02190004746573741c02190003666f6f1c020000020004'; + $this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) ); + } + + public function testXMPExtractionAltAppId() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-alt.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testIPTCHashComparisionNoHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-no-hash', $res ); + } + + public function testIPTCHashComparisionBadHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-bad-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-bad-hash', $res ); + } + + public function testIPTCHashComparisionGoodHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-good-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-good-hash', $res ); + } + + public function testExifByteOrder() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'exif-user-comment.jpg' ); + $expected = 'BE'; + $this->assertEquals( $expected, $res['byteOrder'] ); + } +} diff --git a/tests/phpunit/includes/media/JpegTest.php b/tests/phpunit/includes/media/JpegTest.php new file mode 100644 index 00000000..2436e7d9 --- /dev/null +++ b/tests/phpunit/includes/media/JpegTest.php @@ -0,0 +1,54 @@ +checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new JpegHandler; + } + + public function testInvalidFile() { + $file = $this->dataFile( 'README', 'image/jpeg' ); + $res = $this->handler->getMetadata( $file, $this->filePath . 'README' ); + $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res ); + } + + public function testJpegMetadataExtraction() { + $file = $this->dataFile( 'test.jpg', 'image/jpeg' ); + $res = $this->handler->getMetadata( $file, $this->filePath . 'test.jpg' ); + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $expected = 'a:7:{s:16:"ImageDescription";s:9:"Test file";s:11:"XResolution";s:4:"72/1";s:11:"YResolution";s:4:"72/1";s:14:"ResolutionUnit";i:2;s:16:"YCbCrPositioning";i:1;s:15:"JPEGFileComment";a:1:{i:0;s:17:"Created with GIMP";}s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + + // Unserialize in case serialization format ever changes. + $this->assertEquals( unserialize( $expected ), unserialize( $res ) ); + } + + /** + * @covers JpegHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray() { + $file = $this->dataFile( 'test.jpg', 'image/jpeg' ); + $res = $this->handler->getCommonMetaArray( $file ); + $expected = array( + 'ImageDescription' => 'Test file', + 'XResolution' => '72/1', + 'YResolution' => '72/1', + 'ResolutionUnit' => 2, + 'YCbCrPositioning' => 1, + 'JPEGFileComment' => array( + 'Created with GIMP', + ), + ); + + $this->assertEquals( $res, $expected ); + } +} diff --git a/tests/phpunit/includes/media/MediaHandlerTest.php b/tests/phpunit/includes/media/MediaHandlerTest.php new file mode 100644 index 00000000..d8cfcc45 --- /dev/null +++ b/tests/phpunit/includes/media/MediaHandlerTest.php @@ -0,0 +1,56 @@ + 50, + 'height' => 50, + 'tests' => array( + 50 => 50, + 17 => 17, + 18 => 18 ) ), + array( + 'width' => 366, + 'height' => 300, + 'tests' => array( + 50 => 61, + 17 => 21, + 18 => 22 ) ), + array( + 'width' => 300, + 'height' => 366, + 'tests' => array( + 50 => 41, + 17 => 14, + 18 => 15 ) ), + array( + 'width' => 100, + 'height' => 400, + 'tests' => array( + 50 => 12, + 17 => 4, + 18 => 4 ) ) ); + foreach ( $vals as $row ) { + $tests = $row['tests']; + $height = $row['height']; + $width = $row['width']; + foreach ( $tests as $max => $expected ) { + $y = round( $expected * $height / $width ); + $result = MediaHandler::fitBoxWidth( $width, $height, $max ); + $y2 = round( $result * $height / $width ); + $this->assertEquals( $expected, + $result, + "($width, $height, $max) wanted: {$expected}x$y, got: {$result}x$y2" ); + } + } + } +} diff --git a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php new file mode 100644 index 00000000..8f28158d --- /dev/null +++ b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php @@ -0,0 +1,86 @@ +filePath = $this->getFilePath(); + $containers = array( 'data' => $this->filePath ); + if ( $this->createsThumbnails() ) { + // We need a temp directory for the thumbnails + // the container is named 'temp-thumb' because it is the + // thumb directory for a FSRepo named "temp". + $containers['temp-thumb'] = $this->getNewTempDirectory(); + } + + $this->backend = new FSFileBackend( array( + 'name' => 'localtesting', + 'wikiId' => wfWikiId(), + 'containerPaths' => $containers + ) ); + $this->repo = new FSRepo( $this->getRepoOptions() ); + } + + /** + * @return array Argument for FSRepo constructor + */ + protected function getRepoOptions() { + return array( + 'name' => 'temp', + 'url' => 'http://localhost/thumbtest', + 'backend' => $this->backend + ); + } + + /** + * The result of this method will set the file path to use, + * as well as the protected member $filePath + * + * @return string Path where files are + */ + protected function getFilePath() { + return __DIR__ . '/../../data/media/'; + } + + /** + * Will the test create thumbnails (and thus do we need to set aside + * a temporary directory for them?) + * + * Override this method if your test case creates thumbnails + * + * @return bool + */ + protected function createsThumbnails() { + return false; + } + + /** + * Utility function: Get a new file object for a file on disk but not actually in db. + * + * File must be in the path returned by getFilePath() + * @param string $name File name + * @param string $type MIME type [optional] + * @return UnregisteredLocalFile + */ + protected function dataFile( $name, $type = null ) { + if ( !$type ) { + // Autodetect by file extension for the lazy. + $magic = MimeMagic::singleton(); + $parts = explode( $name, '.' ); + $type = $magic->guessTypesForExtension( $parts[count( $parts ) - 1] ); + } + return new UnregisteredLocalFile( false, $this->repo, + "mwstore://localtesting/data/$name", $type ); + } +} diff --git a/tests/phpunit/includes/media/PNGMetadataExtractorTest.php b/tests/phpunit/includes/media/PNGMetadataExtractorTest.php new file mode 100644 index 00000000..a9eaa9e7 --- /dev/null +++ b/tests/phpunit/includes/media/PNGMetadataExtractorTest.php @@ -0,0 +1,155 @@ +filePath = __DIR__ . '/../../data/media/'; + } + + /** + * Tests zTXt tag (compressed textual metadata) + */ + public function testPngNativetZtxt() { + $this->checkPHPExtension( 'zlib' ); + + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + $expected = "foo bar baz foo foo foo foof foo foo foo foo"; + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'Make', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['Make'] ); + + $this->assertEquals( $expected, $meta['Make']['x-default'] ); + } + + /** + * Test tEXt tag (Uncompressed textual metadata) + */ + public function testPngNativeText() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + $expected = "Some long image desc"; + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'ImageDescription', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['ImageDescription'] ); + $this->assertArrayHasKey( '_type', $meta['ImageDescription'] ); + + $this->assertEquals( $expected, $meta['ImageDescription']['x-default'] ); + } + + /** + * tEXt tags must be encoded iso-8859-1 (vs iTXt which are utf-8) + * Make sure non-ascii characters get converted properly + */ + public function testPngNativeTextNonAscii() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + // Note the Copyright symbol here is a utf-8 one + // (aka \xC2\xA9) where in the file its iso-8859-1 + // encoded as just \xA9. + $expected = "© 2010 Bawolff"; + + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'Copyright', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['Copyright'] ); + + $this->assertEquals( $expected, $meta['Copyright']['x-default'] ); + } + + /** + * Test extraction of pHYs tags, which can tell what the + * actual resolution of the image is (aka in dots per meter). + */ + /* + public function testPngPhysTag() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + + $this->assertEquals( '2835/100', $meta['XResolution'] ); + $this->assertEquals( '2835/100', $meta['YResolution'] ); + $this->assertEquals( 3, $meta['ResolutionUnit'] ); // 3 = cm + } + */ + + /** + * Given a normal static PNG, check the animation metadata returned. + */ + public function testStaticPngAnimationMetadata() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 0, $meta['frameCount'] ); + $this->assertEquals( 1, $meta['loopCount'] ); + $this->assertEquals( 0, $meta['duration'] ); + } + + /** + * Given an animated APNG image file + * check it gets animated metadata right. + */ + public function testApngAnimationMetadata() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Animated_PNG_example_bouncing_beach_ball.png' ); + + $this->assertEquals( 20, $meta['frameCount'] ); + // Note loop count of 0 = infinity + $this->assertEquals( 0, $meta['loopCount'] ); + $this->assertEquals( 1.5, $meta['duration'], '', 0.00001 ); + } + + public function testPngBitDepth8() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 8, $meta['bitDepth'] ); + } + + public function testPngBitDepth1() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + '1bit-png.png' ); + $this->assertEquals( 1, $meta['bitDepth'] ); + } + + public function testPngIndexColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 'index-coloured', $meta['colorType'] ); + } + + public function testPngRgbColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'rgb-png.png' ); + $this->assertEquals( 'truecolour-alpha', $meta['colorType'] ); + } + + public function testPngRgbNoAlphaColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'rgb-na-png.png' ); + $this->assertEquals( 'truecolour', $meta['colorType'] ); + } + + public function testPngGreyscaleColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'greyscale-png.png' ); + $this->assertEquals( 'greyscale-alpha', $meta['colorType'] ); + } + + public function testPngGreyscaleNoAlphaColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'greyscale-na-png.png' ); + $this->assertEquals( 'greyscale', $meta['colorType'] ); + } +} diff --git a/tests/phpunit/includes/media/PNGTest.php b/tests/phpunit/includes/media/PNGTest.php new file mode 100644 index 00000000..36872a75 --- /dev/null +++ b/tests/phpunit/includes/media/PNGTest.php @@ -0,0 +1,131 @@ +handler = new PNGHandler(); + } + + /** + * @covers PNGHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . '/README' ); + $this->assertEquals( PNGHandler::BROKEN_FILE, $res ); + } + + /** + * @param string $filename Basename of the file to check + * @param bool $expected Expected result. + * @dataProvider provideIsAnimated + * @covers PNGHandler::isAnimatedImage + */ + public function testIsAnimanted( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->isAnimatedImage( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsAnimated() { + return array( + array( 'Animated_PNG_example_bouncing_beach_ball.png', true ), + array( '1bit-png.png', false ), + ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideGetImageArea + * @covers PNGHandler::getImageArea + */ + public function testGetImageArea( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetImageArea() { + return array( + array( '1bit-png.png', 2500 ), + array( 'greyscale-png.png', 2500 ), + array( 'Png-native-test.png', 126000 ), + array( 'Animated_PNG_example_bouncing_beach_ball.png', 10000 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of PNGHandler + * @dataProvider provideIsMetadataValid + * @covers PNGHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( PNGHandler::BROKEN_FILE, PNGHandler::METADATA_GOOD ), + array( '', PNGHandler::METADATA_BAD ), + array( null, PNGHandler::METADATA_BAD ), + array( 'Something invalid!', PNGHandler::METADATA_BAD ), + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}', PNGHandler::METADATA_GOOD ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers PNGHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); +// $this->assertEquals( unserialize( $expected ), unserialize( $actual ) ); + $this->assertEquals( ( $expected ), ( $actual ) ); + } + + public static function provideGetMetadata() { + return array( + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'rgb-na-png.png', 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}' ), + array( 'xmp.png', 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:1;s:9:"colorType";s:14:"index-coloured";s:8:"metadata";a:2:{s:12:"SerialNumber";s:9:"123456789";s:15:"_MW_PNG_VERSION";i:1;}}' ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param array $expected Expected standard metadata + * @dataProvider provideGetIndependentMetaArray + * @covers PNGHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getCommonMetaArray( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetIndependentMetaArray() { + return array( + array( 'rgb-na-png.png', array() ), + array( 'xmp.png', + array( + 'SerialNumber' => '123456789', + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php new file mode 100644 index 00000000..ab33d1c2 --- /dev/null +++ b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php @@ -0,0 +1,160 @@ +assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgFilesWithXMLMetadata + */ + public function testGetXMLMetadata( $infile, $expected ) { + $r = new XMLReader(); + if ( !method_exists( $r, 'readInnerXML' ) ) { + $this->markTestSkipped( 'XMLReader::readInnerXML() does not exist (libxml >2.6.20 needed).' ); + + return; + } + $this->assertMetadata( $infile, $expected ); + } + + function assertMetadata( $infile, $expected ) { + try { + $data = SVGMetadataExtractor::getMetadata( $infile ); + $this->assertEquals( $expected, $data, 'SVG metadata extraction test' ); + } catch ( MWException $e ) { + if ( $expected === false ) { + $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' ); + } else { + throw $e; + } + } + } + + public static function provideSvgFiles() { + $base = __DIR__ . '/../../data/media'; + + return array( + array( + "$base/Wikimedia-logo.svg", + array( + 'width' => 1024, + 'height' => 1024, + 'originalWidth' => '1024', + 'originalHeight' => '1024', + 'translations' => array(), + ) + ), + array( + "$base/QA_icon.svg", + array( + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60', + 'originalHeight' => '60', + 'translations' => array(), + ) + ), + array( + "$base/Gtk-media-play-ltr.svg", + array( + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60.0000000', + 'originalHeight' => '60.0000000', + 'translations' => array(), + ) + ), + array( + "$base/Toll_Texas_1.svg", + // This file triggered bug 31719, needs entity expansion in the xmlns checks + array( + 'width' => 385, + 'height' => 385, + 'originalWidth' => '385', + 'originalHeight' => '385.0004883', + 'translations' => array(), + ) + ), + array( + "$base/Tux.svg", + array( + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'title' => 'Tux', + 'translations' => array(), + 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ) + ), + array( + "$base/Speech_bubbles.svg", + array( + 'width' => 627, + 'height' => 461, + 'originalWidth' => '17.7cm', + 'originalHeight' => '13cm', + 'translations' => array( + 'de' => SVGReader::LANG_FULL_MATCH, + 'fr' => SVGReader::LANG_FULL_MATCH, + 'nl' => SVGReader::LANG_FULL_MATCH, + 'tlh-ca' => SVGReader::LANG_FULL_MATCH, + 'tlh' => SVGReader::LANG_PREFIX_MATCH + ), + ) + ), + array( + "$base/Soccer_ball_animated.svg", + array( + 'width' => 150, + 'height' => 150, + 'originalWidth' => '150', + 'originalHeight' => '150', + 'animated' => true, + 'translations' => array() + ), + ), + ); + } + + public static function provideSvgFilesWithXMLMetadata() { + $base = __DIR__ . '/../../data/media'; + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $metadata = ' + + image/svg+xml + + + '; + // @codingStandardsIgnoreEnd + + $metadata = str_replace( "\r", '', $metadata ); // Windows compat + return array( + array( + "$base/US_states_by_total_state_tax_revenue.svg", + array( + 'height' => 593, + 'metadata' => $metadata, + 'width' => 959, + 'originalWidth' => '958.69', + 'originalHeight' => '592.78998', + 'translations' => array(), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/SVGTest.php b/tests/phpunit/includes/media/SVGTest.php new file mode 100644 index 00000000..8f7a0d69 --- /dev/null +++ b/tests/phpunit/includes/media/SVGTest.php @@ -0,0 +1,41 @@ +filePath = __DIR__ . '/../../data/media/'; + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new SvgHandler; + } + + /** + * @param string $filename + * @param array $expected The expected independent metadata + * @dataProvider providerGetIndependentMetaArray + * @covers SvgHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/svg+xml' ); + $res = $this->handler->getCommonMetaArray( $file ); + + $this->assertEquals( $res, $expected ); + } + + public static function providerGetIndependentMetaArray() { + return array( + array( 'Tux.svg', array( + 'ObjectName' => 'Tux', + 'ImageDescription' => + 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ) ), + array( 'Wikimedia-logo.svg', array() ) + ); + } +} diff --git a/tests/phpunit/includes/media/TiffTest.php b/tests/phpunit/includes/media/TiffTest.php new file mode 100644 index 00000000..d1148202 --- /dev/null +++ b/tests/phpunit/includes/media/TiffTest.php @@ -0,0 +1,45 @@ +checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->filePath = __DIR__ . '/../../data/media/'; + $this->handler = new TiffHandler; + } + + /** + * @covers TiffHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . 'README' ); + $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res ); + } + + /** + * @covers TiffHandler::getMetadata + */ + public function testTiffMetadataExtraction() { + $res = $this->handler->getMetadata( null, $this->filePath . 'test.tiff' ); + + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $expected = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + + // Re-unserialize in case there are subtle differences between how versions + // of php serialize stuff. + $this->assertEquals( unserialize( $expected ), unserialize( $res ) ); + } +} diff --git a/tests/phpunit/includes/media/XCFTest.php b/tests/phpunit/includes/media/XCFTest.php new file mode 100644 index 00000000..5b2de151 --- /dev/null +++ b/tests/phpunit/includes/media/XCFTest.php @@ -0,0 +1,78 @@ +handler = new XCFHandler(); + } + + + /** + * @param string $filename + * @param int $expectedWidth Width + * @param int $expectedHeight Height + * @dataProvider provideGetImageSize + * @covers XCFHandler::getImageSize + */ + public function testGetImageSize( $filename, $expectedWidth, $expectedHeight ) { + $file = $this->dataFile( $filename, 'image/x-xcf' ); + $actual = $this->handler->getImageSize( $file, $file->getLocalRefPath() ); + $this->assertEquals( $expectedWidth, $actual[0] ); + $this->assertEquals( $expectedHeight, $actual[1] ); + } + + public static function provideGetImageSize() { + return array( + array( '80x60-2layers.xcf', 80, 60 ), + array( '80x60-RGB.xcf', 80, 60 ), + array( '80x60-Greyscale.xcf', 80, 60 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of XCFHandler + * @dataProvider provideIsMetadataValid + * @covers XCFHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( '', XCFHandler::METADATA_BAD ), + array( serialize( array( 'error' => true ) ), XCFHandler::METADATA_GOOD ), + array( false, XCFHandler::METADATA_BAD ), + array( serialize( array( 'colorType' => 'greyscale-alpha' ) ), XCFHandler::METADATA_GOOD ), + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers XCFHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + return array( + array( '80x60-2layers.xcf', 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}' ), + array( '80x60-RGB.xcf', 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}' ), + array( '80x60-Greyscale.xcf', 'a:1:{s:9:"colorType";s:15:"greyscale-alpha";}' ), + ); + } +} diff --git a/tests/phpunit/includes/media/XMPTest.php b/tests/phpunit/includes/media/XMPTest.php new file mode 100644 index 00000000..6758e94c --- /dev/null +++ b/tests/phpunit/includes/media/XMPTest.php @@ -0,0 +1,223 @@ +checkPHPExtension( 'exif' ); # Requires libxml to do XMP parsing + } + + /** + * Put XMP in, compare what comes out... + * + * @param string $xmp The actual xml data. + * @param array $expected Expected result of parsing the xmp. + * @param string $info Short sentence on what's being tested. + * + * @throws Exception + * @dataProvider provideXMPParse + * + * @covers XMPReader::parse + */ + public function testXMPParse( $xmp, $expected, $info ) { + if ( !is_string( $xmp ) || !is_array( $expected ) ) { + throw new Exception( "Invalid data provided to " . __METHOD__ ); + } + $reader = new XMPReader; + $reader->parse( $xmp ); + $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 ); + } + + public static function provideXMPParse() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $data = array(); + + // $xmpFiles format: array of arrays with first arg file base name, + // with the actual file having .xmp on the end for the xmp + // and .result.php on the end for a php file containing the result + // array. Second argument is some info on what's being tested. + $xmpFiles = array( + array( '1', 'parseType=Resource test' ), + array( '2', 'Structure with mixed attribute and element props' ), + array( '3', 'Extra qualifiers (that should be ignored)' ), + array( '3-invalid', 'Test ignoring qualifiers that look like normal props' ), + array( '4', 'Flash as qualifier' ), + array( '5', 'Flash as qualifier 2' ), + array( '6', 'Multiple rdf:Description' ), + array( '7', 'Generic test of several property types' ), + array( 'flash', 'Test of Flash property' ), + array( 'invalid-child-not-struct', 'Test child props not in struct or ignored' ), + array( 'no-recognized-props', 'Test namespace and no recognized props' ), + array( 'no-namespace', 'Test non-namespaced attributes are ignored' ), + array( 'bag-for-seq', "Allow bag's instead of seq's. (bug 27105)" ), + array( 'utf16BE', 'UTF-16BE encoding' ), + array( 'utf16LE', 'UTF-16LE encoding' ), + array( 'utf32BE', 'UTF-32BE encoding' ), + array( 'utf32LE', 'UTF-32LE encoding' ), + array( 'xmpExt', 'Extended XMP missing second part' ), + array( 'gps', 'Handling of exif GPS parameters in XMP' ), + ); + + $xmpFiles[] = array( 'doctype-included', 'XMP includes doctype' ); + + foreach ( $xmpFiles as $file ) { + $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' ); + // I'm not sure if this is the best way to handle getting the + // result array, but it seems kind of big to put directly in the test + // file. + $result = null; + include $xmpPath . $file[0] . '.result.php'; + $data[] = array( $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ); + } + + return $data; + } + + /** Test ExtendedXMP block support. (Used when the XMP has to be split + * over multiple jpeg segments, due to 64k size limit on jpeg segments. + * + * @todo This is based on what the standard says. Need to find a real + * world example file to double check the support for this is right. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMP() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + 'FNumber' => '2/10', + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * This test has an extended XMP block with a wrong guid (md5sum) + * and thus should only return the StandardXMP, not the ExtendedXMP. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPWithWrongGUID() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit. + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * Have a high offset to simulate a missing packet, + * which should cause it to ignore the ExtendedXMP packet. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPMissingPacket() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 2048 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * Test for multi-section, hostile XML + * @covers checkParseSafety + */ + public function testCheckParseSafety() { + + // Test for detection + $xmpPath = __DIR__ . '/../../data/xmp/'; + $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' ); + $this->assertEquals( + array(), + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + fclose( $file ); + unset( $reader ); + + // Test for false positives + $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertTrue( + $valid, + 'Check for false-positive detecting doctype in fragmented XML' + ); + $this->assertEquals( + array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) + ), + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + } +} diff --git a/tests/phpunit/includes/media/XMPValidateTest.php b/tests/phpunit/includes/media/XMPValidateTest.php new file mode 100644 index 00000000..ebec8f6c --- /dev/null +++ b/tests/phpunit/includes/media/XMPValidateTest.php @@ -0,0 +1,50 @@ +assertEquals( $expected, $value ); + } + + public static function provideDates() { + /* For reference valid date formats are: + * YYYY + * YYYY-MM + * YYYY-MM-DD + * YYYY-MM-DDThh:mmTZD + * YYYY-MM-DDThh:mm:ssTZD + * YYYY-MM-DDThh:mm:ss.sTZD + * (Time zone is optional) + */ + return array( + array( '1992', '1992' ), + array( '1992-04', '1992:04' ), + array( '1992-02-01', '1992:02:01' ), + array( '2011-09-29', '2011:09:29' ), + array( '1982-12-15T20:12', '1982:12:15 20:12' ), + array( '1982-12-15T20:12Z', '1982:12:15 20:12' ), + array( '1982-12-15T20:12+02:30', '1982:12:15 22:42' ), + array( '1982-12-15T01:12-02:30', '1982:12:14 22:42' ), + array( '1982-12-15T20:12:11', '1982:12:15 20:12:11' ), + array( '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ), + array( '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ), + array( '2045-12-15T20:12:11', '2045:12:15 20:12:11' ), + array( '1867-06-01T15:00:00', '1867:06:01 15:00:00' ), + /* some invalid ones */ + array( '2001--12', null ), + array( '2001-5-12', null ), + array( '2001-5-12TZ', null ), + array( '2001-05-12T15', null ), + array( '2001-12T15:13', null ), + ); + } +} diff --git a/tests/phpunit/includes/normal/CleanUpTest.php b/tests/phpunit/includes/normal/CleanUpTest.php new file mode 100644 index 00000000..f4b469b8 --- /dev/null +++ b/tests/phpunit/includes/normal/CleanUpTest.php @@ -0,0 +1,409 @@ + + * https://www.mediawiki.org/ + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Additional tests for UtfNormal::cleanUp() function, inclusion + * regression checks for known problems. + * Requires PHPUnit. + * + * @ingroup UtfNormal + * @group Large + * + * @todo covers tags, will be UtfNormal::cleanUp once the below is resolved + * @todo split me into test methods and providers per the below comment + * + * We ignore code coverage for this test suite until they are rewritten + * to use data providers (bug 46561). + * @codeCoverageIgnore + */ +class CleanUpTest extends MediaWikiTestCase { + /** @todo document */ + public function testAscii() { + $text = 'This is plain ASCII text.'; + $this->assertEquals( $text, UtfNormal::cleanUp( $text ) ); + } + + /** @todo document */ + public function testNull() { + $text = "a \x00 null"; + $expect = "a \xef\xbf\xbd null"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testLatin() { + $text = "L'\xc3\xa9cole"; + $this->assertEquals( $text, UtfNormal::cleanUp( $text ) ); + } + + /** @todo document */ + public function testLatinNormal() { + $text = "L'e\xcc\x81cole"; + $expect = "L'\xc3\xa9cole"; + $this->assertEquals( $expect, UtfNormal::cleanUp( $text ) ); + } + + /** + * This test is *very* expensive! + * @todo document + */ + function XtestAllChars() { + $rep = UTF8_REPLACEMENT; + for ( $i = 0x0; $i < UNICODE_MAX; $i++ ) { + $char = codepointToUtf8( $i ); + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%04X", $i ); + + if ( $i % 0x1000 == 0 ) { + echo "U+$x\n"; + } + + if ( $i == 0x0009 || + $i == 0x000a || + $i == 0x000d || + ( $i > 0x001f && $i < UNICODE_SURROGATE_FIRST ) || + ( $i > UNICODE_SURROGATE_LAST && $i < 0xfffe ) || + ( $i > 0xffff && $i <= UNICODE_MAX ) + ) { + if ( isset( UtfNormal::$utfCanonicalComp[$char] ) + || isset( UtfNormal::$utfCanonicalDecomp[$char] ) + ) { + $comp = UtfNormal::NFC( $char ); + $this->assertEquals( + bin2hex( $comp ), + bin2hex( $clean ), + "U+$x should be decomposed" ); + } else { + $this->assertEquals( + bin2hex( $char ), + bin2hex( $clean ), + "U+$x should be intact" ); + } + } else { + $this->assertEquals( bin2hex( $rep ), bin2hex( $clean ), $x ); + } + } + } + + /** @todo document */ + public static function provideAllBytes() { + return array( + array( '', '' ), + array( 'x', '' ), + array( '', 'x' ), + array( 'x', 'x' ), + ); + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testBytes( $head, $tail ) { + for ( $i = 0x0; $i < 256; $i++ ) { + $char = $head . chr( $i ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X", $i ); + + if ( $i == 0x0009 || + $i == 0x000a || + $i == 0x000d || + ( $i > 0x001f && $i < 0x80 ) + ) { + $this->assertEquals( + bin2hex( $char ), + bin2hex( $clean ), + "ASCII byte $x should be intact" ); + if ( $char != $clean ) { + return; + } + } else { + $norm = $head . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden byte $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } + } + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testDoubleBytes( $head, $tail ) { + for ( $first = 0xc0; $first < 0x100; $first += 2 ) { + for ( $second = 0x80; $second < 0x100; $second += 2 ) { + $char = $head . chr( $first ) . chr( $second ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X,%02X", $first, $second ); + if ( $first > 0xc1 && + $first < 0xe0 && + $second < 0xc0 + ) { + $norm = UtfNormal::NFC( $char ); + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Pair $x should be intact" ); + if ( $norm != $clean ) { + return; + } + } elseif ( $first > 0xfd || $second > 0xbf ) { + # fe and ff are not legal head bytes -- expect two replacement chars + $norm = $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden pair $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } else { + $norm = $head . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden pair $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } + } + } + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testTripleBytes( $head, $tail ) { + for ( $first = 0xc0; $first < 0x100; $first += 2 ) { + for ( $second = 0x80; $second < 0x100; $second += 2 ) { + #for( $third = 0x80; $third < 0x100; $third++ ) { + for ( $third = 0x80; $third < 0x81; $third++ ) { + $char = $head . chr( $first ) . chr( $second ) . chr( $third ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X,%02X,%02X", $first, $second, $third ); + + if ( $first >= 0xe0 && + $first < 0xf0 && + $second < 0xc0 && + $third < 0xc0 + ) { + if ( $first == 0xe0 && $second < 0xa0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Overlong triplet $x should be rejected" ); + } elseif ( $first == 0xed && + ( chr( $first ) . chr( $second ) . chr( $third ) ) >= UTF8_SURROGATE_FIRST + ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Surrogate triplet $x should be rejected" ); + } else { + $this->assertEquals( + bin2hex( UtfNormal::NFC( $char ) ), + bin2hex( $clean ), + "Triplet $x should be intact" ); + } + } elseif ( $first > 0xc1 && $first < 0xe0 && $second < 0xc0 ) { + $this->assertEquals( + bin2hex( UtfNormal::NFC( $head . chr( $first ) . + chr( $second ) ) . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Valid 2-byte $x + broken tail" ); + } elseif ( $second > 0xc1 && $second < 0xe0 && $third < 0xc0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . + UtfNormal::NFC( chr( $second ) . chr( $third ) . $tail ) ), + bin2hex( $clean ), + "Broken head + valid 2-byte $x" ); + } elseif ( ( $first > 0xfd || $second > 0xfd ) && + ( ( $second > 0xbf && $third > 0xbf ) || + ( $second < 0xc0 && $third < 0xc0 ) || + ( $second > 0xfd ) || + ( $third > 0xfd ) ) + ) { + # fe and ff are not legal head bytes -- expect three replacement chars + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } elseif ( $first > 0xc2 && $second < 0xc0 && $third < 0xc0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } else { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } + } + } + } + } + + /** @todo document */ + public function testChunkRegression() { + # Check for regression against a chunking bug + $text = "\x46\x55\xb8" . + "\xdc\x96" . + "\xee" . + "\xe7" . + "\x44" . + "\xaa" . + "\x2f\x25"; + $expect = "\x46\x55\xef\xbf\xbd" . + "\xdc\x96" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x44" . + "\xef\xbf\xbd" . + "\x2f\x25"; + + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testInterposeRegression() { + $text = "\x4e\x30" . + "\xb1" . # bad tail + "\x3a" . + "\x92" . # bad tail + "\x62\x3a" . + "\x84" . # bad tail + "\x43" . + "\xc6" . # bad head + "\x3f" . + "\x92" . # bad tail + "\xad" . # bad tail + "\x7d" . + "\xd9\x95"; + + $expect = "\x4e\x30" . + "\xef\xbf\xbd" . + "\x3a" . + "\xef\xbf\xbd" . + "\x62\x3a" . + "\xef\xbf\xbd" . + "\x43" . + "\xef\xbf\xbd" . + "\x3f" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x7d" . + "\xd9\x95"; + + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testOverlongRegression() { + $text = "\x67" . + "\x1a" . # forbidden ascii + "\xea" . # bad head + "\xc1\xa6" . # overlong sequence + "\xad" . # bad tail + "\x1c" . # forbidden ascii + "\xb0" . # bad tail + "\x3c" . + "\x9e"; # bad tail + $expect = "\x67" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x3c" . + "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testSurrogateRegression() { + $text = "\xed\xb4\x96" . # surrogate 0xDD16 + "\x83" . # bad tail + "\xb4" . # bad tail + "\xac"; # bad head + $expect = "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testBomRegression() { + $text = "\xef\xbf\xbe" . # U+FFFE, illegal char + "\xb2" . # bad tail + "\xef" . # bad head + "\x59"; + $expect = "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x59"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testForbiddenRegression() { + $text = "\xef\xbf\xbf"; # U+FFFF, illegal char + $expect = "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testHangulRegression() { + $text = "\xed\x9c\xaf" . # Hangul char + "\xe1\x87\x81"; # followed by another final jamo + $expect = $text; # Should *not* change. + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } +} diff --git a/tests/phpunit/includes/objectcache/BagOStuffTest.php b/tests/phpunit/includes/objectcache/BagOStuffTest.php new file mode 100644 index 00000000..987b6e64 --- /dev/null +++ b/tests/phpunit/includes/objectcache/BagOStuffTest.php @@ -0,0 +1,147 @@ + + */ +class BagOStuffTest extends MediaWikiTestCase { + private $cache; + + 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->cache->delete( wfMemcKey( 'test' ) ); + } + + public function testMerge() { + $key = wfMemcKey( 'test' ); + + $usleep = 0; + + /** + * Callback method: append "merged" to whatever is in cache. + * + * @param BagOStuff $cache + * @param string $key + * @param int $existingValue + * @use int $usleep + * @return int + */ + $callback = function ( BagOStuff $cache, $key, $existingValue ) use ( &$usleep ) { + // let's pretend this is an expensive callback to test concurrent merge attempts + usleep( $usleep ); + + if ( $existingValue === false ) { + return 'merged'; + } + + return $existingValue . 'merged'; + }; + + // merge on non-existing value + $merged = $this->cache->merge( $key, $callback, 0 ); + $this->assertTrue( $merged ); + $this->assertEquals( $this->cache->get( $key ), 'merged' ); + + // merge on existing value + $merged = $this->cache->merge( $key, $callback, 0 ); + $this->assertTrue( $merged ); + $this->assertEquals( $this->cache->get( $key ), 'mergedmerged' ); + + /* + * 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 ) { + // 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 + $merged = $this->cache->merge( $key, $callback, 0, 1 ); + + // merge has failed because child process was merging (and we only attempted once) + $this->assertFalse( $merged ); + + // make sure the child's merge is completed and verify + usleep( 3000 ); + $this->assertEquals( $this->cache->get( $key ), 'mergedmergedmerged' ); + } else { + $this->cache->merge( $key, $callback, 0, 1 ); + + // 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 testAdd() { + $key = wfMemcKey( 'test' ); + $this->assertTrue( $this->cache->add( $key, 'test' ) ); + } + + public function testGet() { + $value = array( 'this' => 'is', 'a' => 'test' ); + + $key = wfMemcKey( 'test' ); + $this->cache->add( $key, $value ); + $this->assertEquals( $this->cache->get( $key ), $value ); + } + + /** + * @covers BagOStuff::incr + */ + public function testIncr() { + $key = wfMemcKey( 'test' ); + $this->cache->add( $key, 0 ); + $this->cache->incr( $key ); + $expectedValue = 1; + $actualValue = $this->cache->get( $key ); + $this->assertEquals( $expectedValue, $actualValue, 'Value should be 1 after incrementing' ); + } + + public function testGetMulti() { + $value1 = array( 'this' => 'is', 'a' => 'test' ); + $value2 = array( 'this' => 'is', 'another' => 'test' ); + + $key1 = wfMemcKey( 'test1' ); + $key2 = wfMemcKey( 'test2' ); + + $this->cache->add( $key1, $value1 ); + $this->cache->add( $key2, $value2 ); + + $this->assertEquals( + $this->cache->getMulti( array( $key1, $key2 ) ), + array( $key1 => $value1, $key2 => $value2 ) + ); + + // cleanup + $this->cache->delete( $key1 ); + $this->cache->delete( $key2 ); + } +} diff --git a/tests/phpunit/includes/parser/MagicVariableTest.php b/tests/phpunit/includes/parser/MagicVariableTest.php new file mode 100644 index 00000000..17226113 --- /dev/null +++ b/tests/phpunit/includes/parser/MagicVariableTest.php @@ -0,0 +1,229 @@ +setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => $contLang, + ) ); + + $this->testParser = new Parser(); + $this->testParser->Options( ParserOptions::newFromUserAndLang( new User, $contLang ) ); + + # initialize parser output + $this->testParser->clearState(); + + # Needs a title to do magic word stuff + $title = Title::newFromText( 'Tests' ); + # Else it needs a db connection just to check if it's a redirect + # (when deciding the page language). + $title->mRedirect = false; + + $this->testParser->setTitle( $title ); + } + + /** + * @param int $num Upper limit for numbers + * @return array Array of numbers from 1 up to $num + */ + private static function createProviderUpTo( $num ) { + $ret = array(); + for ( $i = 1; $i <= $num; $i++ ) { + $ret[] = array( $i ); + } + + return $ret; + } + + /** + * @return array Array of months numbers (as an integer) + */ + public static function provideMonths() { + return self::createProviderUpTo( 12 ); + } + + /** + * @return array Array of days numbers (as an integer) + */ + public static function provideDays() { + return self::createProviderUpTo( 31 ); + } + + ############### TESTS ############################################# + # @todo FIXME: + # - those got copy pasted, we can probably make them cleaner + # - tests are lacking useful messages + + # day + + /** @dataProvider provideDays */ + public function testCurrentdayIsUnPadded( $day ) { + $this->assertUnPadded( 'currentday', $day ); + } + + /** @dataProvider provideDays */ + public function testCurrentdaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'currentday2', $day ); + } + + /** @dataProvider provideDays */ + public function testLocaldayIsUnPadded( $day ) { + $this->assertUnPadded( 'localday', $day ); + } + + /** @dataProvider provideDays */ + public function testLocaldaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'localday2', $day ); + } + + # month + + /** @dataProvider provideMonths */ + public function testCurrentmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'currentmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testCurrentmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'currentmonth1', $month ); + } + + /** @dataProvider provideMonths */ + public function testLocalmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'localmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testLocalmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'localmonth1', $month ); + } + + # revision day + + /** @dataProvider provideDays */ + public function testRevisiondayIsUnPadded( $day ) { + $this->assertUnPadded( 'revisionday', $day ); + } + + /** @dataProvider provideDays */ + public function testRevisiondaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'revisionday2', $day ); + } + + # revision month + + /** @dataProvider provideMonths */ + public function testRevisionmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'revisionmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testRevisionmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'revisionmonth1', $month ); + } + + ############### HELPERS ############################################ + + /** assertion helper expecting a magic output which is zero padded */ + public function assertZeroPadded( $magic, $value ) { + $this->assertMagicPadding( $magic, $value, '%02d' ); + } + + /** assertion helper expecting a magic output which is unpadded */ + public function assertUnPadded( $magic, $value ) { + $this->assertMagicPadding( $magic, $value, '%d' ); + } + + /** + * Main assertion helper for magic variables padding + * @param string $magic Magic variable name + * @param mixed $value Month or day + * @param string $format Sprintf format for $value + */ + private function assertMagicPadding( $magic, $value, $format ) { + # Initialize parser timestamp as year 2010 at 12h34 56s. + # month and day are given by the caller ($value). Month < 12! + if ( $value > 12 ) { + $month = $value % 12; + } else { + $month = $value; + } + + $this->setParserTS( + sprintf( '2010%02d%02d123456', $month, $value ) + ); + + # please keep the following commented line of code. It helps debugging. + //print "\nDEBUG (value $value):" . sprintf( '2010%02d%02d123456', $value, $value ) . "\n"; + + # format expectation and test it + $expected = sprintf( $format, $value ); + $this->assertMagic( $expected, $magic ); + } + + /** + * helper to set the parser timestamp and revision timestamp + * @param string $ts + */ + private function setParserTS( $ts ) { + $this->testParser->Options()->setTimestamp( $ts ); + $this->testParser->mRevisionTimestamp = $ts; + } + + /** + * Assertion helper to test a magic variable output + * @param string|int $expected + * @param string $magic + */ + private function assertMagic( $expected, $magic ) { + if ( in_array( $magic, $this->expectedAsInteger ) ) { + $expected = (int)$expected; + } + + # Generate a message for the assertion + $msg = sprintf( "Magic %s should be <%s:%s>", + $magic, + $expected, + gettype( $expected ) + ); + + $this->assertSame( + $expected, + $this->testParser->getVariableValue( $magic ), + $msg + ); + } +} diff --git a/tests/phpunit/includes/parser/MediaWikiParserTest.php b/tests/phpunit/includes/parser/MediaWikiParserTest.php new file mode 100644 index 00000000..df891f5a --- /dev/null +++ b/tests/phpunit/includes/parser/MediaWikiParserTest.php @@ -0,0 +1,134 @@ + "\\'", '\\' => '\\\\' ) ); + $parserTestClassName = ucfirst( $testsName ); + // Official spec for class names: http://php.net/manual/en/language.oop5.basic.php + // Prepend 'ParserTest_' to be paranoid about it not starting with a number + $parserTestClassName = 'ParserTest_' . preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName ); + if ( isset( $testList[$parserTestClassName] ) ) { + // If a conflict happens, gives a very unclear fatal. + // So as a last ditch effort to prevent that eventuality, if there + // is a conflict, append a number. + $counter++; + $parserTestClassName .= $counter; + } + $testList[$parserTestClassName] = true; + $parserTestClassDefinition = <<addTestSuite( $parserTestClassName ); + } + return $suite; + } + + /** + * Write $msg under log group 'tests-parser' + * @param string $msg Message to log + */ + protected static function debug( $msg ) { + return wfDebugLog( 'tests-parser', wfGetCaller() . ' ' . $msg ); + } +} diff --git a/tests/phpunit/includes/parser/NewParserTest.php b/tests/phpunit/includes/parser/NewParserTest.php new file mode 100644 index 00000000..0df52f5e --- /dev/null +++ b/tests/phpunit/includes/parser/NewParserTest.php @@ -0,0 +1,1091 @@ +getCliArg( 'regex' ) ) { + $this->regex = $this->getCliArg( 'regex' ); + } else { + # Matches anything + $this->regex = ''; + } + + $this->keepUploads = $this->getCliArg( 'keep-uploads' ); + + $tmpGlobals = array(); + + $tmpGlobals['wgLanguageCode'] = 'en'; + $tmpGlobals['wgContLang'] = Language::factory( 'en' ); + $tmpGlobals['wgSitename'] = 'MediaWiki'; + $tmpGlobals['wgServer'] = 'http://example.org'; + $tmpGlobals['wgServerName'] = 'example.org'; + $tmpGlobals['wgScript'] = '/index.php'; + $tmpGlobals['wgScriptPath'] = '/'; + $tmpGlobals['wgArticlePath'] = '/wiki/$1'; + $tmpGlobals['wgActionPaths'] = array(); + $tmpGlobals['wgVariantArticlePath'] = false; + $tmpGlobals['wgExtensionAssetsPath'] = '/extensions'; + $tmpGlobals['wgStylePath'] = '/skins'; + $tmpGlobals['wgEnableUploads'] = true; + $tmpGlobals['wgUploadNavigationUrl'] = false; + $tmpGlobals['wgThumbnailScriptPath'] = false; + $tmpGlobals['wgLocalFileRepo'] = array( + 'class' => 'LocalRepo', + 'name' => 'local', + 'url' => 'http://example.com/images', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => 'local-backend' + ); + $tmpGlobals['wgForeignFileRepos'] = array(); + $tmpGlobals['wgDefaultExternalStore'] = array(); + $tmpGlobals['wgEnableParserCache'] = false; + $tmpGlobals['wgCapitalLinks'] = true; + $tmpGlobals['wgNoFollowLinks'] = true; + $tmpGlobals['wgNoFollowDomainExceptions'] = array(); + $tmpGlobals['wgExternalLinkTarget'] = false; + $tmpGlobals['wgThumbnailScriptPath'] = false; + $tmpGlobals['wgUseImageResize'] = true; + $tmpGlobals['wgAllowExternalImages'] = true; + $tmpGlobals['wgRawHtml'] = false; + $tmpGlobals['wgWellFormedXml'] = true; + $tmpGlobals['wgAllowMicrodataAttributes'] = true; + $tmpGlobals['wgExperimentalHtmlIds'] = false; + $tmpGlobals['wgAdaptiveMessageCache'] = true; + $tmpGlobals['wgUseDatabaseMessages'] = true; + $tmpGlobals['wgLocaltimezone'] = 'UTC'; + $tmpGlobals['wgDeferredUpdateList'] = array(); + $tmpGlobals['wgGroupPermissions'] = array( + '*' => array( + 'createaccount' => true, + 'read' => true, + 'edit' => true, + 'createpage' => true, + 'createtalk' => true, + ) ); + $tmpGlobals['wgNamespaceProtection'] = array( NS_MEDIAWIKI => 'editinterface' ); + + $tmpGlobals['wgParser'] = new StubObject( + 'wgParser', $GLOBALS['wgParserConf']['class'], + array( $GLOBALS['wgParserConf'] ) ); + + $tmpGlobals['wgFileExtensions'][] = 'svg'; + $tmpGlobals['wgSVGConverter'] = 'rsvg'; + $tmpGlobals['wgSVGConverters']['rsvg'] = + '$path/rsvg-convert -w $width -h $height $input -o $output'; + + if ( $GLOBALS['wgStyleDirectory'] === false ) { + $tmpGlobals['wgStyleDirectory'] = "$IP/skins"; + } + + # Replace all media handlers with a mock. We do not need to generate + # actual thumbnails to do parser testing, we only care about receiving + # a ThumbnailImage properly initialized. + global $wgMediaHandlers; + foreach ( $wgMediaHandlers as $type => $handler ) { + $tmpGlobals['wgMediaHandlers'][$type] = 'MockBitmapHandler'; + } + // Vector images have to be handled slightly differently + $tmpGlobals['wgMediaHandlers']['image/svg+xml'] = 'MockSvgHandler'; + + // DjVu images have to be handled slightly differently + $tmpGlobals['wgMediaHandlers']['image/vnd.djvu'] = 'MockDjVuHandler'; + + $tmpHooks = $wgHooks; + $tmpHooks['ParserTestParser'][] = 'ParserTestParserHook::setup'; + $tmpHooks['ParserGetVariableValueTs'][] = 'ParserTest::getFakeTimestamp'; + $tmpGlobals['wgHooks'] = $tmpHooks; + # add a namespace shadowing a interwiki link, to test + # proper precedence when resolving links. (bug 51680) + $tmpGlobals['wgExtraNamespaces'] = array( 100 => 'MemoryAlpha' ); + + $tmpGlobals['wgLocalInterwikis'] = array( 'local', 'mi' ); + # "extra language links" + # see https://gerrit.wikimedia.org/r/111390 + $tmpGlobals['wgExtraInterlanguageLinkPrefixes'] = array( 'mul' ); + + // DjVu support + $this->djVuSupport = new DjVuSupport(); + // Tidy support + $this->tidySupport = new TidySupport(); + // We always set 'wgUseTidy' to false when parsing, but certain + // test-running modes still use tidy if available, so ensure + // that the tidy-related options are all set to their defaults. + $tmpGlobals['wgUseTidy'] = false; + $tmpGlobals['wgAlwaysUseTidy'] = false; + $tmpGlobals['wgDebugTidy'] = false; + $tmpGlobals['wgTidyConf'] = $IP . '/includes/tidy.conf'; + $tmpGlobals['wgTidyOpts'] = ''; + $tmpGlobals['wgTidyInternal'] = $this->tidySupport->isInternal(); + + $this->setMwGlobals( $tmpGlobals ); + + $this->savedWeirdGlobals['image_alias'] = $wgNamespaceAliases['Image']; + $this->savedWeirdGlobals['image_talk_alias'] = $wgNamespaceAliases['Image_talk']; + + $wgNamespaceAliases['Image'] = NS_FILE; + $wgNamespaceAliases['Image_talk'] = NS_FILE_TALK; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function tearDown() { + global $wgNamespaceAliases, $wgContLang; + + $wgNamespaceAliases['Image'] = $this->savedWeirdGlobals['image_alias']; + $wgNamespaceAliases['Image_talk'] = $this->savedWeirdGlobals['image_talk_alias']; + + // Restore backends + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + + // Remove temporary pages from the link cache + LinkCache::singleton()->clear(); + + // Restore message cache (temporary pages and $wgUseDatabaseMessages) + MessageCache::destroyInstance(); + + parent::tearDown(); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + public static function tearDownAfterClass() { + ParserTest::tearDownInterwikis(); + parent::tearDownAfterClass(); + } + + function addDBData() { + $this->tablesUsed[] = 'site_stats'; + # disabled for performance + #$this->tablesUsed[] = 'image'; + + # Update certain things in site_stats + $this->db->insert( 'site_stats', + array( 'ss_row_id' => 1, 'ss_images' => 2, 'ss_good_articles' => 1 ), + __METHOD__ + ); + + $user = User::newFromId( 0 ); + LinkCache::singleton()->clear(); # Avoids the odd failure at creating the nullRevision + + # Upload DB table entries for files. + # We will upload the actual files later. Note that if anything causes LocalFile::load() + # to be triggered before then, it will break via maybeUpgrade() setting the fileExists + # member to false and storing it in cache. + # note that the size/width/height/bits/etc of the file + # are actually set by inspecting the file itself; the arguments + # to recordUpload2 have no effect. That said, we try to make things + # match up so it is less confusing to readers of the code & tests. + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.jpg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'Upload of some lame file', + 'Some lame file', + array( + 'size' => 7881, + 'width' => 1941, + 'height' => 220, + 'bits' => 8, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/jpeg', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '1', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20010115123500' ), $user + ); + } + + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Thumb.png' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'Upload of some lame thumbnail', + 'Some lame thumbnail', + array( + 'size' => 22589, + 'width' => 135, + 'height' => 135, + 'bits' => 8, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/png', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '2', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20130225203040' ), $user + ); + } + + # This image will be blacklisted in [[MediaWiki:Bad image list]] + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Bad.jpg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'zomgnotcensored', + 'Borderline image', + array( + 'size' => 12345, + 'width' => 320, + 'height' => 240, + 'bits' => 24, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/jpeg', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '3', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20010115123500' ), $user + ); + } + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.svg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( '', 'Upload of some lame SVG', 'Some lame SVG', array( + 'size' => 12345, + 'width' => 240, + 'height' => 180, + 'bits' => 0, + 'media_type' => MEDIATYPE_DRAWING, + 'mime' => 'image/svg+xml', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20010115123500' ), $user ); + } + + # A DjVu file + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', array( + 'size' => 3249, + 'width' => 2480, + 'height' => 3508, + 'bits' => 0, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/vnd.djvu', + 'metadata' => ' + + + + + + + + + + + + + + + + + + + + + + + + +', + 'sha1' => wfBaseConvert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20140115123600' ), $user ); + } + } + + //ParserTest setup/teardown functions + + /** + * Set up the global variables for a consistent environment for each test. + * Ideally this should replace the global configuration entirely. + * @param array $opts + * @param string $config + * @return RequestContext + */ + protected function setupGlobals( $opts = array(), $config = '' ) { + global $wgFileBackends; + # Find out values for some special options. + $lang = + self::getOptionValue( 'language', $opts, 'en' ); + $variant = + self::getOptionValue( 'variant', $opts, false ); + $maxtoclevel = + self::getOptionValue( 'wgMaxTocLevel', $opts, 999 ); + $linkHolderBatchSize = + self::getOptionValue( 'wgLinkHolderBatchSize', $opts, 1000 ); + + $uploadDir = $this->getUploadDir(); + if ( $this->getCliArg( 'use-filebackend' ) ) { + if ( self::$backendToUse ) { + $backend = self::$backendToUse; + } else { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + } + } + $useConfig['name'] = 'local-backend'; // swap name + unset( $useConfig['lockManager'] ); + unset( $useConfig['fileJournal'] ); + $class = $useConfig['class']; + self::$backendToUse = new $class( $useConfig ); + $backend = self::$backendToUse; + } + } else { + # Replace with a mock. We do not care about generating real + # files on the filesystem, just need to expose the file + # informations. + $backend = new MockFileBackend( array( + 'name' => 'local-backend', + 'wikiId' => wfWikiId() + ) ); + } + + $settings = array( + 'wgLocalFileRepo' => array( + 'class' => 'LocalRepo', + 'name' => 'local', + 'url' => 'http://example.com/images', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => $backend + ), + 'wgEnableUploads' => self::getOptionValue( 'wgEnableUploads', $opts, true ), + 'wgLanguageCode' => $lang, + 'wgDBprefix' => $this->db->getType() != 'oracle' ? 'unittest_' : 'ut_', + 'wgRawHtml' => self::getOptionValue( 'wgRawHtml', $opts, false ), + 'wgNamespacesWithSubpages' => array( NS_MAIN => isset( $opts['subpage'] ) ), + 'wgAllowExternalImages' => self::getOptionValue( 'wgAllowExternalImages', $opts, true ), + 'wgThumbLimits' => array( self::getOptionValue( 'thumbsize', $opts, 180 ) ), + 'wgMaxTocLevel' => $maxtoclevel, + 'wgUseTeX' => isset( $opts['math'] ) || isset( $opts['texvc'] ), + 'wgMathDirectory' => $uploadDir . '/math', + 'wgDefaultLanguageVariant' => $variant, + 'wgLinkHolderBatchSize' => $linkHolderBatchSize, + ); + + if ( $config ) { + $configLines = explode( "\n", $config ); + + foreach ( $configLines as $line ) { + list( $var, $value ) = explode( '=', $line, 2 ); + + $settings[$var] = eval( "return $value;" ); //??? + } + } + + $this->savedGlobals = array(); + + /** @since 1.20 */ + wfRunHooks( 'ParserTestGlobals', array( &$settings ) ); + + $langObj = Language::factory( $lang ); + $settings['wgContLang'] = $langObj; + $settings['wgLang'] = $langObj; + + $context = new RequestContext(); + $settings['wgOut'] = $context->getOutput(); + $settings['wgUser'] = $context->getUser(); + $settings['wgRequest'] = $context->getRequest(); + + // We (re)set $wgThumbLimits to a single-element array above. + $context->getUser()->setOption( 'thumbsize', 0 ); + + foreach ( $settings as $var => $val ) { + if ( array_key_exists( $var, $GLOBALS ) ) { + $this->savedGlobals[$var] = $GLOBALS[$var]; + } + + $GLOBALS[$var] = $val; + } + + MagicWord::clearCache(); + + # The entries saved into RepoGroup cache with previous globals will be wrong. + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + + # Create dummy files in storage + $this->setupUploads(); + + # Publish the articles after we have the final language set + $this->publishTestArticles(); + + MessageCache::destroyInstance(); + + return $context; + } + + /** + * Get an FS upload directory (only applies to FSFileBackend) + * + * @return string The directory + */ + protected function getUploadDir() { + if ( $this->keepUploads ) { + $dir = wfTempDir() . '/mwParser-images'; + + if ( is_dir( $dir ) ) { + return $dir; + } + } else { + $dir = wfTempDir() . "/mwParser-" . mt_rand() . "-images"; + } + + // wfDebug( "Creating upload directory $dir\n" ); + if ( file_exists( $dir ) ) { + wfDebug( "Already exists!\n" ); + + return $dir; + } + + return $dir; + } + + /** + * Create a dummy uploads directory which will contain a couple + * of files in order to pass existence tests. + * + * @return string The directory + */ + protected function setupUploads() { + global $IP; + + $base = $this->getBaseDir(); + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + $backend->prepare( array( 'dir' => "$base/local-public/3/3a" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", + 'dst' => "$base/local-public/3/3a/Foobar.jpg" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/e/ea" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/wiki.png", + 'dst' => "$base/local-public/e/ea/Thumb.png" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/0/09" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", + 'dst' => "$base/local-public/0/09/Bad.jpg" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/5/5f" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/LoremIpsum.djvu", + 'dst' => "$base/local-public/5/5f/LoremIpsum.djvu" + ) ); + + // No helpful SVG file to copy, so make one ourselves + $data = '' . + ''; + + $backend->prepare( array( 'dir' => "$base/local-public/f/ff" ) ); + $backend->quickCreate( array( + 'content' => $data, 'dst' => "$base/local-public/f/ff/Foobar.svg" + ) ); + } + + /** + * Restore default values and perform any necessary clean-up + * after each test runs. + */ + protected function teardownGlobals() { + $this->teardownUploads(); + + foreach ( $this->savedGlobals as $var => $val ) { + $GLOBALS[$var] = $val; + } + } + + /** + * Remove the dummy uploads directory + */ + private function teardownUploads() { + if ( $this->keepUploads ) { + return; + } + + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + if ( $backend instanceof MockFileBackend ) { + # In memory backend, so dont bother cleaning them up. + return; + } + + $base = $this->getBaseDir(); + // delete the files first, then the dirs. + self::deleteFiles( + array( + "$base/local-public/3/3a/Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/100px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/120px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/137px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/177px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/180px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/200px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/206px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/20px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/220px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/265px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/270px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/274px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/300px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/30px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/330px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/353px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/360px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/400px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/40px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/440px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/442px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/450px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/50px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/600px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/640px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/70px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/75px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/960px-Foobar.jpg", + + "$base/local-public/e/ea/Thumb.png", + + "$base/local-public/0/09/Bad.jpg", + + "$base/local-public/5/5f/LoremIpsum.djvu", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-2480px-LoremIpsum.djvu.jpg", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-3720px-LoremIpsum.djvu.jpg", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-4960px-LoremIpsum.djvu.jpg", + + "$base/local-public/f/ff/Foobar.svg", + "$base/local-thumb/f/ff/Foobar.svg/180px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/270px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/360px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-180px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-270px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-360px-Foobar.svg.png", + + "$base/local-public/math/f/a/5/fa50b8b616463173474302ca3e63586b.png", + ) + ); + } + + /** + * Delete the specified files, if they exist. + * @param array $files Full paths to files to delete. + */ + private static function deleteFiles( $files ) { + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + foreach ( $files as $file ) { + $backend->delete( array( 'src' => $file ), array( 'force' => 1 ) ); + } + foreach ( $files as $file ) { + $tmp = $file; + while ( $tmp = FileBackend::parentStoragePath( $tmp ) ) { + if ( !$backend->clean( array( 'dir' => $tmp ) )->isOK() ) { + break; + } + } + } + } + + protected function getBaseDir() { + return 'mwstore://local-backend'; + } + + public function parserTestProvider() { + if ( $this->file === false ) { + global $wgParserTestFiles; + $this->file = $wgParserTestFiles[0]; + } + + return new TestFileIterator( $this->file, $this ); + } + + /** + * Set the file from whose tests will be run by this instance + * @param string $filename + */ + public function setParserTestFile( $filename ) { + $this->file = $filename; + } + + /** + * @group medium + * @dataProvider parserTestProvider + * @param string $desc + * @param string $input + * @param string $result + * @param array $opts + * @param array $config + */ + public function testParserTest( $desc, $input, $result, $opts, $config ) { + if ( $this->regex != '' && !preg_match( '/' . $this->regex . '/', $desc ) ) { + $this->assertTrue( true ); // XXX: don't flood output with "test made no assertions" + //$this->markTestSkipped( 'Filtered out by the user' ); + return; + } + + if ( !$this->isWikitextNS( NS_MAIN ) ) { + // parser tests frequently assume that the main namespace contains wikitext. + // @todo When setting up pages, force the content model. Only skip if + // $wgtContentModelUseDB is false. + $this->markTestSkipped( "Main namespace does not support wikitext," + . "skipping parser test: $desc" ); + } + + wfDebug( "Running parser test: $desc\n" ); + + $opts = $this->parseOptions( $opts ); + $context = $this->setupGlobals( $opts, $config ); + + $user = $context->getUser(); + $options = ParserOptions::newFromContext( $context ); + + if ( isset( $opts['title'] ) ) { + $titleText = $opts['title']; + } else { + $titleText = 'Parser test'; + } + + $local = isset( $opts['local'] ); + $preprocessor = isset( $opts['preprocessor'] ) ? $opts['preprocessor'] : null; + $parser = $this->getParser( $preprocessor ); + + $title = Title::newFromText( $titleText ); + + # Parser test requiring math. Make sure texvc is executable + # or just skip such tests. + if ( isset( $opts['math'] ) || isset( $opts['texvc'] ) ) { + global $wgTexvc; + + if ( !isset( $wgTexvc ) ) { + $this->markTestSkipped( "SKIPPED: \$wgTexvc is not set" ); + } elseif ( !is_executable( $wgTexvc ) ) { + $this->markTestSkipped( "SKIPPED: texvc binary does not exist" + . " or is not executable.\n" + . "Current configuration is:\n\$wgTexvc = '$wgTexvc'" ); + } + } + if ( isset( $opts['djvu'] ) ) { + if ( !$this->djVuSupport->isEnabled() ) { + $this->markTestSkipped( "SKIPPED: djvu binaries do not exist or are not executable.\n" ); + } + } + + if ( isset( $opts['pst'] ) ) { + $out = $parser->preSaveTransform( $input, $title, $user, $options ); + } elseif ( isset( $opts['msg'] ) ) { + $out = $parser->transformMsg( $input, $options, $title ); + } elseif ( isset( $opts['section'] ) ) { + $section = $opts['section']; + $out = $parser->getSection( $input, $section ); + } elseif ( isset( $opts['replace'] ) ) { + $section = $opts['replace'][0]; + $replace = $opts['replace'][1]; + $out = $parser->replaceSection( $input, $section, $replace ); + } elseif ( isset( $opts['comment'] ) ) { + $out = Linker::formatComment( $input, $title, $local ); + } elseif ( isset( $opts['preload'] ) ) { + $out = $parser->getPreloadText( $input, $title, $options ); + } else { + $output = $parser->parse( $input, $title, $options, true, true, 1337 ); + $output->setTOCEnabled( !isset( $opts['notoc'] ) ); + $out = $output->getText(); + if ( isset( $opts['tidy'] ) ) { + if ( !$this->tidySupport->isEnabled() ) { + $this->markTestSkipped( "SKIPPED: tidy extension is not installed.\n" ); + } else { + $out = MWTidy::tidy( $out ); + $out = preg_replace( '/\s+$/', '', $out ); + } + } + + if ( isset( $opts['showtitle'] ) ) { + if ( $output->getTitleText() ) { + $title = $output->getTitleText(); + } + + $out = "$title\n$out"; + } + + if ( isset( $opts['ill'] ) ) { + $out = implode( ' ', $output->getLanguageLinks() ); + } elseif ( isset( $opts['cat'] ) ) { + $outputPage = $context->getOutput(); + $outputPage->addCategoryLinks( $output->getCategories() ); + $cats = $outputPage->getCategoryLinks(); + + if ( isset( $cats['normal'] ) ) { + $out = implode( ' ', $cats['normal'] ); + } else { + $out = ''; + } + } + $parser->mPreprocessor = null; + } + + $this->teardownGlobals(); + + $this->assertEquals( $result, $out, $desc ); + } + + /** + * Run a fuzz test series + * Draw input from a set of test files + * + * @todo fixme Needs some work to not eat memory until the world explodes + * + * @group ParserFuzz + */ + public function testFuzzTests() { + global $wgParserTestFiles; + + $files = $wgParserTestFiles; + + if ( $this->getCliArg( 'file' ) ) { + $files = array( $this->getCliArg( 'file' ) ); + } + + $dict = $this->getFuzzInput( $files ); + $dictSize = strlen( $dict ); + $logMaxLength = log( $this->maxFuzzTestLength ); + + ini_set( 'memory_limit', $this->memoryLimit * 1048576 ); + + $user = new User; + $opts = ParserOptions::newFromUser( $user ); + $title = Title::makeTitle( NS_MAIN, 'Parser_test' ); + + $id = 1; + + while ( true ) { + + // Generate test input + mt_srand( ++$this->fuzzSeed ); + $totalLength = mt_rand( 1, $this->maxFuzzTestLength ); + $input = ''; + + while ( strlen( $input ) < $totalLength ) { + $logHairLength = mt_rand( 0, 1000000 ) / 1000000 * $logMaxLength; + $hairLength = min( intval( exp( $logHairLength ) ), $dictSize ); + $offset = mt_rand( 0, $dictSize - $hairLength ); + $input .= substr( $dict, $offset, $hairLength ); + } + + $this->setupGlobals(); + $parser = $this->getParser(); + + // Run the test + try { + $parser->parse( $input, $title, $opts ); + $this->assertTrue( true, "Test $id, fuzz seed {$this->fuzzSeed}" ); + } catch ( Exception $exception ) { + $input_dump = sprintf( "string(%d) \"%s\"\n", strlen( $input ), $input ); + + $this->assertTrue( false, "Test $id, fuzz seed {$this->fuzzSeed}. \n\n" . + "Input: $input_dump\n\nError: {$exception->getMessage()}\n\n" . + "Backtrace: {$exception->getTraceAsString()}" ); + } + + $this->teardownGlobals(); + $parser->__destruct(); + + if ( $id % 100 == 0 ) { + $usage = intval( memory_get_usage( true ) / $this->memoryLimit / 1048576 * 100 ); + //echo "{$this->fuzzSeed}: $numSuccess/$numTotal (mem: $usage%)\n"; + if ( $usage > 90 ) { + $ret = "Out of memory:\n"; + $memStats = $this->getMemoryBreakdown(); + + foreach ( $memStats as $name => $usage ) { + $ret .= "$name: $usage\n"; + } + + throw new MWException( $ret ); + } + } + + $id++; + } + } + + //Various getter functions + + /** + * Get an input dictionary from a set of parser test files + * @param array $filenames + * @return string + */ + function getFuzzInput( $filenames ) { + $dict = ''; + + foreach ( $filenames as $filename ) { + $contents = file_get_contents( $filename ); + preg_match_all( '/!!\s*input\n(.*?)\n!!\s*result/s', $contents, $matches ); + + foreach ( $matches[1] as $match ) { + $dict .= $match . "\n"; + } + } + + return $dict; + } + + /** + * Get a memory usage breakdown + * @return array + */ + function getMemoryBreakdown() { + $memStats = array(); + + foreach ( $GLOBALS as $name => $value ) { + $memStats['$' . $name] = strlen( serialize( $value ) ); + } + + $classes = get_declared_classes(); + + foreach ( $classes as $class ) { + $rc = new ReflectionClass( $class ); + $props = $rc->getStaticProperties(); + $memStats[$class] = strlen( serialize( $props ) ); + $methods = $rc->getMethods(); + + foreach ( $methods as $method ) { + $memStats[$class] += strlen( serialize( $method->getStaticVariables() ) ); + } + } + + $functions = get_defined_functions(); + + foreach ( $functions['user'] as $function ) { + $rf = new ReflectionFunction( $function ); + $memStats["$function()"] = strlen( serialize( $rf->getStaticVariables() ) ); + } + + asort( $memStats ); + + return $memStats; + } + + /** + * Get a Parser object + * @param Preprocessor $preprocessor + * @return Parser + */ + function getParser( $preprocessor = null ) { + global $wgParserConf; + + $class = $wgParserConf['class']; + $parser = new $class( array( 'preprocessorClass' => $preprocessor ) + $wgParserConf ); + + wfRunHooks( 'ParserTestParser', array( &$parser ) ); + + return $parser; + } + + //Various action functions + + public function addArticle( $name, $text, $line ) { + self::$articles[$name] = array( $text, $line ); + } + + public function publishTestArticles() { + if ( empty( self::$articles ) ) { + return; + } + + foreach ( self::$articles as $name => $info ) { + list( $text, $line ) = $info; + ParserTest::addArticle( $name, $text, $line, 'ignoreduplicate' ); + } + } + + /** + * Steal a callback function from the primary parser, save it for + * application to our scary parser. If the hook is not installed, + * abort processing of this file. + * + * @param string $name + * @return bool True if tag hook is present + */ + public function requireHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mTagHooks[$name] ); + } + + public function requireFunctionHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mFunctionHooks[$name] ); + } + + public function requireTransparentHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mTransparentTagHooks[$name] ); + } + + //Various "cleanup" functions + + /** + * Remove last character if it is a newline + * @param string $s + * @return string + */ + public function removeEndingNewline( $s ) { + if ( substr( $s, -1 ) === "\n" ) { + return substr( $s, 0, -1 ); + } else { + return $s; + } + } + + //Test options parser functions + + protected function parseOptions( $instring ) { + $opts = array(); + // foo + // foo=bar + // foo="bar baz" + // foo=[[bar baz]] + // foo=bar,"baz quux" + $regex = '/\b + ([\w-]+) # Key + \b + (?:\s* + = # First sub-value + \s* + ( + " + [^"]* # Quoted val + " + | + \[\[ + [^]]* # Link target + \]\] + | + [\w-]+ # Plain word + ) + (?:\s* + , # Sub-vals 1..N + \s* + ( + "[^"]*" # Quoted val + | + \[\[[^]]*\]\] # Link target + | + [\w-]+ # Plain word + ) + )* + )? + /x'; + + if ( preg_match_all( $regex, $instring, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $bits ) { + array_shift( $bits ); + $key = strtolower( array_shift( $bits ) ); + if ( count( $bits ) == 0 ) { + $opts[$key] = true; + } elseif ( count( $bits ) == 1 ) { + $opts[$key] = $this->cleanupOption( array_shift( $bits ) ); + } else { + // Array! + $opts[$key] = array_map( array( $this, 'cleanupOption' ), $bits ); + } + } + } + + return $opts; + } + + protected function cleanupOption( $opt ) { + if ( substr( $opt, 0, 1 ) == '"' ) { + return substr( $opt, 1, -1 ); + } + + if ( substr( $opt, 0, 2 ) == '[[' ) { + return substr( $opt, 2, -2 ); + } + + return $opt; + } + + /** + * Use a regex to find out the value of an option + * @param string $key Name of option val to retrieve + * @param array $opts Options array to look in + * @param mixed $default Default value returned if not found + * @return mixed + */ + protected static function getOptionValue( $key, $opts, $default ) { + $key = strtolower( $key ); + + if ( isset( $opts[$key] ) ) { + return $opts[$key]; + } else { + return $default; + } + } +} diff --git a/tests/phpunit/includes/parser/ParserMethodsTest.php b/tests/phpunit/includes/parser/ParserMethodsTest.php new file mode 100644 index 00000000..1790086a --- /dev/null +++ b/tests/phpunit/includes/parser/ParserMethodsTest.php @@ -0,0 +1,187 @@ +~~~', + 'hello \'\'this\'\' is ~~~', + ), + ); + } + + /** + * @dataProvider providePreSaveTransform + * @covers Parser::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + global $wgParser; + + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $user = new User(); + $user->setName( "127.0.0.1" ); + $popts = ParserOptions::newFromUser( $user ); + $text = $wgParser->preSaveTransform( $text, $title, $user, $popts ); + + $this->assertEquals( $expected, $text ); + } + + public static function provideStripOuterParagraph() { + // This mimics the most common use case (stripping paragraphs generated by the parser). + $message = new RawMessage( "Message text." ); + + return array( + array( + "

    Text.

    ", + "Text.", + ), + array( + "

    Text.

    ", + "

    Text.

    ", + ), + array( + "

    Text.\n

    \n", + "Text.", + ), + array( + "

    Text.

    More text.

    ", + "

    Text.

    More text.

    ", + ), + array( + $message->parse(), + "Message text.", + ), + ); + } + + /** + * @dataProvider provideStripOuterParagraph + * @covers Parser::stripOuterParagraph + */ + public function testStripOuterParagraph( $text, $expected ) { + $this->assertEquals( $expected, Parser::stripOuterParagraph( $text ) ); + } + + /** + * @expectedException MWException + * @expectedExceptionMessage Parser state cleared while parsing. Did you call Parser::parse recursively? + * @covers Parser::lock + */ + public function testRecursiveParse() { + global $wgParser; + $title = Title::newFromText( 'foo' ); + $po = new ParserOptions; + $wgParser->setHook( 'recursivecallparser', array( $this, 'helperParserFunc' ) ); + $wgParser->parse( 'baz', $title, $po ); + } + + public function helperParserFunc( $input, $args, $parser ) { + $title = Title::newFromText( 'foo' ); + $po = new ParserOptions; + $parser->parse( $input, $title, $po ); + return 'bar'; + } + + /** + * @covers Parser::callParserFunction + */ + public function testCallParserFunction() { + global $wgParser; + + // Normal parses test passing PPNodes. Test passing an array. + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $wgParser->startExternalParse( $title, new ParserOptions(), Parser::OT_HTML ); + $frame = $wgParser->getPreprocessor()->newFrame(); + $ret = $wgParser->callParserFunction( $frame, '#tag', + array( 'pre', 'foo', 'style' => 'margin-left: 1.6em' ) + ); + $ret['text'] = $wgParser->mStripState->unstripBoth( $ret['text'] ); + $this->assertSame( array( + 'found' => true, + 'text' => '
    foo
    ', + ), $ret, 'callParserFunction works for {{#tag:pre|foo|style=margin-left: 1.6em}}' ); + } + + /** + * @covers Parser::parse + * @covers ParserOutput::getSections + */ + public function testGetSections() { + global $wgParser; + + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $out = $wgParser->parse( "==foo==\n

    bar

    \n==baz==\n", $title, new ParserOptions() ); + $this->assertSame( array( + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'foo', + 'number' => '1', + 'index' => '1', + 'fromtitle' => $title->getPrefixedDBkey(), + 'byteoffset' => 0, + 'anchor' => 'foo', + ), + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'bar', + 'number' => '2', + 'index' => '', + 'fromtitle' => false, + 'byteoffset' => null, + 'anchor' => 'bar', + ), + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'baz', + 'number' => '3', + 'index' => '2', + 'fromtitle' => $title->getPrefixedDBkey(), + 'byteoffset' => 21, + 'anchor' => 'baz', + ), + ), $out->getSections(), 'getSections() with proper value when

    is used' ); + } + + /** + * @dataProvider provideNormalizeLinkUrl + * @covers Parser::normalizeLinkUrl + * @covers Parser::normalizeUrlComponent + */ + public function testNormalizeLinkUrl( $explanation, $url, $expected ) { + $this->assertEquals( $expected, Parser::normalizeLinkUrl( $url ), $explanation ); + } + + public static function provideNormalizeLinkUrl() { + return array( + array( + 'Escaping of unsafe characters', + 'http://example.org/foo bar?param[]="value"¶m[]=valüe', + 'http://example.org/foo%20bar?param%5B%5D=%22value%22¶m%5B%5D=val%C3%BCe', + ), + array( + 'Case normalization of percent-encoded characters', + 'http://example.org/%ab%cD%Ef%FF', + 'http://example.org/%AB%CD%EF%FF', + ), + array( + 'Unescaping of safe characters', + 'http://example.org/%3C%66%6f%6F%3E?%3C%66%6f%6F%3E#%3C%66%6f%6F%3E', + 'http://example.org/%3Cfoo%3E?%3Cfoo%3E#%3Cfoo%3E', + ), + array( + 'Context-sensitive replacement of sometimes-safe characters', + 'http://example.org/%23%2F%3F%26%3D%2B%3B?%23%2F%3F%26%3D%2B%3B#%23%2F%3F%26%3D%2B%3B', + 'http://example.org/%23%2F%3F&=+;?%23/?%26%3D%2B%3B#%23/?&=+;', + ), + ); + } + + // @todo Add tests for cleanSig() / cleanSigInSig(), getSection(), + // replaceSection(), getPreloadText() +} diff --git a/tests/phpunit/includes/parser/ParserOutputTest.php b/tests/phpunit/includes/parser/ParserOutputTest.php new file mode 100644 index 00000000..c024cee5 --- /dev/null +++ b/tests/phpunit/includes/parser/ParserOutputTest.php @@ -0,0 +1,87 @@ +assertEquals( $shouldMatch, ParserOutput::isLinkInternal( $server, $url ) ); + } + + /** + * @covers ParserOutput::setExtensionData + * @covers ParserOutput::getExtensionData + */ + public function testExtensionData() { + $po = new ParserOutput(); + + $po->setExtensionData( "one", "Foo" ); + + $this->assertEquals( "Foo", $po->getExtensionData( "one" ) ); + $this->assertNull( $po->getExtensionData( "spam" ) ); + + $po->setExtensionData( "two", "Bar" ); + $this->assertEquals( "Foo", $po->getExtensionData( "one" ) ); + $this->assertEquals( "Bar", $po->getExtensionData( "two" ) ); + + $po->setExtensionData( "one", null ); + $this->assertNull( $po->getExtensionData( "one" ) ); + $this->assertEquals( "Bar", $po->getExtensionData( "two" ) ); + } + + /** + * @covers ParserOutput::setProperty + * @covers ParserOutput::getProperty + * @covers ParserOutput::unsetProperty + * @covers ParserOutput::getProperties + */ + public function testProperties() { + $po = new ParserOutput(); + + $po->setProperty( 'foo', 'val' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), 'val' ); + $this->assertEquals( $properties['foo'], 'val' ); + + $po->setProperty( 'foo', 'second val' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), 'second val' ); + $this->assertEquals( $properties['foo'], 'second val' ); + + $po->unsetProperty( 'foo' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), false ); + $this->assertArrayNotHasKey( 'foo', $properties ); + } +} diff --git a/tests/phpunit/includes/parser/ParserPreloadTest.php b/tests/phpunit/includes/parser/ParserPreloadTest.php new file mode 100644 index 00000000..d12fee36 --- /dev/null +++ b/tests/phpunit/includes/parser/ParserPreloadTest.php @@ -0,0 +1,80 @@ +testParserOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang ); + + $this->testParser = new Parser(); + $this->testParser->Options( $this->testParserOptions ); + $this->testParser->clearState(); + + $this->title = Title::newFromText( 'Preload Test' ); + } + + protected function tearDown() { + parent::tearDown(); + + unset( $this->testParser ); + unset( $this->title ); + } + + /** + * @covers Parser::getPreloadText + */ + public function testPreloadSimpleText() { + $this->assertPreloaded( 'simple', 'simple' ); + } + + /** + * @covers Parser::getPreloadText + */ + public function testPreloadedPreIsUnstripped() { + $this->assertPreloaded( + '
    monospaced
    ', + '
    monospaced
    ', + '
     in preloaded text must be unstripped (bug 27467)'
    +		);
    +	}
    +
    +	/**
    +	 * @covers Parser::getPreloadText
    +	 */
    +	public function testPreloadedNowikiIsUnstripped() {
    +		$this->assertPreloaded(
    +			'[[Dummy title]]',
    +			'[[Dummy title]]',
    +			' in preloaded text must be unstripped (bug 27467)'
    +		);
    +	}
    +
    +	protected function assertPreloaded( $expected, $text, $msg = '' ) {
    +		$this->assertEquals(
    +			$expected,
    +			$this->testParser->getPreloadText(
    +				$text,
    +				$this->title,
    +				$this->testParserOptions
    +			),
    +			$msg
    +		);
    +	}
    +}
    diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php
    new file mode 100644
    index 00000000..345fd0a5
    --- /dev/null
    +++ b/tests/phpunit/includes/parser/PreprocessorTest.php
    @@ -0,0 +1,247 @@
    +mOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang );
    +		$name = isset( $wgParserConf['preprocessorClass'] )
    +			? $wgParserConf['preprocessorClass']
    +			: 'Preprocessor_DOM';
    +
    +		$this->mPreprocessor = new $name( $this );
    +	}
    +
    +	function getStripList() {
    +		return array( 'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo' );
    +	}
    +
    +	public static function provideCases() {
    +		// @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong
    +		return array(
    +			array( "Foo", "Foo" ),
    +			array( "", "<!-- Foo -->" ),
    +			array( "", "<!-- Foo --><!-- Bar -->" ),
    +			array( "  ", "<!-- Foo -->  <!-- Bar -->" ),
    +			array( " \n ", "<!-- Foo --> \n <!-- Bar -->" ),
    +			array( " \n \n", "<!-- Foo --> \n <!-- Bar -->\n" ),
    +			array( "  \n", "<!-- Foo -->  <!-- Bar -->\n" ),
    +			array( "Bar", "<!-->Bar" ),
    +			array( "\n== Baz ==\n", "== Foo ==\n  <!-- Bar -->\n== Baz ==\n" ),
    +			array( "", "gallery" ),
    +			array( "Foo  Bar", "Foo gallery Bar" ),
    +			array( "", "gallery</gallery>" ),
    +			array( " ", "<foo> gallery</gallery>" ),
    +			array( " ", "<foo> gallery<gallery></gallery>" ),
    +			array( " Foo bar ", "<noinclude> Foo bar </noinclude>" ),
    +			array( "\n{{Foo}}\n", "<noinclude>\n\n</noinclude>" ),
    +			array( "\n{{Foo}}\n\n", "<noinclude>\n\n</noinclude>\n" ),
    +			array( "foo bar", "galleryfoo bar" ),
    +			array( "<{{foo}}>", "<>" ),
    +			array( "<{{{foo}}}>", "<foo>" ),
    +			array( "", "gallery</gallery</gallery>" ),
    +			array( "=== Foo === ", "=== Foo === " ),
    +			array( "=== Foo === ", "==<!-- -->= Foo === " ),
    +			array( "=== Foo === ", "=== Foo ==<!-- -->= " ),
    +			array( "=== Foo ===\n", "=== Foo ===<!-- -->\n" ),
    +			array( "=== Foo === \n", "=== Foo ===<!-- --> <!-- -->\n" ),
    +			array( "== Foo ==\n== Bar == \n", "== Foo ==\n== Bar == \n" ),
    +			array( "===========", "===========" ),
    +			array( "Foo\n=\n==\n=\n", "Foo\n=\n==\n=\n" ),
    +			array( "{{Foo}}", "" ),
    +			array( "\n{{Foo}}", "\n" ),
    +			array( "{{Foo|bar}}", "" ),
    +			array( "{{Foo|bar}}a", "a" ),
    +			array( "{{Foo|bar|baz}}", "" ),
    +			array( "{{Foo|1=bar}}", "" ),
    +			array( "{{Foo|=bar}}", "" ),
    +			array( "{{Foo|bar=baz}}", "" ),
    +			array( "{{Foo|{{bar}}=baz}}", "" ),
    +			array( "{{Foo|1=bar|baz}}", "" ),
    +			array( "{{Foo|1=bar|2=baz}}", "" ),
    +			array( "{{Foo|bar|foo=baz}}", "" ),
    +			array( "{{{1}}}", "1" ),
    +			array( "{{{1|}}}", "1" ),
    +			array( "{{{Foo}}}", "Foo" ),
    +			array( "{{{Foo|}}}", "Foo" ),
    +			array( "{{{Foo|bar|baz}}}", "Foobarbaz" ),
    +			array( "{{Foo}}", "{<!-- -->{Foo}}" ),
    +			array( "{{{{Foobar}}}}", "{Foobar}" ),
    +			array( "{{{ {{Foo}} }}}", " <template><title>Foo " ),
    +			array( "{{ {{{Foo}}} }}", "" ),
    +			array( "{{{{{Foo}}}}}", "" ),
    +			array( "{{{{{Foo}} }}}", "<template><title>Foo " ),
    +			array( "{{{{{{Foo}}}}}}", "<tplarg><title>Foo" ),
    +			array( "{{{{{{Foo}}}}}", "{" ),
    +			array( "[[[Foo]]", "[[[Foo]]" ),
    +			array( "{{Foo|[[[[bar]]|baz]]}}", "" ), // This test is important, since it means the difference between having the [[ rule stacked or not
    +			array( "{{Foo|[[[[bar]|baz]]}}", "{{Foo|[[[[bar]|baz]]}}" ),
    +			array( "{{Foo|Foo [[[[bar]|baz]]}}", "{{Foo|Foo [[[[bar]|baz]]}}" ),
    +			array( "Foo BarBaz", "Foo display mapBar</display map             >Baz" ),
    +			array( "Foo BarBaz", "Foo display map fooBar</display map             >Baz" ),
    +			array( "Foo ", "Foo gallery bar="baz" " ),
    +			array( "Foo ", "Foo gallery bar="1" baz=2 " ),
    +			array( "Foo", "/fooFoo<//foo>" ), # Worth blacklisting IMHO
    +			array( "{{#ifexpr: ({{{1|1}}} = 2) | Foo | Bar }}", "" ),
    +			array( "{{#if: {{{1|}}} | Foo | {{Bar}} }}", "" ),
    +			array( "{{#if: {{{1|}}} | Foo | [[Bar]] }}", "" ),
    +			array( "{{#if: {{{1|}}} | [[Foo]] | Bar }}", "" ),
    +			array( "{{#if: {{{1|}}} | 1 | {{#if: {{{1|}}} | 2 | 3 }} }}", "" ),
    +			array( "{{ {{Foo}}", "{{ " ),
    +			array( "{{Foobar {{Foo}} {{Bar}} {{Baz}} ", "{{Foobar    " ),
    +			array( "[[Foo]] |", "[[Foo]] |" ),
    +			array( "{{Foo|Bar|", "{{Foo|Bar|" ),
    +			array( "[[Foo]", "[[Foo]" ),
    +			array( "[[Foo|Bar]", "[[Foo|Bar]" ),
    +			array( "{{Foo| [[Bar] }}", "{{Foo| [[Bar] }}" ),
    +			array( "{{Foo| [[Bar|Baz] }}", "{{Foo| [[Bar|Baz] }}" ),
    +			array( "{{Foo|bar=[[baz]}}", "{{Foo|bar=[[baz]}}" ),
    +			array( "{{foo|", "{{foo|" ),
    +			array( "{{foo|}", "{{foo|}" ),
    +			array( "{{foo|} }}", "" ),
    +			array( "{{foo|bar=|}", "{{foo|bar=|}" ),
    +			array( "{{Foo|} Bar=", "{{Foo|} Bar=" ),
    +			array( "{{Foo|} Bar=}}", "" ),
    +			/* array( file_get_contents( __DIR__ . '/QuoteQuran.txt' ), file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ), */
    +		);
    +		// @codingStandardsIgnoreEnd
    +	}
    +
    +	/**
    +	 * Get XML preprocessor tree from the preprocessor (which may not be the
    +	 * native XML-based one).
    +	 *
    +	 * @param string $wikiText
    +	 * @return string
    +	 */
    +	protected function preprocessToXml( $wikiText ) {
    +		if ( method_exists( $this->mPreprocessor, 'preprocessToXml' ) ) {
    +			return $this->normalizeXml( $this->mPreprocessor->preprocessToXml( $wikiText ) );
    +		}
    +
    +		$dom = $this->mPreprocessor->preprocessToObj( $wikiText );
    +		if ( is_callable( array( $dom, 'saveXML' ) ) ) {
    +			return $dom->saveXML();
    +		} else {
    +			return $this->normalizeXml( $dom->__toString() );
    +		}
    +	}
    +
    +	/**
    +	 * Normalize XML string to the form that a DOMDocument saves out.
    +	 *
    +	 * @param string $xml
    +	 * @return string
    +	 */
    +	protected function normalizeXml( $xml ) {
    +		return preg_replace( '!<([a-z]+)/>!', '<$1>', str_replace( ' />', '/>', $xml ) );
    +	}
    +
    +	/**
    +	 * @dataProvider provideCases
    +	 * @covers Preprocessor_DOM::preprocessToXml
    +	 */
    +	public function testPreprocessorOutput( $wikiText, $expectedXml ) {
    +		$this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) );
    +	}
    +
    +	/**
    +	 * These are more complex test cases taken out of wiki articles.
    +	 */
    +	public static function provideFiles() {
    +		// @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong
    +		return array(
    +			array( "QuoteQuran" ), # http://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver
    +			array( "Factorial" ), # http://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium
    +			array( "All_system_messages" ), # http://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki
    +			array( "Fundraising" ), # http://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor.
    +			array( "NestedTemplates" ), # bug 27936
    +		);
    +		// @codingStandardsIgnoreEnd
    +	}
    +
    +	/**
    +	 * @dataProvider provideFiles
    +	 * @covers Preprocessor_DOM::preprocessToXml
    +	 */
    +	public function testPreprocessorOutputFiles( $filename ) {
    +		$folder = __DIR__ . "/../../../parser/preprocess";
    +		$wikiText = file_get_contents( "$folder/$filename.txt" );
    +		$output = $this->preprocessToXml( $wikiText );
    +
    +		$expectedFilename = "$folder/$filename.expected";
    +		if ( file_exists( $expectedFilename ) ) {
    +			$expectedXml = $this->normalizeXml( file_get_contents( $expectedFilename ) );
    +			$this->assertEquals( $expectedXml, $output );
    +		} else {
    +			$tempFilename = tempnam( $folder, "$filename." );
    +			file_put_contents( $tempFilename, $output );
    +			$this->markTestIncomplete( "File $expectedFilename missing. Output stored as $tempFilename" );
    +		}
    +	}
    +
    +	/**
    +	 * Tests from Bug 28642 · https://bugzilla.wikimedia.org/28642
    +	 */
    +	public static function provideHeadings() {
    +		// @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong
    +		return array( /* These should become headings: */
    +			array( "== h ==", "== h ==<!--c1-->" ),
    +			array( "== h == 	", "== h == 	<!--c1-->" ),
    +			array( "== h == 	", "== h ==<!--c1--> 	" ),
    +			array( "== h == 	 	", "== h == 	<!--c1--> 	" ),
    +			array( "== h ==", "== h ==<!--c1--><!--c2-->" ),
    +			array( "== h == 	", "== h == 	<!--c1--><!--c2-->" ),
    +			array( "== h == 	", "== h ==<!--c1--><!--c2--> 	" ),
    +			array( "== h == 	 	", "== h == 	<!--c1--><!--c2--> 	" ),
    +			array( "== h == 	  ", "== h == 	<!--c1-->  <!--c2-->" ),
    +			array( "== h ==   	", "== h ==<!--c1-->  <!--c2--> 	" ),
    +			array( "== h == 	   	", "== h == 	<!--c1-->  <!--c2--> 	" ),
    +			array( "== h ==", "== h ==<!--c1--><!--c2--><!--c3-->" ),
    +			array( "== h ==  ", "== h ==<!--c1-->  <!--c2--><!--c3-->" ),
    +			array( "== h ==  ", "== h ==<!--c1--><!--c2-->  <!--c3-->" ),
    +			array( "== h ==    ", "== h ==<!--c1-->  <!--c2-->  <!--c3-->" ),
    +			array( "== h ==  ", "== h ==  <!--c1--><!--c2--><!--c3-->" ),
    +			array( "== h ==    ", "== h ==  <!--c1-->  <!--c2--><!--c3-->" ),
    +			array( "== h ==    ", "== h ==  <!--c1--><!--c2-->  <!--c3-->" ),
    +			array( "== h ==      ", "== h ==  <!--c1-->  <!--c2-->  <!--c3-->" ),
    +			array( "== h ==  ", "== h ==<!--c1--><!--c2--><!--c3-->  " ),
    +			array( "== h ==    ", "== h ==<!--c1-->  <!--c2--><!--c3-->  " ),
    +			array( "== h ==    ", "== h ==<!--c1--><!--c2-->  <!--c3-->  " ),
    +			array( "== h ==      ", "== h ==<!--c1-->  <!--c2-->  <!--c3-->  " ),
    +			array( "== h ==    ", "== h ==  <!--c1--><!--c2--><!--c3-->  " ),
    +			array( "== h ==      ", "== h ==  <!--c1-->  <!--c2--><!--c3-->  " ),
    +			array( "== h ==      ", "== h ==  <!--c1--><!--c2-->  <!--c3-->  " ),
    +			array( "== h ==        ", "== h ==  <!--c1-->  <!--c2-->  <!--c3-->  " ),
    +			array( "== h == 	", "== h ==<!--c1--> 	<!--c2-->" ),
    +			array( "== h == 	 	", "== h == 	<!--c1--> 	<!--c2-->" ),
    +			array( "== h == 	 	", "== h ==<!--c1--> 	<!--c2--> 	" ),
    +
    +			/* These are not working: */
    +			array( "== h == x   ", "== h == x <!--c1--><!--c2--><!--c3-->  " ),
    +			array( "== h == x   ", "== h ==<!--c1--> x <!--c2--><!--c3-->  " ),
    +			array( "== h == x ", "== h ==<!--c1--><!--c2--><!--c3--> x " ),
    +		);
    +		// @codingStandardsIgnoreEnd
    +	}
    +
    +	/**
    +	 * @dataProvider provideHeadings
    +	 * @covers Preprocessor_DOM::preprocessToXml
    +	 */
    +	public function testHeadings( $wikiText, $expectedXml ) {
    +		$this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) );
    +	}
    +}
    diff --git a/tests/phpunit/includes/parser/TagHooksTest.php b/tests/phpunit/includes/parser/TagHooksTest.php
    new file mode 100644
    index 00000000..e3c4cc84
    --- /dev/null
    +++ b/tests/phpunit/includes/parser/TagHooksTest.php
    @@ -0,0 +1,108 @@
    +bar" ), array( "foo\nbar" ), array( "foo\rbar" ) );
    +	}
    +
    +	protected function setUp() {
    +		parent::setUp();
    +
    +		$this->setMwGlobals( 'wgAlwaysUseTidy', false );
    +	}
    +
    +	/**
    +	 * @dataProvider provideValidNames
    +	 * @covers Parser::setHook
    +	 */
    +	public function testTagHooks( $tag ) {
    +		global $wgParserConf, $wgContLang;
    +		$parser = new Parser( $wgParserConf );
    +
    +		$parser->setHook( $tag, array( $this, 'tagCallback' ) );
    +		$parserOutput = $parser->parse(
    +			"Foo<$tag>BarBaz",
    +			Title::newFromText( 'Test' ),
    +			ParserOptions::newFromUserAndLang( new User, $wgContLang )
    +		);
    +		$this->assertEquals( "

    FooOneBaz\n

    ", $parserOutput->getText() ); + + $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle + } + + /** + * @dataProvider provideBadNames + * @expectedException MWException + * @covers Parser::setHook + */ + public function testBadTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setHook( $tag, array( $this, 'tagCallback' ) ); + $parser->parse( + "Foo<$tag>BarBaz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->fail( 'Exception not thrown.' ); + } + + /** + * @dataProvider provideValidNames + * @covers Parser::setFunctionTagHook + */ + public function testFunctionTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setFunctionTagHook( $tag, array( $this, 'functionTagCallback' ), 0 ); + $parserOutput = $parser->parse( + "Foo<$tag>BarBaz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->assertEquals( "

    FooOneBaz\n

    ", $parserOutput->getText() ); + + $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle + } + + /** + * @dataProvider provideBadNames + * @expectedException MWException + * @covers Parser::setFunctionTagHook + */ + public function testBadFunctionTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setFunctionTagHook( $tag, array( $this, 'functionTagCallback' ), SFH_OBJECT_ARGS ); + $parser->parse( + "Foo<$tag>BarBaz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->fail( 'Exception not thrown.' ); + } + + function tagCallback( $text, $params, $parser ) { + return str_rot13( $text ); + } + + function functionTagCallback( &$parser, $frame, $code, $attribs ) { + return str_rot13( $code ); + } +} diff --git a/tests/phpunit/includes/parser/TidyTest.php b/tests/phpunit/includes/parser/TidyTest.php new file mode 100644 index 00000000..f656a74d --- /dev/null +++ b/tests/phpunit/includes/parser/TidyTest.php @@ -0,0 +1,64 @@ +markTestSkipped( 'Tidy not found' ); + } + } + + /** + * @dataProvider provideTestWrapping + */ + public function testTidyWrapping( $expected, $text, $msg = '' ) { + $text = MWTidy::tidy( $text ); + // We don't care about where Tidy wants to stick is

    s + $text = trim( preg_replace( '##', '', $text ) ); + // Windows, we love you! + $text = str_replace( "\r", '', $text ); + $this->assertEquals( $expected, $text, $msg ); + } + + public static function provideTestWrapping() { + $testMathML = <<<'MathML' + + + a + + + x + 2 + + + + b + + x + + + c + + +MathML; + return array( + array( + 'foo', + 'foo', + ' should survive tidy' + ), + array( + 'foo', + 'foo', + ' should survive tidy' + ), + array( 'foo', 'foo', ' should survive tidy' ), + array( "\nfoo", 'foo', ' should survive tidy' ), + array( "\nfoo", 'foo', ' should survive tidy' ), + array( $testMathML, $testMathML, ' should survive tidy' ), + ); + } +} diff --git a/tests/phpunit/includes/password/BcryptPasswordTest.php b/tests/phpunit/includes/password/BcryptPasswordTest.php new file mode 100644 index 00000000..8ac419ff --- /dev/null +++ b/tests/phpunit/includes/password/BcryptPasswordTest.php @@ -0,0 +1,40 @@ + array( + 'class' => 'BcryptPassword', + 'cost' => 9, + ) ); + } + + public static function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + // Tests from glibc bcrypt implementation + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "U*U" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$VGOzA784oUp/Z0DY336zx7pLYAy0lwK', "U*U*" ), + array( true, ':bcrypt:5$XXXXXXXXXXXXXXXXXXXXXO$AcXxm9kjPGEMsLznoKqmqw7tc8WCx4a', "U*U*U" ), + array( true, ':bcrypt:5$abcdefghijklmnopqrstuu$5s2v8.iXieOjg/.AySBTTZIIVFJeBui', "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$CE5elHaaO4EbggVDjb8P19RukzXSM3e', "\xff\xff\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$o./n25XVfn6oAPaUvHe.Csk4zRfsYPi', "\xff\xa334\xff\xff\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6', "\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaachars after 72 are ignored as usual" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy', "\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe', "\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy', "" ), + // One or two false sanity tests + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "UXU" ), + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "" ), + ); + /** @codingStandardsIgnoreEnd */ + } +} diff --git a/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php new file mode 100644 index 00000000..86e8270a --- /dev/null +++ b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php @@ -0,0 +1,51 @@ + array( + 'class' => 'LayeredParameterizedPassword', + 'types' => array( + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredFinal', + ), + ), + 'testLargeLayeredBottom' => array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha512', + 'cost' => 1024, + 'length' => 512, + ), + 'testLargeLayeredFinal' => array( + 'class' => 'BcryptPassword', + 'cost' => 5, + ) + ); + } + + public static function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + array( true, ':testLargeLayeredTop:sha512:1024:512!sha512:1024:512!sha512:1024:512!sha512:1024:512!5!vnRy+2SrSA0fHt3dwhTP5g==!AVnwfZsAQjn+gULv7FSGjA==!xvHUX3WcpkeSn1lvjWcvBg==!It+OC/N9tu+d3ByHhuB0BQ==!Tb.gqUOiD.aWktVwHM.Q/O!7CcyMfXUPky5ptyATJsR2nq3vUqtnBC', 'testPassword123' ), + ); + /** @codingStandardsIgnoreEnd */ + } + + /** + * @covers LayeredParameterizedPassword::partialCrypt + */ + public function testLargeLayeredPartialUpdate() { + /** @var ParameterizedPassword $partialPassword */ + $partialPassword = $this->passwordFactory->newFromType( 'testLargeLayeredBottom' ); + $partialPassword->crypt( 'testPassword123' ); + + /** @var LayeredParameterizedPassword $totalPassword */ + $totalPassword = $this->passwordFactory->newFromType( 'testLargeLayeredTop' ); + $totalPassword->partialCrypt( $partialPassword ); + + $this->assertTrue( $totalPassword->equals( 'testPassword123' ) ); + } +} diff --git a/tests/phpunit/includes/password/PasswordTestCase.php b/tests/phpunit/includes/password/PasswordTestCase.php new file mode 100644 index 00000000..ef16f1c4 --- /dev/null +++ b/tests/phpunit/includes/password/PasswordTestCase.php @@ -0,0 +1,88 @@ +passwordFactory = new PasswordFactory(); + foreach ( $this->getTypeConfigs() as $type => $config ) { + $this->passwordFactory->register( $type, $config ); + } + } + + /** + * Return an array of configs to be used for this class's password type. + * + * @return array[] + */ + abstract protected function getTypeConfigs(); + + /** + * An array of tests in the form of (bool, string, string), where the first + * element is whether the second parameter (a password hash) and the third + * parameter (a password) should match. + * + * @return array + */ + abstract public static function providePasswordTests(); + + /** + * @dataProvider providePasswordTests + */ + public function testHashing( $shouldMatch, $hash, $password ) { + $hash = $this->passwordFactory->newFromCiphertext( $hash ); + $password = $this->passwordFactory->newFromPlaintext( $password, $hash ); + $this->assertSame( $shouldMatch, $hash->equals( $password ) ); + } + + /** + * @dataProvider providePasswordTests + */ + public function testStringSerialization( $shouldMatch, $hash, $password ) { + $hashObj = $this->passwordFactory->newFromCiphertext( $hash ); + $serialized = $hashObj->toString(); + $unserialized = $this->passwordFactory->newFromCiphertext( $serialized ); + $this->assertTrue( $hashObj->equals( $unserialized ) ); + } + + /** + * @dataProvider providePasswordTests + * @covers InvalidPassword::equals + * @covers InvalidPassword::toString + */ + public function testInvalidUnequalNormal( $shouldMatch, $hash, $password ) { + $invalid = $this->passwordFactory->newFromCiphertext( null ); + $normal = $this->passwordFactory->newFromCiphertext( $hash ); + + $this->assertFalse( $invalid->equals( $normal ) ); + $this->assertFalse( $normal->equals( $invalid ) ); + } +} diff --git a/tests/phpunit/includes/password/Pbkdf2PasswordTest.php b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php new file mode 100644 index 00000000..091853e1 --- /dev/null +++ b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php @@ -0,0 +1,24 @@ + array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha256', + 'cost' => '10000', + 'length' => '128', + ) ); + } + + public static function providePasswordTests() { + return array( + array( true, ":pbkdf2:sha1:1:20:c2FsdA==:DGDID5YfDnHzqbUkr2ASBi/gN6Y=", 'password' ), + array( true, ":pbkdf2:sha1:2:20:c2FsdA==:6mwBTcctb4zNHtkqzh1B8NjeiVc=", 'password' ), + array( true, ":pbkdf2:sha1:4096:20:c2FsdA==:SwB5AbdlSJq+rUnZJvch0GWkKcE=", 'password' ), + array( true, ":pbkdf2:sha1:4096:16:c2EAbHQ=:Vvpqp1VICZ3MN9fwNCXgww==", "pass\x00word" ), + ); + } +} diff --git a/tests/phpunit/includes/poolcounter/PoolCounterTest.php b/tests/phpunit/includes/poolcounter/PoolCounterTest.php new file mode 100644 index 00000000..019e532c --- /dev/null +++ b/tests/phpunit/includes/poolcounter/PoolCounterTest.php @@ -0,0 +1,72 @@ + 'PoolCounterMock', + 'timeout' => 10, + 'workers' => 10, + 'maxqueue' => 100, + ); + + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + ->setConstructorArgs( array( $poolCounterConfig, 'testCounter', 'someKey' ) ) + // don't mock anything - the proper syntax would be setMethods(null), but due + // to a PHPUnit bug that does not work with getMockForAbstractClass() + ->setMethods( array( 'idontexist' ) ) + ->getMockForAbstractClass(); + $this->assertInstanceOf( 'PoolCounter', $poolCounter ); + } + + public function testConstructWithSlots() { + $poolCounterConfig = array( + 'class' => 'PoolCounterMock', + 'timeout' => 10, + 'workers' => 10, + 'slots' => 2, + 'maxqueue' => 100, + ); + + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + ->setConstructorArgs( array( $poolCounterConfig, 'testCounter', 'key' ) ) + ->setMethods( array( 'idontexist' ) ) // don't mock anything + ->getMockForAbstractClass(); + $this->assertInstanceOf( 'PoolCounter', $poolCounter ); + } + + public function testHashKeyIntoSlots() { + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + // don't mock anything - the proper syntax would be setMethods(null), but due + // to a PHPUnit bug that does not work with getMockForAbstractClass() + ->setMethods( array( 'idontexist' ) ) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $hashKeyIntoSlots = new ReflectionMethod( $poolCounter, 'hashKeyIntoSlots' ); + $hashKeyIntoSlots->setAccessible( true ); + + $keysWithTwoSlots = $keysWithFiveSlots = array(); + foreach ( range( 1, 100 ) as $i ) { + $keysWithTwoSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'key ' . $i, 2 ); + $keysWithFiveSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'key ' . $i, 5 ); + } + + $this->assertArrayEquals( range( 0, 1 ), array_unique( $keysWithTwoSlots ) ); + $this->assertArrayEquals( range( 0, 4 ), array_unique( $keysWithFiveSlots ) ); + + // make sure it is deterministic + $this->assertEquals( + $hashKeyIntoSlots->invoke( $poolCounter, 'asdfgh', 1000 ), + $hashKeyIntoSlots->invoke( $poolCounter, 'asdfgh', 1000 ) + ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php new file mode 100644 index 00000000..b0edaaf7 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php @@ -0,0 +1,132 @@ +register( + 'fakeskin', + 'FakeSkin', + function () { + } + ); + } + + /** + * @covers ResourceLoaderFileModule::getAllSkinStyleFiles + */ + public function testGetAllSkinStyleFiles() { + $context = self::getResourceLoaderContext(); + + $baseParams = array( + 'scripts' => array( + 'foo.js', + 'bar.js', + ), + 'styles' => array( + 'foo.css', + 'bar.css' => array( 'media' => 'print' ), + 'screen.less' => array( 'media' => 'screen' ), + 'screen-query.css' => array( 'media' => 'screen and (min-width: 400px)' ), + ), + 'skinStyles' => array( + 'default' => 'quux-fallback.less', + 'fakeskin' => array( + 'baz-vector.css', + 'quux-vector.less', + ), + ), + 'messages' => array( + 'hello', + 'world', + ), + ); + + $module = new ResourceLoaderFileModule( $baseParams ); + + $this->assertEquals( + array( + 'foo.css', + 'baz-vector.css', + 'quux-vector.less', + 'quux-fallback.less', + 'bar.css', + 'screen.less', + 'screen-query.css', + ), + array_map( 'basename', $module->getAllStyleFiles() ) + ); + } + + /** + * @covers ResourceLoaderModule::getDefinitionSummary + * @covers ResourceLoaderFileModule::getDefinitionSummary + */ + public function testDefinitionSummary() { + $context = self::getResourceLoaderContext(); + + $baseParams = array( + 'scripts' => array( 'foo.js', 'bar.js' ), + 'dependencies' => array( 'jquery', 'mediawiki' ), + 'messages' => array( 'hello', 'world' ), + ); + + $module = new ResourceLoaderFileModule( $baseParams ); + + $jsonSummary = json_encode( $module->getDefinitionSummary( $context ) ); + + // Exactly the same + $module = new ResourceLoaderFileModule( $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Instance is insignificant' + ); + + // Re-order dependencies + $module = new ResourceLoaderFileModule( array( + 'dependencies' => array( 'mediawiki', 'jquery' ), + ) + $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of dependencies is insignificant' + ); + + // Re-order messages + $module = new ResourceLoaderFileModule( array( + 'messages' => array( 'world', 'hello' ), + ) + $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of messages is insignificant' + ); + + // Re-order scripts + $module = new ResourceLoaderFileModule( array( + 'scripts' => array( 'bar.js', 'foo.js' ), + ) + $baseParams ); + + $this->assertNotEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of scripts is significant' + ); + + // Subclass + $module = new ResourceLoaderFileModuleTestModule( $baseParams ); + + $this->assertNotEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Class is significant' + ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php new file mode 100644 index 00000000..a1893873 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php @@ -0,0 +1,388 @@ + 'Empty registry', + 'modules' => array(), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [] );' + ) ), + array( array( + 'msg' => 'Basic registry', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ] +] );', + ) ), + array( array( + 'msg' => 'Group signature', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.group.foo' => new ResourceLoaderTestModule( array( 'group' => 'x-foo' ) ), + 'test.group.bar' => new ResourceLoaderTestModule( array( 'group' => 'x-bar' ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.group.foo", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.bar", + "1388534400", + [], + "x-bar" + ] +] );' + ) ), + array( array( + 'msg' => 'Different target (non-test should not be registered)', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.target.foo' => new ResourceLoaderTestModule( array( 'targets' => array( 'x-foo' ) ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ] +] );' + ) ), + array( array( + 'msg' => 'Foreign source', + 'sources' => array( + 'example' => array( + 'loadScript' => 'http://example.org/w/load.php', + 'apiScript' => 'http://example.org/w/api.php', + ), + ), + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule( array( 'source' => 'example' ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php", + "example": "http://example.org/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400", + [], + null, + "example" + ] +] );' + ) ), + array( array( + 'msg' => 'Conditional dependency function', + 'modules' => array( + 'test.x.core' => new ResourceLoaderTestModule(), + 'test.x.polyfill' => new ResourceLoaderTestModule( array( + 'skipFunction' => 'return true;' + ) ), + 'test.y.polyfill' => new ResourceLoaderTestModule( array( + 'skipFunction' => + 'return !!(' . + ' window.JSON &&' . + ' JSON.parse &&' . + ' JSON.stringify' . + ');' + ) ), + 'test.z.foo' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + 'test.x.polyfil', + 'test.y.polyfil', + ), + ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.x.core", + "1388534400" + ], + [ + "test.x.polyfill", + "1388534400", + [], + null, + "local", + "return true;" + ], + [ + "test.y.polyfill", + "1388534400", + [], + null, + "local", + "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);" + ], + [ + "test.z.foo", + "1388534400", + [ + "test.x.core", + "test.x.polyfil", + "test.y.polyfil" + ] + ] +] );', + ) ), + array( array( + // This may seem like an edge case, but a plain MediaWiki core install + // with a few extensions installed is likely far more complex than this + // even, not to mention an install like Wikipedia. + // TODO: Make this even more realistic. + 'msg' => 'Advanced (everything combined)', + 'sources' => array( + 'example' => array( + 'loadScript' => 'http://example.org/w/load.php', + 'apiScript' => 'http://example.org/w/api.php', + ), + ), + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.x.core' => new ResourceLoaderTestModule(), + 'test.x.util' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + ), + ) ), + 'test.x.foo' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + ), + ) ), + 'test.x.bar' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + 'test.x.util', + ), + ) ), + 'test.x.quux' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.foo', + 'test.x.bar', + 'test.x.util', + 'test.x.unknown', + ), + ) ), + 'test.group.foo.1' => new ResourceLoaderTestModule( array( + 'group' => 'x-foo', + ) ), + 'test.group.foo.2' => new ResourceLoaderTestModule( array( + 'group' => 'x-foo', + ) ), + 'test.group.bar.1' => new ResourceLoaderTestModule( array( + 'group' => 'x-bar', + ) ), + 'test.group.bar.2' => new ResourceLoaderTestModule( array( + 'group' => 'x-bar', + 'source' => 'example', + ) ), + 'test.target.foo' => new ResourceLoaderTestModule( array( + 'targets' => array( 'x-foo' ), + ) ), + 'test.target.bar' => new ResourceLoaderTestModule( array( + 'source' => 'example', + 'targets' => array( 'x-foo' ), + ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php", + "example": "http://example.org/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.x.core", + "1388534400" + ], + [ + "test.x.util", + "1388534400", + [ + "test.x.core" + ] + ], + [ + "test.x.foo", + "1388534400", + [ + "test.x.core" + ] + ], + [ + "test.x.bar", + "1388534400", + [ + "test.x.util" + ] + ], + [ + "test.x.quux", + "1388534400", + [ + "test.x.foo", + "test.x.bar", + "test.x.unknown" + ] + ], + [ + "test.group.foo.1", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.foo.2", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.bar.1", + "1388534400", + [], + "x-bar" + ], + [ + "test.group.bar.2", + "1388534400", + [], + "x-bar", + "example" + ] +] );' + ) ), + ); + } + + /** + * @dataProvider provideGetModuleRegistrations + * @covers ResourceLoaderStartupModule::optimizeDependencies + * @covers ResourceLoaderStartUpModule::getModuleRegistrations + * @covers ResourceLoader::makeLoaderSourcesScript + * @covers ResourceLoader::makeLoaderRegisterScript + */ + public function testGetModuleRegistrations( $case ) { + if ( isset( $case['sources'] ) ) { + $this->setMwGlobals( 'wgResourceLoaderSources', $case['sources'] ); + } + + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + + $rl->register( $case['modules'] ); + + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( + ltrim( $case['out'], "\n" ), + $module->getModuleRegistrations( $context ), + $case['msg'] + ); + } + + public static function provideRegistrations() { + return array( + array( array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.min' => new ResourceLoaderTestModule( array( + 'skipFunction' => + 'return !!(' . + ' window.JSON &&' . + ' JSON.parse &&' . + ' JSON.stringify' . + ');', + 'dependencies' => array( + 'test.blank', + ), + ) ), + ) ) + ); + } + /** + * @dataProvider provideRegistrations + */ + public function testRegistrationsMinified( $modules ) { + $this->setMwGlobals( 'wgResourceLoaderDebug', false ); + + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + $rl->register( $modules ); + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( +'mw.loader.addSource({"local":"/w/load.php"});' +. 'mw.loader.register([' +. '["test.blank","1388534400"],' +. '["test.min","1388534400",["test.blank"],null,"local",' +. '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"' +. ']]);', + $module->getModuleRegistrations( $context ), + 'Minified output' + ); + } + + /** + * @dataProvider provideRegistrations + */ + public function testRegistrationsUnminified( $modules ) { + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + $rl->register( $modules ); + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( +'mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.min", + "1388534400", + [ + "test.blank" + ], + null, + "local", + "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);" + ] +] );', + $module->getModuleRegistrations( $context ), + 'Unminified output' + ); + } + +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php new file mode 100644 index 00000000..f19f6886 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -0,0 +1,249 @@ +setMwGlobals( array( + 'wgResourceLoaderLESSFunctions' => array( + 'test-sum' => function ( $frame, $less ) { + $sum = 0; + foreach ( $frame[2] as $arg ) { + $sum += (int)$arg[1]; + } + return $sum; + }, + ), + 'wgResourceLoaderLESSImportPaths' => array( + dirname( dirname( __DIR__ ) ) . '/data/less/common', + ), + 'wgResourceLoaderLESSVars' => array( + 'foo' => '2px', + 'Foo' => '#eeeeee', + 'bar' => 5, + ), + ) ); + } + + /* Hook Methods */ + + /** + * ResourceLoaderRegisterModules hook + */ + public static function resourceLoaderRegisterModules( &$resourceLoader ) { + self::$resourceLoaderRegisterModulesHook = true; + + return true; + } + + /* Provider Methods */ + public static function provideValidModules() { + return array( + array( 'TEST.validModule1', new ResourceLoaderTestModule() ), + ); + } + + /* Test Methods */ + + /** + * Ensures that the ResourceLoaderRegisterModules hook is called when a new + * ResourceLoader object is constructed. + * @covers ResourceLoader::__construct + */ + public function testCreatingNewResourceLoaderCallsRegistrationHook() { + self::$resourceLoaderRegisterModulesHook = false; + $resourceLoader = new ResourceLoader(); + $this->assertTrue( self::$resourceLoaderRegisterModulesHook ); + + return $resourceLoader; + } + + /** + * @dataProvider provideValidModules + * @depends testCreatingNewResourceLoaderCallsRegistrationHook + * @covers ResourceLoader::register + * @covers ResourceLoader::getModule + */ + public function testRegisteredValidModulesAreAccessible( + $name, ResourceLoaderModule $module, ResourceLoader $resourceLoader + ) { + $resourceLoader->register( $name, $module ); + $this->assertEquals( $module, $resourceLoader->getModule( $name ) ); + } + + /** + * @covers ResourceLoaderFileModule::compileLessFile + */ + public function testLessFileCompilation() { + $context = self::getResourceLoaderContext(); + $basePath = __DIR__ . '/../../data/less/module'; + $module = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'styles.less' ), + ) ); + $module->setName( 'test.less' ); + $styles = $module->getStyles( $context ); + $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); + } + + /** + * Strip @noflip annotations from CSS code. + * @param string $css + * @return string + */ + private function stripNoflip( $css ) { + return str_replace( '/*@noflip*/ ', '', $css ); + } + + /** + * What happens when you mix @embed and @noflip? + * This really is an integration test, but oh well. + */ + public function testMixedCssAnnotations( ) { + $basePath = __DIR__ . '/../../data/css'; + $testModule = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'test.css' ), + ) ); + $expectedModule = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'expected.css' ), + ) ); + + $contextLtr = self::getResourceLoaderContext( 'en' ); + $contextRtl = self::getResourceLoaderContext( 'he' ); + + // Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and + // the @noflip annotations are always preserved, we need to strip them first. + $this->assertEquals( + $expectedModule->getStyles( $contextLtr ), + $this->stripNoflip( $testModule->getStyles( $contextLtr ) ), + "/*@noflip*/ with /*@embed*/ gives correct results in LTR mode" + ); + $this->assertEquals( + $expectedModule->getStyles( $contextLtr ), + $this->stripNoflip( $testModule->getStyles( $contextRtl ) ), + "/*@noflip*/ with /*@embed*/ gives correct results in RTL mode" + ); + } + + /** + * @dataProvider providePackedModules + * @covers ResourceLoader::makePackedModulesString + */ + public function testMakePackedModulesString( $desc, $modules, $packed ) { + $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc ); + } + + /** + * @dataProvider providePackedModules + * @covers ResourceLoaderContext::expandModuleNames + */ + public function testexpandModuleNames( $desc, $modules, $packed ) { + $this->assertEquals( $modules, ResourceLoaderContext::expandModuleNames( $packed ), $desc ); + } + + public static function providePackedModules() { + return array( + array( + 'Example from makePackedModulesString doc comment', + array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ), + 'foo.bar,baz|bar.baz,quux', + ), + array( + 'Example from expandModuleNames doc comment', + array( 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ), + 'jquery.foo,bar|jquery.ui.baz,quux', + ), + array( + 'Regression fixed in r88706 with dotless names', + array( 'foo', 'bar', 'baz' ), + 'foo,bar,baz', + ), + array( + 'Prefixless modules after a prefixed module', + array( 'single.module', 'foobar', 'foobaz' ), + 'single.module|foobar,foobaz', + ), + ); + } + + public static function provideAddSource() { + return array( + array( 'examplewiki', '//example.org/w/load.php', 'examplewiki' ), + array( 'example2wiki', array( 'loadScript' => '//example.com/w/load.php' ), 'example2wiki' ), + array( + array( 'foowiki' => '//foo.org/w/load.php', 'bazwiki' => '//baz.org/w/load.php' ), + null, + array( 'foowiki', 'bazwiki' ) + ), + array( + array( 'foowiki' => '//foo.org/w/load.php' ), + null, + false, + ), + ); + } + + /** + * @dataProvider provideAddSource + * @covers ResourceLoader::addSource + */ + public function testAddSource( $name, $info, $expected ) { + $rl = new ResourceLoader; + if ( $expected === false ) { + $this->setExpectedException( 'MWException', 'ResourceLoader duplicate source addition error' ); + $rl->addSource( $name, $info ); + } + $rl->addSource( $name, $info ); + if ( is_array( $expected ) ) { + foreach ( $expected as $source ) { + $this->assertArrayHasKey( $source, $rl->getSources() ); + } + } else { + $this->assertArrayHasKey( $expected, $rl->getSources() ); + } + } + + public static function fakeSources() { + return array( + 'examplewiki' => array( + 'loadScript' => '//example.org/w/load.php', + 'apiScript' => '//example.org/w/api.php', + ), + 'example2wiki' => array( + 'loadScript' => '//example.com/w/load.php', + 'apiScript' => '//example.com/w/api.php', + ), + ); + } + + /** + * @covers ResourceLoader::getLoadScript + */ + public function testGetLoadScript() { + $this->setMwGlobals( 'wgResourceLoaderSources', array() ); + $rl = new ResourceLoader(); + $sources = self::fakeSources(); + $rl->addSource( $sources ); + foreach ( array( 'examplewiki', 'example2wiki' ) as $name ) { + $this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] ); + } + + try { + $rl->getLoadScript( 'thiswasneverreigstered' ); + $this->assertTrue( false, 'ResourceLoader::getLoadScript should have thrown an exception' ); + } catch ( MWException $e ) { + $this->assertTrue( true ); + } + } +} + +/* Hooks */ +global $wgHooks; +$wgHooks['ResourceLoaderRegisterModules'][] = 'ResourceLoaderTest::resourceLoaderRegisterModules'; diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php new file mode 100644 index 00000000..9dc18050 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php @@ -0,0 +1,67 @@ +getMockBuilder( 'ResourceLoaderWikiModuleTestModule' ) + ->setMethods( array( 'getTitleInfo', 'getGroup' ) ) + ->getMock(); + $module->expects( $this->any() ) + ->method( 'getTitleInfo' ) + ->will( $this->returnValue( $titleInfo ) ); + $module->expects( $this->any() ) + ->method( 'getGroup' ) + ->will( $this->returnValue( $group ) ); + $context = $this->getMockBuilder( 'ResourceLoaderContext' ) + ->disableOriginalConstructor() + ->getMock(); + $this->assertEquals( $expected, $module->isKnownEmpty( $context ) ); + } + + public static function provideIsKnownEmpty() { + return array( + // No valid pages + array( array(), 'test1', true ), + // 'site' module with a non-empty page + array( + array( + 'MediaWiki:Common.js' => array( + 'timestamp' => 123456789, + 'length' => 1234 + ) + ), 'site', false, + ), + // 'site' module with an empty page + array( + array( + 'MediaWiki:Monobook.js' => array( + 'timestamp' => 987654321, + 'length' => 0, + ), + ), 'site', false, + ), + // 'user' module with a non-empty page + array( + array( + 'User:FooBar/common.js' => array( + 'timestamp' => 246813579, + 'length' => 25, + ), + ), 'user', false, + ), + // 'user' module with an empty page + array( + array( + 'User:FooBar/monobook.js' => array( + 'timestamp' => 1357924680, + 'length' => 0, + ), + ), 'user', true, + ), + ); + } +} diff --git a/tests/phpunit/includes/search/SearchEngineTest.php b/tests/phpunit/includes/search/SearchEngineTest.php new file mode 100644 index 00000000..3da13615 --- /dev/null +++ b/tests/phpunit/includes/search/SearchEngineTest.php @@ -0,0 +1,187 @@ + + * @note Coverage will only ever show one of on of the Search* classes + */ +class SearchEngineTest extends MediaWikiLangTestCase { + + /** + * @var SearchEngine + */ + protected $search; + + protected $pageList; + + /** + * Checks for database type & version. + * Will skip current test if DB does not support search. + */ + protected function setUp() { + parent::setUp(); + + // Search tests require MySQL or SQLite with FTS + $dbType = $this->db->getType(); + $dbSupported = ( $dbType === 'mysql' ) + || ( $dbType === 'sqlite' && $this->db->getFulltextSearchModule() == 'FTS3' ); + + if ( !$dbSupported ) { + $this->markTestSkipped( "MySQL or SQLite with FTS3 only" ); + } + + $searchType = $this->db->getSearchEngine(); + $this->setMwGlobals( array( + 'wgSearchType' => $searchType + ) ); + + if ( !isset( self::$pageList ) ) { + $this->addPages(); + } + + $this->search = new $searchType( $this->db ); + } + + protected function tearDown() { + unset( $this->search ); + + parent::tearDown(); + } + + protected function addPages() { + if ( !$this->isWikitextNS( NS_MAIN ) ) { + // @todo cover the case of non-wikitext content in the main namespace + return; + } + + $this->insertPage( "Not_Main_Page", "This is not a main page", 0 ); + $this->insertPage( + 'Talk:Not_Main_Page', + 'This is not a talk page to the main page, see [[smithee]]', + 1 + ); + $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]', 0 ); + $this->insertPage( 'Talk:Smithee', 'This article sucks.', 1 ); + $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.', 0 ); + $this->insertPage( 'Another_page', 'This page also is unrelated.', 0 ); + $this->insertPage( 'Help:Help', 'Help me!', 4 ); + $this->insertPage( 'Thppt', 'Blah blah', 0 ); + $this->insertPage( 'Alan_Smithee', 'yum', 0 ); + $this->insertPage( 'Pages', 'are\'food', 0 ); + $this->insertPage( 'HalfOneUp', 'AZ', 0 ); + $this->insertPage( 'FullOneUp', 'AZ', 0 ); + $this->insertPage( 'HalfTwoLow', 'az', 0 ); + $this->insertPage( 'FullTwoLow', 'az', 0 ); + $this->insertPage( 'HalfNumbers', '1234567890', 0 ); + $this->insertPage( 'FullNumbers', '1234567890', 0 ); + $this->insertPage( 'DomainName', 'example.com', 0 ); + } + + protected function fetchIds( $results ) { + if ( !$this->isWikitextNS( NS_MAIN ) ) { + $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content " + . "in the main namespace" ); + } + $this->assertTrue( is_object( $results ) ); + + $matches = array(); + $row = $results->next(); + while ( $row ) { + $matches[] = $row->getTitle()->getPrefixedText(); + $row = $results->next(); + } + $results->free(); + # Search is not guaranteed to return results in a certain order; + # sort them numerically so we will compare simply that we received + # the expected matches. + sort( $matches ); + + return $matches; + } + + /** + * Insert a new page + * + * @param string $pageName Page name + * @param string $text Page's content + * @param int $ns Unused + */ + protected function insertPage( $pageName, $text, $ns ) { + $title = Title::newFromText( $pageName, $ns ); + + $user = User::newFromName( 'WikiSysop' ); + $comment = 'Search Test'; + + // avoid memory leak...? + LinkCache::singleton()->clear(); + + $page = WikiPage::factory( $title ); + $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user ); + + $this->pageList[] = array( $title, $page->getId() ); + + return true; + } + + public function testFullWidth() { + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'AZ' ) ), + "Search for normalized from Half-width Upper" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'az' ) ), + "Search for normalized from Half-width Lower" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'AZ' ) ), + "Search for normalized from Full-width Upper" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'az' ) ), + "Search for normalized from Full-width Lower" ); + } + + public function testTextSearch() { + $this->assertEquals( + array( 'Smithee' ), + $this->fetchIds( $this->search->searchText( 'smithee' ) ), + "Plain search failed" ); + } + + public function testTextPowerSearch() { + $this->search->setNamespaces( array( 0, 1, 4 ) ); + $this->assertEquals( + array( + 'Smithee', + 'Talk:Not Main Page', + ), + $this->fetchIds( $this->search->searchText( 'smithee' ) ), + "Power search failed" ); + } + + public function testTitleSearch() { + $this->assertEquals( + array( + 'Alan Smithee', + 'Smithee', + ), + $this->fetchIds( $this->search->searchTitle( 'smithee' ) ), + "Title search failed" ); + } + + public function testTextTitlePowerSearch() { + $this->search->setNamespaces( array( 0, 1, 4 ) ); + $this->assertEquals( + array( + 'Alan Smithee', + 'Smithee', + 'Talk:Smithee', + ), + $this->fetchIds( $this->search->searchTitle( 'smithee' ) ), + "Title power search failed" ); + } + +} diff --git a/tests/phpunit/includes/search/SearchUpdateTest.php b/tests/phpunit/includes/search/SearchUpdateTest.php new file mode 100644 index 00000000..c6275371 --- /dev/null +++ b/tests/phpunit/includes/search/SearchUpdateTest.php @@ -0,0 +1,81 @@ +setMwGlobals( 'wgSearchType', 'MockSearch' ); + } + + public function updateText( $text ) { + return trim( SearchUpdate::updateText( $text ) ); + } + + /** + * @covers SearchUpdate::updateText + */ + public function testUpdateText() { + $this->assertEquals( + 'test', + $this->updateText( '

    TeSt
    ' ), + 'HTML stripped, text lowercased' + ); + + $this->assertEquals( + 'foo bar boz quux', + $this->updateText( << +
    foo
    bar + bozquux + +EOT + ), 'Stripping HTML tables' ); + + $this->assertEquals( + 'a b', + $this->updateText( 'a > b' ), + 'Handle unclosed tags' + ); + + $text = str_pad( "foo assertNotEquals( + '', + $this->updateText( $text ), + 'Bug 18609' + ); + } + + /** + * @covers SearchUpdate::updateText + * @todo give this test a real name explaining what is being tested here + */ + public function testBug32712() { + $text = "text „http://example.com“ text"; + $result = $this->updateText( $text ); + $processed = preg_replace( '/Q/u', 'Q', $result ); + $this->assertTrue( + $processed != '', + 'Link surrounded by unicode quotes should not fail UTF-8 validation' + ); + } +} diff --git a/tests/phpunit/includes/site/MediaWikiSiteTest.php b/tests/phpunit/includes/site/MediaWikiSiteTest.php new file mode 100644 index 00000000..c3fd1557 --- /dev/null +++ b/tests/phpunit/includes/site/MediaWikiSiteTest.php @@ -0,0 +1,109 @@ + + */ +class MediaWikiSiteTest extends SiteTest { + + public function testNormalizePageTitle() { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiki' ); + + //NOTE: this does not actually call out to the enwiki site to perform the normalization, + // but uses a local Title object to do so. This is hardcoded on SiteLink::normalizePageTitle + // for the case that MW_PHPUNIT_TEST is set. + $this->assertEquals( 'Foo', $site->normalizePageName( ' foo ' ) ); + } + + public function fileUrlProvider() { + return array( + // url, filepath, path arg, expected + array( 'https://en.wikipedia.org', '/w/$1', 'api.php', 'https://en.wikipedia.org/w/api.php' ), + array( 'https://en.wikipedia.org', '/w/', 'api.php', 'https://en.wikipedia.org/w/' ), + array( + 'https://en.wikipedia.org', + '/foo/page.php?name=$1', + 'api.php', + 'https://en.wikipedia.org/foo/page.php?name=api.php' + ), + array( + 'https://en.wikipedia.org', + '/w/$1', + '', + 'https://en.wikipedia.org/w/' + ), + array( + 'https://en.wikipedia.org', + '/w/$1', + 'foo/bar/api.php', + 'https://en.wikipedia.org/w/foo/bar/api.php' + ), + ); + } + + /** + * @dataProvider fileUrlProvider + * @covers MediaWikiSite::getFileUrl + */ + public function testGetFileUrl( $url, $filePath, $pathArgument, $expected ) { + $site = new MediaWikiSite(); + $site->setFilePath( $url . $filePath ); + + $this->assertEquals( $expected, $site->getFileUrl( $pathArgument ) ); + } + + public static function provideGetPageUrl() { + return array( + // path, page, expected substring + array( 'http://acme.test/wiki/$1', 'Berlin', '/wiki/Berlin' ), + array( 'http://acme.test/wiki/', 'Berlin', '/wiki/' ), + array( 'http://acme.test/w/index.php?title=$1', 'Berlin', '/w/index.php?title=Berlin' ), + array( 'http://acme.test/wiki/$1', '', '/wiki/' ), + array( 'http://acme.test/wiki/$1', 'Berlin/sub page', '/wiki/Berlin/sub_page' ), + array( 'http://acme.test/wiki/$1', 'Cork (city) ', '/Cork_(city)' ), + array( 'http://acme.test/wiki/$1', 'M&M', '/wiki/M%26M' ), + ); + } + + /** + * @dataProvider provideGetPageUrl + * @covers MediaWikiSite::getPageUrl + */ + public function testGetPageUrl( $path, $page, $expected ) { + $site = new MediaWikiSite(); + $site->setLinkPath( $path ); + + $this->assertContains( $path, $site->getPageUrl() ); + $this->assertContains( $expected, $site->getPageUrl( $page ) ); + } +} diff --git a/tests/phpunit/includes/site/SiteListTest.php b/tests/phpunit/includes/site/SiteListTest.php new file mode 100644 index 00000000..534ed9c9 --- /dev/null +++ b/tests/phpunit/includes/site/SiteListTest.php @@ -0,0 +1,240 @@ + + */ +class SiteListTest extends MediaWikiTestCase { + + /** + * Returns instances of SiteList implementing objects. + * @return array + */ + public function siteListProvider() { + $sitesArrays = $this->siteArrayProvider(); + + $listInstances = array(); + + foreach ( $sitesArrays as $sitesArray ) { + $listInstances[] = new SiteList( $sitesArray[0] ); + } + + return $this->arrayWrap( $listInstances ); + } + + /** + * Returns arrays with instances of Site implementing objects. + * @return array + */ + public function siteArrayProvider() { + $sites = TestSites::getSites(); + + $siteArrays = array(); + + $siteArrays[] = $sites; + + $siteArrays[] = array( array_shift( $sites ) ); + + $siteArrays[] = array( array_shift( $sites ), array_shift( $sites ) ); + + return $this->arrayWrap( $siteArrays ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::isEmpty + */ + public function testIsEmpty( SiteList $sites ) { + $this->assertEquals( count( $sites ) === 0, $sites->isEmpty() ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSite + */ + public function testGetSiteByGlobalId( SiteList $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertEquals( $site, $sites->getSite( $site->getGlobalId() ) ); + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSiteByInternalId + */ + public function testGetSiteByInternalId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + if ( is_integer( $site->getInternalId() ) ) { + $this->assertEquals( $site, $sites->getSiteByInternalId( $site->getInternalId() ) ); + } + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSiteByNavigationId + */ + public function testGetSiteByNavigationId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $ids = $site->getNavigationIds(); + foreach ( $ids as $navId ) { + $this->assertEquals( $site, $sites->getSiteByNavigationId( $navId ) ); + } + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasSite + */ + public function testHasGlobalId( $sites ) { + $this->assertFalse( $sites->hasSite( 'non-existing-global-id' ) ); + $this->assertFalse( $sites->hasInternalId( 720101010 ) ); + + if ( !$sites->isEmpty() ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasInternalId + */ + public function testHasInternallId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + if ( is_integer( $site->getInternalId() ) ) { + $this->assertTrue( $site, $sites->hasInternalId( $site->getInternalId() ) ); + } + } + + $this->assertFalse( $sites->hasInternalId( -1 ) ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasNavigationId + */ + public function testHasNavigationId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $ids = $site->getNavigationIds(); + foreach ( $ids as $navId ) { + $this->assertTrue( $sites->hasNavigationId( $navId ) ); + } + } + + $this->assertFalse( $sites->hasNavigationId( 'non-existing-navigation-id' ) ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getGlobalIdentifiers + */ + public function testGetGlobalIdentifiers( SiteList $sites ) { + $identifiers = $sites->getGlobalIdentifiers(); + + $this->assertTrue( is_array( $identifiers ) ); + + $expected = array(); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $expected[] = $site->getGlobalId(); + } + + $this->assertArrayEquals( $expected, $identifiers ); + } + + /** + * @dataProvider siteListProvider + * + * @since 1.21 + * + * @param SiteList $list + * @covers SiteList::getSerializationData + * @covers SiteList::unserialize + */ + public function testSerialization( SiteList $list ) { + $serialization = serialize( $list ); + /** + * @var SiteArray $copy + */ + $copy = unserialize( $serialization ); + + $this->assertArrayEquals( $list->getGlobalIdentifiers(), $copy->getGlobalIdentifiers() ); + + /** + * @var Site $site + */ + foreach ( $list as $site ) { + $this->assertTrue( $copy->hasInternalId( $site->getInternalId() ) ); + + foreach ( $site->getNavigationIds() as $navId ) { + $this->assertTrue( + $copy->hasNavigationId( $navId ), + 'unserialized data expects nav id ' . $navId . ' for site ' . $site->getGlobalId() + ); + } + } + } +} diff --git a/tests/phpunit/includes/site/SiteSQLStoreTest.php b/tests/phpunit/includes/site/SiteSQLStoreTest.php new file mode 100644 index 00000000..6002c1a1 --- /dev/null +++ b/tests/phpunit/includes/site/SiteSQLStoreTest.php @@ -0,0 +1,134 @@ + + */ +class SiteSQLStoreTest extends MediaWikiTestCase { + + /** + * @covers SiteSQLStore::getSites + */ + public function testGetSites() { + $expectedSites = TestSites::getSites(); + TestSites::insertIntoDb(); + + $store = SiteSQLStore::newInstance(); + + $sites = $store->getSites(); + + $this->assertInstanceOf( 'SiteList', $sites ); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertInstanceOf( 'Site', $site ); + } + + foreach ( $expectedSites as $site ) { + if ( $site->getGlobalId() !== null ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @covers SiteSQLStore::saveSites + */ + public function testSaveSites() { + $store = SiteSQLStore::newInstance(); + + $sites = array(); + + $site = new Site(); + $site->setGlobalId( 'ertrywuutr' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'sdfhxujgkfpth' ); + $site->setLanguageCode( 'nl' ); + $sites[] = $site; + + $this->assertTrue( $store->saveSites( $sites ) ); + + $site = $store->getSite( 'ertrywuutr' ); + $this->assertInstanceOf( 'Site', $site ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + $this->assertTrue( is_integer( $site->getInternalId() ) ); + $this->assertTrue( $site->getInternalId() >= 0 ); + + $site = $store->getSite( 'sdfhxujgkfpth' ); + $this->assertInstanceOf( 'Site', $site ); + $this->assertEquals( 'nl', $site->getLanguageCode() ); + $this->assertTrue( is_integer( $site->getInternalId() ) ); + $this->assertTrue( $site->getInternalId() >= 0 ); + } + + /** + * @covers SiteSQLStore::reset + */ + public function testReset() { + $store1 = SiteSQLStore::newInstance(); + $store2 = SiteSQLStore::newInstance(); + + // initialize internal cache + $this->assertGreaterThan( 0, $store1->getSites()->count() ); + $this->assertGreaterThan( 0, $store2->getSites()->count() ); + + // Clear actual data. Will purge the external cache and reset the internal + // cache in $store1, but not the internal cache in store2. + $this->assertTrue( $store1->clear() ); + + // sanity check: $store2 should have a stale cache now + $this->assertNotNull( $store2->getSite( 'enwiki' ) ); + + // purge cache + $store2->reset(); + + // ...now the internal cache of $store2 should be updated and thus empty. + $site = $store2->getSite( 'enwiki' ); + $this->assertNull( $site ); + } + + /** + * @covers SiteSQLStore::clear + */ + public function testClear() { + $store = SiteSQLStore::newInstance(); + $this->assertTrue( $store->clear() ); + + $site = $store->getSite( 'enwiki' ); + $this->assertNull( $site ); + + $sites = $store->getSites(); + $this->assertEquals( 0, $sites->count() ); + } +} diff --git a/tests/phpunit/includes/site/SiteTest.php b/tests/phpunit/includes/site/SiteTest.php new file mode 100644 index 00000000..29c1ff33 --- /dev/null +++ b/tests/phpunit/includes/site/SiteTest.php @@ -0,0 +1,296 @@ + + */ +class SiteTest extends MediaWikiTestCase { + + public function instanceProvider() { + return $this->arrayWrap( TestSites::getSites() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getInterwikiIds + */ + public function testGetInterwikiIds( Site $site ) { + $this->assertInternalType( 'array', $site->getInterwikiIds() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getNavigationIds + */ + public function testGetNavigationIds( Site $site ) { + $this->assertInternalType( 'array', $site->getNavigationIds() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::addNavigationId + */ + public function testAddNavigationId( Site $site ) { + $site->addNavigationId( 'foobar' ); + $this->assertTrue( in_array( 'foobar', $site->getNavigationIds(), true ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::addInterwikiId + */ + public function testAddInterwikiId( Site $site ) { + $site->addInterwikiId( 'foobar' ); + $this->assertTrue( in_array( 'foobar', $site->getInterwikiIds(), true ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getLanguageCode + */ + public function testGetLanguageCode( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getLanguageCode(), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setLanguageCode + */ + public function testSetLanguageCode( Site $site ) { + $site->setLanguageCode( 'en' ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::normalizePageName + */ + public function testNormalizePageName( Site $site ) { + $this->assertInternalType( 'string', $site->normalizePageName( 'Foobar' ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getGlobalId + */ + public function testGetGlobalId( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getGlobalId(), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setGlobalId + */ + public function testSetGlobalId( Site $site ) { + $site->setGlobalId( 'foobar' ); + $this->assertEquals( 'foobar', $site->getGlobalId() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getType + */ + public function testGetType( Site $site ) { + $this->assertInternalType( 'string', $site->getType() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getPath + */ + public function testGetPath( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getPath( 'page_path' ), null ); + $this->assertTypeOrValue( 'string', $site->getPath( 'file_path' ), null ); + $this->assertTypeOrValue( 'string', $site->getPath( 'foobar' ), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getAllPaths + */ + public function testGetAllPaths( Site $site ) { + $this->assertInternalType( 'array', $site->getAllPaths() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setPath + * @covers Site::removePath + */ + public function testSetAndRemovePath( Site $site ) { + $count = count( $site->getAllPaths() ); + + $site->setPath( 'spam', 'http://www.wikidata.org/$1' ); + $site->setPath( 'spam', 'http://www.wikidata.org/foo/$1' ); + $site->setPath( 'foobar', 'http://www.wikidata.org/bar/$1' ); + + $this->assertEquals( $count + 2, count( $site->getAllPaths() ) ); + + $this->assertInternalType( 'string', $site->getPath( 'foobar' ) ); + $this->assertEquals( 'http://www.wikidata.org/foo/$1', $site->getPath( 'spam' ) ); + + $site->removePath( 'spam' ); + $site->removePath( 'foobar' ); + + $this->assertEquals( $count, count( $site->getAllPaths() ) ); + + $this->assertNull( $site->getPath( 'foobar' ) ); + $this->assertNull( $site->getPath( 'spam' ) ); + } + + /** + * @covers Site::setLinkPath + */ + public function testSetLinkPath() { + $site = new Site(); + $path = "TestPath/$1"; + + $site->setLinkPath( $path ); + $this->assertEquals( $path, $site->getLinkPath() ); + } + + /** + * @covers Site::getLinkPathType + */ + public function testGetLinkPathType() { + $site = new Site(); + + $path = 'TestPath/$1'; + $site->setLinkPath( $path ); + $this->assertEquals( $path, $site->getPath( $site->getLinkPathType() ) ); + + $path = 'AnotherPath/$1'; + $site->setPath( $site->getLinkPathType(), $path ); + $this->assertEquals( $path, $site->getLinkPath() ); + } + + /** + * @covers Site::setPath + */ + public function testSetPath() { + $site = new Site(); + + $path = 'TestPath/$1'; + $site->setPath( 'foo', $path ); + + $this->assertEquals( $path, $site->getPath( 'foo' ) ); + } + + /** + * @covers Site::setPath + * @covers Site::getProtocol + */ + public function testProtocolRelativePath() { + $site = new Site(); + + $type = $site->getLinkPathType(); + $path = '//acme.com/'; // protocol-relative URL + $site->setPath( $type, $path ); + + $this->assertEquals( '', $site->getProtocol() ); + } + + public static function provideGetPageUrl() { + //NOTE: the assumption that the URL is built by replacing $1 + // with the urlencoded version of $page + // is true for Site but not guaranteed for subclasses. + // Subclasses need to override this provider appropriately. + + return array( + array( #0 + 'http://acme.test/TestPath/$1', + 'Foo', + '/TestPath/Foo', + ), + array( #1 + 'http://acme.test/TestScript?x=$1&y=bla', + 'Foo', + 'TestScript?x=Foo&y=bla', + ), + array( #2 + 'http://acme.test/TestPath/$1', + 'foo & bar/xyzzy (quux-shmoox?)', + '/TestPath/foo%20%26%20bar%2Fxyzzy%20%28quux-shmoox%3F%29', + ), + ); + } + + /** + * @dataProvider provideGetPageUrl + * @covers Site::getPageUrl + */ + public function testGetPageUrl( $path, $page, $expected ) { + $site = new Site(); + + //NOTE: the assumption that getPageUrl is based on getLinkPath + // is true for Site but not guaranteed for subclasses. + // Subclasses need to override this test case appropriately. + $site->setLinkPath( $path ); + $this->assertContains( $path, $site->getPageUrl() ); + + $this->assertContains( $expected, $site->getPageUrl( $page ) ); + } + + protected function assertTypeOrFalse( $type, $value ) { + if ( $value === false ) { + $this->assertTrue( true ); + } else { + $this->assertInternalType( $type, $value ); + } + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::serialize + * @covers Site::unserialize + */ + public function testSerialization( Site $site ) { + $this->assertInstanceOf( 'Serializable', $site ); + + $serialization = serialize( $site ); + $newInstance = unserialize( $serialization ); + + $this->assertInstanceOf( 'Site', $newInstance ); + + $this->assertEquals( $serialization, serialize( $newInstance ) ); + } +} diff --git a/tests/phpunit/includes/site/TestSites.php b/tests/phpunit/includes/site/TestSites.php new file mode 100644 index 00000000..af314ba2 --- /dev/null +++ b/tests/phpunit/includes/site/TestSites.php @@ -0,0 +1,115 @@ + + */ +class TestSites { + + /** + * @since 1.21 + * + * @return array + */ + public static function getSites() { + $sites = array(); + + $site = new Site(); + $site->setGlobalId( 'foobar' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'enwiktionary' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'dewiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'de' ); + $site->addInterwikiId( 'dewiktionary' ); + $site->addInterwikiId( 'wiktionaryde' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://de.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://de.wiktionary.org/w/$1" ); + $sites[] = $site; + + $site = new Site(); + $site->setGlobalId( 'spam' ); + $site->setGroup( 'spam' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'spam' ); + $site->addNavigationId( 'spamz' ); + $site->addInterwikiId( 'spamzz' ); + $site->setLinkPath( "http://spamzz.test/testing/" ); + $sites[] = $site; + + /** + * Add at least one right-to-left language (current RTL languages in MediaWiki core are: + * aeb, ar, arc, arz, azb, bcc, bqi, ckb, dv, en_rtl, fa, glk, he, khw, kk_arab, kk_cn, + * ks_arab, ku_arab, lrc, mzn, pnb, ps, sd, ug_arab, ur, yi). + */ + $languageCodes = array( + 'de', + 'en', + 'fa', //right-to-left + 'nl', + 'nn', + 'no', + 'sr', + 'sv', + ); + foreach ( $languageCodes as $langCode ) { + $site = new MediaWikiSite(); + $site->setGlobalId( $langCode . 'wiki' ); + $site->setGroup( 'wikipedia' ); + $site->setLanguageCode( $langCode ); + $site->addInterwikiId( $langCode ); + $site->addNavigationId( $langCode ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://$langCode.wikipedia.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://$langCode.wikipedia.org/w/$1" ); + $sites[] = $site; + } + + return $sites; + } + + /** + * Inserts sites into the database for the unit tests that need them. + * + * @since 0.1 + */ + public static function insertIntoDb() { + $sitesTable = SiteSQLStore::newInstance(); + $sitesTable->clear(); + $sitesTable->saveSites( TestSites::getSites() ); + } +} diff --git a/tests/phpunit/includes/skins/SkinFactoryTest.php b/tests/phpunit/includes/skins/SkinFactoryTest.php new file mode 100644 index 00000000..d3663c84 --- /dev/null +++ b/tests/phpunit/includes/skins/SkinFactoryTest.php @@ -0,0 +1,70 @@ +register( 'fallback', 'Fallback', function () { + return new SkinFallback(); + } ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( 'InvalidArgumentException' ); + $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithNoBuilders() { + $factory = new SkinFactory(); + $this->setExpectedException( 'SkinException' ); + $factory->makeSkin( 'nobuilderregistered' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithInvalidCallback() { + $factory = new SkinFactory(); + $factory->register( 'unittest', 'Unittest', function () { + return true; // Not a Skin object + } ); + $this->setExpectedException( 'UnexpectedValueException' ); + $factory->makeSkin( 'unittest' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithValidCallback() { + $factory = new SkinFactory(); + $factory->register( 'testfallback', 'TestFallback', function () { + return new SkinFallback(); + } ); + + $skin = $factory->makeSkin( 'testfallback' ); + $this->assertInstanceOf( 'Skin', $skin ); + $this->assertInstanceOf( 'SkinFallback', $skin ); + } + + /** + * @covers SkinFactory::getSkinNames + */ + public function testGetSkinNames() { + $factory = new SkinFactory(); + // A fake callback we can use that will never be called + $callback = function () { + // NOP + }; + $factory->register( 'skin1', 'Skin1', $callback ); + $factory->register( 'skin2', 'Skin2', $callback ); + $names = $factory->getSkinNames(); + $this->assertArrayHasKey( 'skin1', $names ); + $this->assertArrayHasKey( 'skin2', $names ); + $this->assertEquals( 'Skin1', $names['skin1'] ); + $this->assertEquals( 'Skin2', $names['skin2'] ); + } +} diff --git a/tests/phpunit/includes/skins/SkinTemplateTest.php b/tests/phpunit/includes/skins/SkinTemplateTest.php new file mode 100644 index 00000000..baa995d4 --- /dev/null +++ b/tests/phpunit/includes/skins/SkinTemplateTest.php @@ -0,0 +1,43 @@ + + */ + +class SkinTemplateTest extends MediaWikiTestCase { + /** + * @dataProvider makeListItemProvider + */ + public function testMakeListItem( $expected, $key, $item, $options, $message ) { + $template = $this->getMockForAbstractClass( 'BaseTemplate' ); + + $this->assertEquals( + $expected, + $template->makeListItem( $key, $item, $options ), + $message + ); + } + + public function makeListItemProvider() { + return array( + array( + '
  • text
  • ', + '', + array( + 'class' => 'class', + 'itemtitle' => 'itemtitle', + 'href' => 'url', + 'title' => 'title', + 'text' => 'text' + ), + array(), + 'Test makteListItem with normal values' + ) + ); + } +} diff --git a/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php new file mode 100644 index 00000000..779fa558 --- /dev/null +++ b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php @@ -0,0 +1,225 @@ + array( 'SpecialAllPages', false ), + 'closure' => array( function() { + return new SpecialAllPages(); + }, false ), + 'function' => array( array( $this, 'newSpecialAllPages' ), false ), + ); + } + + /** + * @dataProvider specialPageProvider + */ + public function testGetPage( $spec, $shouldReuseInstance ) { + $this->mergeMwGlobalArrayValue( 'wgSpecialPages', array( 'testdummy' => $spec ) ); + SpecialPageFactory::resetList(); + + $page = SpecialPageFactory::getPage( 'testdummy' ); + $this->assertInstanceOf( 'SpecialPage', $page ); + + $page2 = SpecialPageFactory::getPage( 'testdummy' ); + $this->assertEquals( $shouldReuseInstance, $page2 === $page, "Should re-use instance:" ); + } + + public function testGetNames() { + $this->mergeMwGlobalArrayValue( 'wgSpecialPages', array( 'testdummy' => 'SpecialAllPages' ) ); + SpecialPageFactory::resetList(); + + $names = SpecialPageFactory::getNames(); + $this->assertInternalType( 'array', $names ); + $this->assertContains( 'testdummy', $names ); + } + + public function testResolveAlias() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + list( $name, $param ) = SpecialPageFactory::resolveAlias( 'Spezialseiten/Foo' ); + $this->assertEquals( 'Specialpages', $name ); + $this->assertEquals( 'Foo', $param ); + } + + public function testGetLocalNameFor() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + $name = SpecialPageFactory::getLocalNameFor( 'Specialpages', 'Foo' ); + $this->assertEquals( 'Spezialseiten/Foo', $name ); + } + + public function testGetTitleForAlias() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + $title = SpecialPageFactory::getTitleForAlias( 'Specialpages/Foo' ); + $this->assertEquals( 'Spezialseiten/Foo', $title->getText() ); + $this->assertEquals( NS_SPECIAL, $title->getNamespace() ); + } + + /** + * @dataProvider provideTestConflictResolution + */ + public function testConflictResolution( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ) { + global $wgContLang; + $lang = clone $wgContLang; + $lang->mExtendedSpecialPageAliases = $aliasesList; + $this->setMwGlobals( 'wgContLang', $lang ); + $this->setMwGlobals( 'wgSpecialPages', + array_combine( array_keys( $aliasesList ), array_keys( $aliasesList ) ) + ); + SpecialPageFactory::resetList(); + + // Catch the warnings we expect to be raised + $warnings = array(); + $this->setMwGlobals( 'wgDevelopmentWarnings', true ); + set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) { + if ( preg_match( '/First alias \'[^\']*\' for .*/', $errstr ) || + preg_match( '/Did not find a usable alias for special page .*/', $errstr ) + ) { + $warnings[] = $errstr; + return true; + } + return false; + } ); + $reset = new ScopedCallback( 'restore_error_handler' ); + + list( $name, /*...*/ ) = SpecialPageFactory::resolveAlias( $alias ); + $this->assertEquals( $expectedName, $name, "$test: Alias to name" ); + $result = SpecialPageFactory::getLocalNameFor( $name ); + $this->assertEquals( $expectedAlias, $result, "$test: Alias to name to alias" ); + + $gotWarnings = count( $warnings ); + if ( $gotWarnings !== $expectWarnings ) { + $this->fail( "Expected $expectWarnings warning(s), but got $gotWarnings:\n" . + join( "\n", $warnings ) + ); + } + } + + /** + * @dataProvider provideTestConflictResolution + */ + public function testConflictResolutionReversed( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ) { + // Make sure order doesn't matter by reversing the list + $aliasesList = array_reverse( $aliasesList ); + return $this->testConflictResolution( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ); + } + + public function provideTestConflictResolution() { + return array( + array( + 'Canonical name wins', + array( 'Foo' => array( 'Foo', 'Bar' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Foo', + 'Foo', + 'Foo', + 1, + ), + + array( + 'Doesn\'t redirect to a different special page\'s canonical name', + array( 'Foo' => array( 'Foo', 'Bar' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'Canonical name wins even if not aliased', + array( 'Foo' => array( 'FooPage' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Foo', + 'Foo', + 'FooPage', + 1, + ), + + array( + 'Doesn\'t redirect to a different special page\'s canonical name even if not aliased', + array( 'Foo' => array( 'FooPage' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'First local name beats non-first', + array( 'First' => array( 'Foo' ), 'NonFirst' => array( 'Bar', 'Foo' ) ), + 'Foo', + 'First', + 'Foo', + 0, + ), + + array( + 'Doesn\'t redirect to a different special page\'s first alias', + array( + 'Foo' => array( 'Foo' ), + 'First' => array( 'Bar' ), + 'Baz' => array( 'Foo', 'Bar', 'BazPage', 'Baz2' ) + ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'Doesn\'t redirect wrong even if all aliases conflict', + array( + 'Foo' => array( 'Foo' ), + 'First' => array( 'Bar' ), + 'Baz' => array( 'Foo', 'Bar' ) + ), + 'Baz', + 'Baz', + 'Baz', + 2, + ), + + ); + } + +} diff --git a/tests/phpunit/includes/specials/ImageListPagerTest.php b/tests/phpunit/includes/specials/ImageListPagerTest.php new file mode 100644 index 00000000..22bdefdf --- /dev/null +++ b/tests/phpunit/includes/specials/ImageListPagerTest.php @@ -0,0 +1,22 @@ +formatValue( 'invalid_field', 'invalid_value' ); + } +} diff --git a/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php new file mode 100644 index 00000000..f92dc66f --- /dev/null +++ b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php @@ -0,0 +1,74 @@ + + */ +class QueryAllSpecialPagesTest extends MediaWikiTestCase { + + /** List query pages that can not be tested automatically */ + protected $manualTest = array( + 'LinkSearchPage' + ); + + /** + * Pages whose query use the same DB table more than once. + * This is used to skip testing those pages when run against a MySQL backend + * which does not support reopening a temporary table. See upstream bug: + * http://bugs.mysql.com/bug.php?id=10327 + */ + protected $reopensTempTable = array( + 'BrokenRedirects', + ); + + /** + * Initialize all query page objects + */ + function __construct() { + parent::__construct(); + + foreach ( QueryPage::getPages() as $page ) { + $class = $page[0]; + if ( !in_array( $class, $this->manualTest ) ) { + $this->queryPages[$class] = new $class; + } + } + } + + /** + * Test SQL for each of our QueryPages objects + * @group Database + */ + public function testQuerypageSqlQuery() { + global $wgDBtype; + + foreach ( $this->queryPages as $page ) { + // With MySQL, skips special pages reopening a temporary table + // See http://bugs.mysql.com/bug.php?id=10327 + if ( + $wgDBtype === 'mysql' + && in_array( $page->getName(), $this->reopensTempTable ) + ) { + $this->markTestSkipped( "SQL query for page {$page->getName()} " + . "can not be tested on MySQL backend (it reopens a temporary table)" ); + continue; + } + + $msg = "SQL query for page {$page->getName()} should give a result wrapper object"; + + $result = $page->reallyDoQuery( 50 ); + if ( $result instanceof ResultWrapper ) { + $this->assertTrue( true, $msg ); + } else { + $this->assertFalse( false, $msg ); + } + } + } +} diff --git a/tests/phpunit/includes/specials/SpecialMIMESearchTest.php b/tests/phpunit/includes/specials/SpecialMIMESearchTest.php new file mode 100644 index 00000000..14d19685 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMIMESearchTest.php @@ -0,0 +1,48 @@ +page = new MIMESearchPage; + $context = new RequestContext(); + $context->setTitle( Title::makeTitle( NS_SPECIAL, 'MIMESearch' ) ); + $context->setRequest( new FauxRequest() ); + $this->page->setContext( $context ); + + parent::setUp(); + } + + /** + * @dataProvider providerMimeFiltering + * @param string $par Subpage for special page + * @param string $major Major MIME type we expect to look for + * @param string $minor Minor MIME type we expect to look for + */ + function testMimeFiltering( $par, $major, $minor ) { + $this->page->run( $par ); + $qi = $this->page->getQueryInfo(); + $this->assertEquals( $qi['conds']['img_major_mime'], $major ); + if ( $minor !== null ) { + $this->assertEquals( $qi['conds']['img_minor_mime'], $minor ); + } else { + $this->assertArrayNotHasKey( 'img_minor_mime', $qi['conds'] ); + } + $this->assertContains( 'image', $qi['tables'] ); + } + + function providerMimeFiltering() { + return array( + array( 'image/gif', 'image', 'gif' ), + array( 'image/png', 'image', 'png' ), + array( 'application/pdf', 'application', 'pdf' ), + array( 'image/*', 'image', null ), + array( 'multipart/*', 'multipart', null ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialMyLanguageTest.php b/tests/phpunit/includes/specials/SpecialMyLanguageTest.php new file mode 100644 index 00000000..4dbfc412 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMyLanguageTest.php @@ -0,0 +1,65 @@ +getId() == 0 ) { + $page->doEditContent( + new WikitextContent( 'UTContent' ), + 'UTPageSummary', + EDIT_NEW, + false, + User::newFromName( 'UTSysop' ) ); + } + } + } + + /** + * @covers SpecialMyLanguage::findTitle + * @dataProvider provideFindTitle + * @param string $expected + * @param string $subpage + * @param string $langCode + * @param string $userLang + */ + public function testFindTitle( $expected, $subpage, $langCode, $userLang ) { + $this->setMwGlobals( 'wgLanguageCode', $langCode ); + $special = new SpecialMyLanguage(); + $special->getContext()->setLanguage( $userLang ); + // Test with subpages both enabled and disabled + $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', array( NS_MAIN => true ) ); + $this->assertTitle( $expected, $special->findTitle( $subpage ) ); + $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', array( NS_MAIN => false ) ); + $this->assertTitle( $expected, $special->findTitle( $subpage ) ); + } + + /** + * @param string $expected + * @param Title|null $title + */ + private function assertTitle( $expected, $title ) { + if ( $title ) { + $title = $title->getPrefixedText(); + } + $this->assertEquals( $expected, $title ); + } + + public static function provideFindTitle() { + return array( + array( null, '::Fail', 'en', 'en' ), + array( 'Page/Another', 'Page/Another/en', 'en', 'en' ), + array( 'Page/Another', 'Page/Another', 'en', 'en' ), + array( 'Page/Another/ru', 'Page/Another', 'en', 'ru' ), + array( 'Page/Another', 'Page/Another', 'en', 'es' ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialPreferencesTest.php b/tests/phpunit/includes/specials/SpecialPreferencesTest.php new file mode 100644 index 00000000..4f6c4116 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialPreferencesTest.php @@ -0,0 +1,57 @@ +setMwGlobals( 'wgMaxSigChars', 2 ); + + $user = $this->getMock( 'User' ); + $user->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + + # Yeah foreach requires an array, not NULL =( + $user->expects( $this->any() ) + ->method( 'getEffectiveGroups' ) + ->will( $this->returnValue( array() ) ); + + # The mocked user has a long nickname + $user->expects( $this->any() ) + ->method( 'getOption' ) + ->will( $this->returnValueMap( array( + array( 'nickname', null, false, 'superlongnickname' ), + ) + ) ); + + # Forge a request to call the special page + $context = new RequestContext(); + $context->setRequest( new FauxRequest() ); + $context->setUser( $user ); + $context->setTitle( Title::newFromText( 'Test' ) ); + + # Do the call, should not spurt a fatal error. + $special = new SpecialPreferences(); + $special->setContext( $context ); + $this->assertNull( $special->execute( array() ) ); + } + +} diff --git a/tests/phpunit/includes/specials/SpecialRecentchangesTest.php b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php new file mode 100644 index 00000000..c3d75aa5 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php @@ -0,0 +1,123 @@ +setRequest( new FauxRequest( $requestOptions ) ); + + # setup the rc object + $this->rc = new SpecialRecentChanges(); + $this->rc->setContext( $context ); + $formOptions = $this->rc->setup( null ); + + # Filter out rc_timestamp conditions which depends on the test runtime + # This condition is not needed as of march 2, 2011 -- hashar + # @todo FIXME: Find a way to generate the correct rc_timestamp + $queryConditions = array_filter( + $this->rc->buildMainQueryConds( $formOptions ), + 'SpecialRecentchangesTest::filterOutRcTimestampCondition' + ); + + $this->assertEquals( + $expected, + $queryConditions, + $message + ); + } + + /** return false if condition begin with 'rc_timestamp ' */ + private static function filterOutRcTimestampCondition( $var ) { + return ( false === strpos( $var, 'rc_timestamp ' ) ); + } + + public function testRcNsFilter() { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => "rc_namespace = '0'", + ), + array( + 'namespace' => NS_MAIN, + ), + "rc conditions with no options (aka default setting)" + ); + } + + public function testRcNsFilterInversion() { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "rc_namespace != '%s'", NS_MAIN ), + ), + array( + 'namespace' => NS_MAIN, + 'invert' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * @bug 2429 + * @dataProvider provideNamespacesAssociations + */ + public function testRcNsFilterAssociation( $ns1, $ns2 ) { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "(rc_namespace = '%s' OR rc_namespace = '%s')", $ns1, $ns2 ), + ), + array( + 'namespace' => $ns1, + 'associated' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * @bug 2429 + * @dataProvider provideNamespacesAssociations + */ + public function testRcNsFilterAssociationWithInversion( $ns1, $ns2 ) { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "(rc_namespace != '%s' AND rc_namespace != '%s')", $ns1, $ns2 ), + ), + array( + 'namespace' => $ns1, + 'associated' => 1, + 'invert' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * Provides associated namespaces to test recent changes + * namespaces association filtering. + */ + public static function provideNamespacesAssociations() { + return array( # (NS => Associated_NS) + array( NS_MAIN, NS_TALK ), + array( NS_TALK, NS_MAIN ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php new file mode 100644 index 00000000..83489c65 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -0,0 +1,144 @@ + true, 'ns6' => true). Null to use default options. + * @param array $userOptions User options to test with. For example: + * array('searchNs5' => 1 );. Null to use default options. + * @param string $expectedProfile An expected search profile name + * @param array $expectedNS Expected namespaces + * @param string $message + */ + public function testProfileAndNamespaceLoading( $requested, $userOptions, + $expectedProfile, $expectedNS, $message = 'Profile name and namespaces mismatches!' + ) { + $context = new RequestContext; + $context->setUser( + $this->newUserWithSearchNS( $userOptions ) + ); + /* + $context->setRequest( new FauxRequest( array( + 'ns5'=>true, + 'ns6'=>true, + ) )); + */ + $context->setRequest( new FauxRequest( $requested ) ); + $search = new SpecialSearch(); + $search->setContext( $context ); + $search->load(); + + /** + * Verify profile name and namespace in the same assertion to make + * sure we will be able to fully compare the above code. PHPUnit stop + * after an assertion fail. + */ + $this->assertEquals( + array( /** Expected: */ + 'ProfileName' => $expectedProfile, + 'Namespaces' => $expectedNS, + ), + array( /** Actual: */ + 'ProfileName' => $search->getProfile(), + 'Namespaces' => $search->getNamespaces(), + ), + $message + ); + } + + public static function provideSearchOptionsTests() { + $defaultNS = SearchEngine::defaultNamespaces(); + $EMPTY_REQUEST = array(); + $NO_USER_PREF = null; + + return array( + /** + * Parameters: + * , + * Followed by expected values: + * , + * Then an optional message. + */ + array( + $EMPTY_REQUEST, $NO_USER_PREF, + 'default', $defaultNS, + 'Bug 33270: No request nor user preferences should give default profile' + ), + array( + array( 'ns5' => 1 ), $NO_USER_PREF, + 'advanced', array( 5 ), + 'Web request with specific NS should override user preference' + ), + array( + $EMPTY_REQUEST, array( + 'searchNs2' => 1, + 'searchNs14' => 1, + ) + array_fill_keys( array_map( function ( $ns ) { + return "searchNs$ns"; + }, $defaultNS ), 0 ), + 'advanced', array( 2, 14 ), + 'Bug 33583: search with no option should honor User search preferences' + . ' and have all other namespace disabled' + ), + ); + } + + /** + * Helper to create a new User object with given options + * User remains anonymous though + * @param array|null $opt + */ + function newUserWithSearchNS( $opt = null ) { + $u = User::newFromId( 0 ); + if ( $opt === null ) { + return $u; + } + foreach ( $opt as $name => $value ) { + $u->setOption( $name, $value ); + } + + return $u; + } + + /** + * Verify we do not expand search term in on search result page + * https://gerrit.wikimedia.org/r/4841 + */ + public function testSearchTermIsNotExpanded() { + $this->setMwGlobals( array( + 'wgSearchType' => null, + ) ); + + # Initialize [[Special::Search]] + $search = new SpecialSearch(); + $search->getContext()->setTitle( Title::newFromText( 'Special:Search' ) ); + $search->load(); + + # Simulate a user searching for a given term + $term = '{{SITENAME}}'; + $search->showResults( $term ); + + # Lookup the HTML page title set for that page + $pageTitle = $search + ->getContext() + ->getOutput() + ->getHTMLTitle(); + + # Compare :-] + $this->assertRegExp( + '/' . preg_quote( $term ) . '/', + $pageTitle, + "Search term '{$term}' should not be expanded in Special:Search <title>" + ); + } +} diff --git a/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php b/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php new file mode 100644 index 00000000..4171c10e --- /dev/null +++ b/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php @@ -0,0 +1,169 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers MediaWikiPageLinkRenderer + * + * @group Title + * @group Database + */ +class MediaWikiPageLinkRendererTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + ) ); + } + + /** + * Returns a mock GenderCache that will return "female" always. + * + * @return GenderCache + */ + private function getGenderCache() { + $genderCache = $this->getMockBuilder( 'GenderCache' ) + ->disableOriginalConstructor() + ->getMock(); + + $genderCache->expects( $this->any() ) + ->method( 'getGenderOf' ) + ->will( $this->returnValue( 'female' ) ); + + return $genderCache; + } + + public static function provideGetPageUrl() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + array(), + '/Foo_Bar' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + array( 'foo' => 'bar' ), + '/User:Hansi_Maier?foo=bar#stuff' + ), + ); + } + + /** + * @dataProvider provideGetPageUrl + */ + public function testGetPageUrl( TitleValue $title, $params, $url ) { + // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the + // WikitextTitleFormatter we pass here, and relies on the Linker + // class for generating the link! This may break the test e.g. + // of Linker uses a different language for the namespace names. + + $lang = Language::factory( 'en' ); + + $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); + $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); + $actual = $renderer->getPageUrl( $title, $params ); + + $this->assertEquals( $url, $actual ); + } + + public static function provideRenderHtmlLink() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + 'Foo Bar', + '!<a .*href=".*?Foo_Bar.*?".*?>Foo Bar</a>!' + ), + array( + //NOTE: Linker doesn't include fragments in "broken" links + //NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + 'Hansi Maier\'s Stuff', + '!<a .*href=".*?User:Hansi_Maier.*?>Hansi Maier\'s Stuff</a>!' + ), + array( + //NOTE: Linker doesn't include fragments in "broken" links + //NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + null, + '!<a .*href=".*?User:Hansi_Maier.*?>User:Hansi Maier#stuff</a>!' + ), + ); + } + + /** + * @dataProvider provideRenderHtmlLink + */ + public function testRenderHtmlLink( TitleValue $title, $text, $pattern ) { + // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the + // WikitextTitleFormatter we pass here, and relies on the Linker + // class for generating the link! This may break the test e.g. + // of Linker uses a different language for the namespace names. + + $lang = Language::factory( 'en' ); + + $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); + $renderer = new MediaWikiPageLinkRenderer( $formatter ); + $actual = $renderer->renderHtmlLink( $title, $text ); + + $this->assertRegExp( $pattern, $actual ); + } + + public static function provideRenderWikitextLink() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + 'Foo Bar', + '[[:0:Foo Bar|Foo Bar]]' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + 'Hansi Maier\'s Stuff', + '[[:2:Hansi Maier#stuff|Hansi Maier's Stuff]]' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + null, + '[[:2:Hansi Maier#stuff|2:Hansi Maier#stuff]]' + ), + ); + } + + /** + * @dataProvider provideRenderWikitextLink + */ + public function testRenderWikitextLink( TitleValue $title, $text, $expected ) { + $formatter = $this->getMock( 'TitleFormatter' ); + $formatter->expects( $this->any() ) + ->method( 'getFullText' ) + ->will( $this->returnCallback( + function ( TitleValue $title ) { + return str_replace( '_', ' ', "$title" ); + } + )); + + $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); + $actual = $renderer->renderWikitextLink( $title, $text ); + + $this->assertEquals( $expected, $actual ); + } +} diff --git a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php new file mode 100644 index 00000000..f95b3050 --- /dev/null +++ b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php @@ -0,0 +1,384 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers MediaWikiTitleCodec + * + * @group Title + * @group Database + * ^--- needed because of global state in + */ +class MediaWikiTitleCodecTest extends MediaWikiTestCase { + + public function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ), + // User language + 'wgLang' => Language::factory( 'en' ), + 'wgAllowUserJs' => false, + 'wgDefaultLanguageVariant' => false, + 'wgLocalInterwikis' => array( 'localtestiw' ), + 'wgCapitalLinks' => true, + + // NOTE: this is why global state is evil. + // TODO: refactor access to the interwiki codes so it can be injected. + 'wgHooks' => array( + 'InterwikiLoadPrefix' => array( + function ( $prefix, &$data ) { + if ( $prefix === 'localtestiw' ) { + $data = array( 'iw_url' => 'localtestiw' ); + } elseif ( $prefix === 'remotetestiw' ) { + $data = array( 'iw_url' => 'remotetestiw' ); + } + return false; + } + ) + ) + ) ); + } + + /** + * Returns a mock GenderCache that will consider a user "female" if the + * first part of the user name ends with "a". + * + * @return GenderCache + */ + private function getGenderCache() { + $genderCache = $this->getMockBuilder( 'GenderCache' ) + ->disableOriginalConstructor() + ->getMock(); + + $genderCache->expects( $this->any() ) + ->method( 'getGenderOf' ) + ->will( $this->returnCallback( function ( $userName ) { + return preg_match( '/^[^- _]+a( |_|$)/u', $userName ) ? 'female' : 'male'; + } ) ); + + return $genderCache; + } + + protected function makeCodec( $lang ) { + $gender = $this->getGenderCache(); + $lang = Language::factory( $lang ); + return new MediaWikiTitleCodec( $lang, $gender ); + } + + public static function provideFormat() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier#stuff and so on' ), + array( false, 'Hansi_Maier', '', 'en', 'Hansi Maier' ), + array( + NS_USER_TALK, + 'hansi__maier', + '', + 'en', + 'User talk:hansi maier', + 'User talk:Hansi maier' + ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ), + ); + } + + /** + * @dataProvider provideFormat + */ + public function testFormat( $namespace, $text, $fragment, $lang, $expected, $normalized = null ) { + if ( $normalized === null ) { + $normalized = $expected; + } + + $codec = $this->makeCodec( $lang ); + $actual = $codec->formatTitle( $namespace, $text, $fragment ); + + $this->assertEquals( $expected, $actual, 'formatted' ); + + // test round trip + $parsed = $codec->parseTitle( $actual, NS_MAIN ); + $actual2 = $codec->formatTitle( + $parsed->getNamespace(), + $parsed->getText(), + $parsed->getFragment() + ); + + $this->assertEquals( $normalized, $actual2, 'normalized after round trip' ); + } + + public static function provideGetText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'Hansi Maier' ), + ); + } + + /** + * @dataProvider provideGetText + */ + public function testGetText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetPrefixedText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier' ), + + // No capitalization or normalization is applied while formatting! + array( NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ), + ); + } + + /** + * @dataProvider provideGetPrefixedText + */ + public function testGetPrefixedText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getPrefixedText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetFullText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier#stuff and so on' ), + + // No capitalization or normalization is applied while formatting! + array( NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ), + ); + } + + /** + * @dataProvider provideGetFullText + */ + public function testGetFullText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getFullText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideParseTitle() { + //TODO: test capitalization and trimming + //TODO: test unicode normalization + + return array( + array( ' : Hansi_Maier _ ', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'Hansi_Maier', '' ) ), + array( 'User:::1', NS_MAIN, 'de', + new TitleValue( NS_USER, '0:0:0:0:0:0:0:1', '' ) ), + array( ' lisa Müller', NS_USER, 'de', + new TitleValue( NS_USER, 'Lisa_Müller', '' ) ), + array( 'benutzerin:lisa Müller#stuff', NS_MAIN, 'de', + new TitleValue( NS_USER, 'Lisa_Müller', 'stuff' ) ), + + array( ':Category:Quux', NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Category:Quux', NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Category:Quux', NS_CATEGORY, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Quux', NS_CATEGORY, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( ':Quux', NS_CATEGORY, 'en', + new TitleValue( NS_MAIN, 'Quux', '' ) ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + + array( 'a b c', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'A_b_c' ) ), + array( ' a b c ', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'A_b_c' ) ), + array( ' _ Foo __ Bar_ _', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'Foo_Bar' ) ), + + //NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync. + array( 'Sandbox', NS_MAIN, 'en', ), + array( 'A "B"', NS_MAIN, 'en', ), + array( 'A \'B\'', NS_MAIN, 'en', ), + array( '.com', NS_MAIN, 'en', ), + array( '~', NS_MAIN, 'en', ), + array( '"', NS_MAIN, 'en', ), + array( '\'', NS_MAIN, 'en', ), + + array( 'Talk:Sandbox', NS_MAIN, 'en', + new TitleValue( NS_TALK, 'Sandbox' ) ), + array( 'Talk:Foo:Sandbox', NS_MAIN, 'en', + new TitleValue( NS_TALK, 'Foo:Sandbox' ) ), + array( 'File:Example.svg', NS_MAIN, 'en', + new TitleValue( NS_FILE, 'Example.svg' ) ), + array( 'File_talk:Example.svg', NS_MAIN, 'en', + new TitleValue( NS_FILE_TALK, 'Example.svg' ) ), + array( 'Foo/.../Sandbox', NS_MAIN, 'en', + 'Foo/.../Sandbox' ), + array( 'Sandbox/...', NS_MAIN, 'en', + 'Sandbox/...' ), + array( 'A~~', NS_MAIN, 'en', + 'A~~' ), + // Length is 256 total, but only title part matters + array( 'Category:' . str_repeat( 'x', 248 ), NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, + 'X' . str_repeat( 'x', 247 ) ) ), + array( str_repeat( 'x', 252 ), NS_MAIN, 'en', + 'X' . str_repeat( 'x', 251 ) ) + ); + } + + /** + * @dataProvider provideParseTitle + */ + public function testParseTitle( $text, $ns, $lang, $title = null ) { + if ( $title === null ) { + $title = str_replace( ' ', '_', trim( $text ) ); + } + + if ( is_string( $title ) ) { + $title = new TitleValue( NS_MAIN, $title, '' ); + } + + $codec = $this->makeCodec( $lang ); + $actual = $codec->parseTitle( $text, $ns ); + + $this->assertEquals( $title, $actual ); + } + + public static function provideParseTitle_invalid() { + //TODO: test unicode errors + + return array( + array( '#' ), + array( '::' ), + array( '::xx' ), + array( '::##' ), + array( ' :: x' ), + + array( 'Talk:File:Foo.jpg' ), + array( 'Talk:localtestiw:Foo' ), + array( 'remotetestiw:Foo' ), + array( '::1' ), // only valid in user namespace + array( 'User::x' ), // leading ":" in a user name is only valid of IPv6 addresses + + //NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync. + array( '' ), + array( ':' ), + array( '__ __' ), + array( ' __ ' ), + // Bad characters forbidden regardless of wgLegalTitleChars + array( 'A [ B' ), + array( 'A ] B' ), + array( 'A { B' ), + array( 'A } B' ), + array( 'A < B' ), + array( 'A > B' ), + array( 'A | B' ), + // URL encoding + array( 'A%20B' ), + array( 'A%23B' ), + array( 'A%2523B' ), + // XML/HTML character entity references + // Note: Commented out because they are not marked invalid by the PHP test as + // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first. + //array( 'A é B' ), + //array( 'A é B' ), + //array( 'A é B' ), + // Subject of NS_TALK does not roundtrip to NS_MAIN + array( 'Talk:File:Example.svg' ), + // Directory navigation + array( '.' ), + array( '..' ), + array( './Sandbox' ), + array( '../Sandbox' ), + array( 'Foo/./Sandbox' ), + array( 'Foo/../Sandbox' ), + array( 'Sandbox/.' ), + array( 'Sandbox/..' ), + // Tilde + array( 'A ~~~ Name' ), + array( 'A ~~~~ Signature' ), + array( 'A ~~~~~ Timestamp' ), + array( str_repeat( 'x', 256 ) ), + // Namespace prefix without actual title + array( 'Talk:' ), + array( 'Category: ' ), + array( 'Category: #bar' ) + ); + } + + /** + * @dataProvider provideParseTitle_invalid + */ + public function testParseTitle_invalid( $text ) { + $this->setExpectedException( 'MalformedTitleException' ); + + $codec = $this->makeCodec( 'en' ); + $codec->parseTitle( $text, NS_MAIN ); + } + + public static function provideGetNamespaceName() { + return array( + array( NS_MAIN, 'Foo', 'en', '' ), + array( NS_USER, 'Foo', 'en', 'User' ), + array( NS_USER, 'Hansi Maier', 'de', 'Benutzer' ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa Müller', 'de', 'Benutzerin' ), + ); + } + + /** + * @dataProvider provideGetNamespaceName + * + * @param int $namespace + * @param string $text + * @param string $lang + * @param string $expected + * + * @internal param \TitleValue $title + */ + public function testGetNamespaceName( $namespace, $text, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $name = $codec->getNamespaceName( $namespace, $text ); + + $this->assertEquals( $expected, $name ); + } +} diff --git a/tests/phpunit/includes/title/TitleValueTest.php b/tests/phpunit/includes/title/TitleValueTest.php new file mode 100644 index 00000000..3ba008d6 --- /dev/null +++ b/tests/phpunit/includes/title/TitleValueTest.php @@ -0,0 +1,100 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers TitleValue + * + * @group Title + */ +class TitleValueTest extends MediaWikiTestCase { + + public function testConstruction() { + $title = new TitleValue( NS_USER, 'TestThis', 'stuff' ); + + $this->assertEquals( NS_USER, $title->getNamespace() ); + $this->assertEquals( 'TestThis', $title->getText() ); + $this->assertEquals( 'stuff', $title->getFragment() ); + } + + public function badConstructorProvider() { + return array( + array( 'foo', 'title', 'fragment' ), + array( null, 'title', 'fragment' ), + array( 2.3, 'title', 'fragment' ), + + array( NS_MAIN, 5, 'fragment' ), + array( NS_MAIN, null, 'fragment' ), + array( NS_MAIN, '', 'fragment' ), + array( NS_MAIN, 'foo bar', '' ), + array( NS_MAIN, 'bar_', '' ), + array( NS_MAIN, '_foo', '' ), + array( NS_MAIN, ' eek ', '' ), + + array( NS_MAIN, 'title', 5 ), + array( NS_MAIN, 'title', null ), + array( NS_MAIN, 'title', array() ), + ); + } + + /** + * @dataProvider badConstructorProvider + */ + public function testConstructionErrors( $ns, $text, $fragment ) { + $this->setExpectedException( 'InvalidArgumentException' ); + new TitleValue( $ns, $text, $fragment ); + } + + public function fragmentTitleProvider() { + return array( + array( new TitleValue( NS_MAIN, 'Test' ), 'foo' ), + array( new TitleValue( NS_TALK, 'Test', 'foo' ), '' ), + array( new TitleValue( NS_CATEGORY, 'Test', 'foo' ), 'bar' ), + ); + } + + /** + * @dataProvider fragmentTitleProvider + */ + public function testCreateFragmentTitle( TitleValue $title, $fragment ) { + $fragmentTitle = $title->createFragmentTitle( $fragment ); + + $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() ); + $this->assertEquals( $title->getText(), $fragmentTitle->getText() ); + $this->assertEquals( $fragment, $fragmentTitle->getFragment() ); + } + + public function getTextProvider() { + return array( + array( 'Foo', 'Foo' ), + array( 'Foo_Bar', 'Foo Bar' ), + ); + } + + /** + * @dataProvider getTextProvider + */ + public function testGetText( $dbkey, $text ) { + $title = new TitleValue( NS_MAIN, $dbkey ); + + $this->assertEquals( $text, $title->getText() ); + } +} diff --git a/tests/phpunit/includes/upload/UploadBaseTest.php b/tests/phpunit/includes/upload/UploadBaseTest.php new file mode 100644 index 00000000..3d3b0068 --- /dev/null +++ b/tests/phpunit/includes/upload/UploadBaseTest.php @@ -0,0 +1,427 @@ +<?php + +/** + * @group Upload + */ +class UploadBaseTest extends MediaWikiTestCase { + + /** @var UploadTestHandler */ + protected $upload; + + protected function setUp() { + global $wgHooks; + parent::setUp(); + + $this->upload = new UploadTestHandler; + $this->hooks = $wgHooks; + $wgHooks['InterwikiLoadPrefix'][] = function ( $prefix, &$data ) { + return false; + }; + } + + protected function tearDown() { + global $wgHooks; + $wgHooks = $this->hooks; + + parent::tearDown(); + } + + /** + * First checks the return code + * of UploadBase::getTitle() and then the actual returned title + * + * @dataProvider provideTestTitleValidation + * @covers UploadBase::getTitle + */ + public function testTitleValidation( $srcFilename, $dstFilename, $code, $msg ) { + /* Check the result code */ + $this->assertEquals( $code, + $this->upload->testTitleValidation( $srcFilename ), + "$msg code" ); + + /* If we expect a valid title, check the title itself. */ + if ( $code == UploadBase::OK ) { + $this->assertEquals( $dstFilename, + $this->upload->getTitle()->getText(), + "$msg text" ); + } + } + + /** + * Test various forms of valid and invalid titles that can be supplied. + */ + public static function provideTestTitleValidation() { + return array( + /* Test a valid title */ + array( 'ValidTitle.jpg', 'ValidTitle.jpg', UploadBase::OK, + 'upload valid title' ), + /* A title with a slash */ + array( 'A/B.jpg', 'B.jpg', UploadBase::OK, + 'upload title with slash' ), + /* A title with illegal char */ + array( 'A:B.jpg', 'A-B.jpg', UploadBase::OK, + 'upload title with colon' ), + /* Stripping leading File: prefix */ + array( 'File:C.jpg', 'C.jpg', UploadBase::OK, + 'upload title with File prefix' ), + /* Test illegal suggested title (r94601) */ + array( '%281%29.JPG', null, UploadBase::ILLEGAL_FILENAME, + 'illegal title for upload' ), + /* A title without extension */ + array( 'A', null, UploadBase::FILETYPE_MISSING, + 'upload title without extension' ), + /* A title with no basename */ + array( '.jpg', null, UploadBase::MIN_LENGTH_PARTNAME, + 'upload title without basename' ), + /* A title that is longer than 255 bytes */ + array( str_repeat( 'a', 255 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG, + 'upload title longer than 255 bytes' ), + /* A title that is longer than 240 bytes */ + array( str_repeat( 'a', 240 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG, + 'upload title longer than 240 bytes' ), + ); + } + + /** + * Test the upload verification functions + * @covers UploadBase::verifyUpload + */ + public function testVerifyUpload() { + /* Setup with zero file size */ + $this->upload->initializePathInfo( '', '', 0 ); + $result = $this->upload->verifyUpload(); + $this->assertEquals( UploadBase::EMPTY_FILE, + $result['status'], + 'upload empty file' ); + } + + // Helper used to create an empty file of size $size. + private function createFileOfSize( $size ) { + $filename = tempnam( wfTempDir(), "mwuploadtest" ); + + $fh = fopen( $filename, 'w' ); + ftruncate( $fh, $size ); + fclose( $fh ); + + return $filename; + } + + /** + * test uploading a 100 bytes file with $wgMaxUploadSize = 100 + * + * This method should be abstracted so we can test different settings. + */ + public function testMaxUploadSize() { + global $wgMaxUploadSize; + $savedGlobal = $wgMaxUploadSize; // save global + global $wgFileExtensions; + $wgFileExtensions[] = 'txt'; + + $wgMaxUploadSize = 100; + + $filename = $this->createFileOfSize( $wgMaxUploadSize ); + $this->upload->initializePathInfo( basename( $filename ) . '.txt', $filename, 100 ); + $result = $this->upload->verifyUpload(); + unlink( $filename ); + + $this->assertEquals( + array( 'status' => UploadBase::OK ), $result ); + + $wgMaxUploadSize = $savedGlobal; // restore global + } + + + /** + * @dataProvider provideCheckSvgScriptCallback + */ + public function testCheckSvgScriptCallback( $svg, $wellFormed, $filterMatch, $message ) { + list( $formed, $match ) = $this->upload->checkSvgString( $svg ); + $this->assertSame( $wellFormed, $formed, $message ); + $this->assertSame( $filterMatch, $match, $message ); + } + + public static function provideCheckSvgScriptCallback() { + return array( + // html5sec SVG vectors + array( + '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>', + true, + true, + 'Script tag in svg (http://html5sec.org/#47)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"><g onload="javascript:alert(1)"></g></svg>', + true, + true, + 'SVG with onload property (http://html5sec.org/#11)' + ), + array( + '<svg onload="javascript:alert(1)" xmlns="http://www.w3.org/2000/svg"></svg>', + true, + true, + 'SVG with onload property (http://html5sec.org/#65)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="javascript:alert(1)"><rect width="1000" height="1000" fill="white"/></a> </svg>', + true, + true, + 'SVG with javascript xlink (http://html5sec.org/#87)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjUwIiBjeD0iMTAwIiBjeT0iMTAwIiBzdHlsZT0iZmlsbDogI0YwMCI+CjxzZXQgYXR0cmlidXRlTmFtZT0iZmlsbCIgYXR0cmlidXRlVHlwZT0iQ1NTIiBvbmJlZ2luPSdhbGVydChkb2N1bWVudC5jb29raWUpJwpvbmVuZD0nYWxlcnQoIm9uZW5kIiknIHRvPSIjMDBGIiBiZWdpbj0iMXMiIGR1cj0iNXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/> </svg>', + true, + true, + 'SVG with Opera image xlink (http://html5sec.org/#88 - c)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="javascript:alert(1)"/> </svg>', + true, + true, + 'SVG with Opera animation xlink (http://html5sec.org/#88 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="data:text/xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>', + true, + true, + 'SVG with Opera animation xlink (http://html5sec.org/#88 - b)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>', + true, + true, + 'SVG with Opera image xlink (http://html5sec.org/#88 - c)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="javascript:alert(1)"/> </svg>', + true, + true, + 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - d)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="data:text/xml,%3Cscript xmlns=\'http://www.w3.org/1999/xhtml\'%3Ealert(1)%3C/script%3E"/> </svg>', + true, + true, + 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - e)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <set attributeName="onmouseover" to="alert(1)"/> </svg>', + true, + true, + 'SVG with event handler set (http://html5sec.org/#89 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <animate attributeName="onunload" to="alert(1)"/> </svg>', + true, + true, + 'SVG with event handler animate (http://html5sec.org/#89 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>', + true, + true, + 'SVG with element handler (http://html5sec.org/#94)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <feImage> <set attributeName="xlink:href" to="data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ%2BYWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg%3D%3D"/> </feImage> </svg>', + true, + true, + 'SVG with href to data: url (http://html5sec.org/#95)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" id="foo"> <x xmlns="http://www.w3.org/2001/xml-events" event="load" observer="foo" handler="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Chandler%20xml%3Aid%3D%22bar%22%20type%3D%22application%2Fecmascript%22%3E alert(1) %3C%2Fhandler%3E%0A%3C%2Fsvg%3E%0A#bar"/> </svg>', + true, + true, + 'SVG with Tiny handler (http://html5sec.org/#104)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect fill="white" style="clip-path:url(test3.svg#a);fill:url(#b);filter:url(#c);marker:url(#d);mask:url(#e);stroke:url(#f);"/> </svg>', + true, + true, + 'SVG with new CSS styles properties (http://html5sec.org/#109)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect clip-path="url(test3.svg#a)" /> </svg>', + true, + true, + 'SVG with new CSS styles properties as attributes' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"> <rect fill="white" width="1000" height="1000"/> </a> <rect fill="url(http://html5sec.org/test3.svg#a)" /> </svg>', + true, + true, + 'SVG with new CSS styles properties as attributes (2)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <path d="M0,0" style="marker-start:url(test4.svg#a)"/> </svg>', + true, + true, + 'SVG with path marker-start (http://html5sec.org/#110)' + ), + array( + '<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <!DOCTYPE doc [ <!ATTLIST xsl:stylesheet id ID #REQUIRED>]> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>', + true, + true, + 'SVG with embedded stylesheet (http://html5sec.org/#125)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" id="x"> <listener event="load" handler="#y" xmlns="http://www.w3.org/2001/xml-events" observer="x"/> <handler id="y">alert(1)</handler> </svg>', + true, + true, + 'SVG with handler attribute (http://html5sec.org/#127)' + ), + array( + // Haven't found a browser that accepts this particular example, but we + // don't want to allow embeded svgs, ever + '<svg> <image style=\'filter:url("data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ/YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg==")\' /> </svg>', + true, + true, + 'SVG with image filter via style (http://html5sec.org/#129)' + ), + array( + // This doesn't seem possible without embedding the svg, but just in case + '<svg> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="?"> <circle r="400"></circle> <animate attributeName="xlink:href" begin="0" from="javascript:alert(1)" to="" /> </a></svg>', + true, + true, + 'SVG with animate from (http://html5sec.org/#137)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a><text y="1em">Click me</text> <animate attributeName="xlink:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a></svg>', + true, + true, + 'SVG with animate xlink:href (http://html5sec.org/#137)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:y="http://www.w3.org/1999/xlink"> <a y:href="#"> <text y="1em">Click me</text> <animate attributeName="y:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a> </svg>', + true, + true, + 'SVG with animate y:href (http://html5sec.org/#137)' + ), + + // Other hostile SVG's + array( + '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="https://upload.wikimedia.org/wikipedia/commons/3/34/Bahnstrecke_Zeitz-Camburg_1930.png" /> </svg>', + true, + true, + 'SVG with non-local image href (bug 65839)' + ), + array( + '<?xml version="1.0" ?> <?xml-stylesheet type="text/xsl" href="/w/index.php?title=User:Jeeves/test.xsl&action=raw&format=xml" ?> <svg> <height>50</height> <width>100</width> </svg>', + true, + true, + 'SVG with remote stylesheet (bug 57550)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewbox="-1 -1 15 15"> <rect y="0" height="13" width="12" stroke="#179" rx="1" fill="#2ac"/> <text x="1.5" y="11" font-family="courier" stroke="white" font-size="16"><![CDATA[B]]></text> <iframe xmlns="http://www.w3.org/1999/xhtml" srcdoc="<script>alert('XSSED => Domain('+top.document.domain+')');</script>"></iframe> </svg>', + true, + true, + 'SVG with rembeded iframe (bug 60771)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org");</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with @import in style element (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org");<foo/></style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with @import in style element and child element (bug 69008#c11)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@imporT "https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org";</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with case-insensitive @import in style element (bug T85349)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:url(https://www.google.com/images/srpr/logo11w.png)"/> </svg>', + true, + true, + 'SVG with remote background image (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:\55rl(https://www.google.com/images/srpr/logo11w.png)"/> </svg>', + true, + true, + 'SVG with remote background image, encoded (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <style> #a { background-image:\55rl(\'https://www.google.com/images/srpr/logo11w.png\'); } </style> <rect width="100" height="100" id="a"/> </svg>', + true, + true, + 'SVG with remote background image, in style element (bug 69008)' + ), + array( + // This currently doesn't seem to work in any browsers, but in case + // http://www.w3.org/TR/css3-images/ is implemented for SVG files + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:image(\'sprites.svg#xywh=40,0,20,20\')"/> </svg>', + true, + true, + 'SVG with remote background image using image() (bug 69008)' + ), + array( + // As reported by Cure53 + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a xlink:href="data:text/html;charset=utf-8;base64, PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ%2BDQo%3D"> <circle r="400" fill="red"></circle> </a> </svg>', + true, + true, + 'SVG with data:text/html link target (firefox only)' + ), + array( + '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY lol "lol"> <!ENTITY lol2 "<script>alert('XSSED => '+document.domain);</script>"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="68" height="68" viewBox="-34 -34 68 68" version="1.1"> <circle cx="0" cy="0" r="24" fill="#c8c8c8"/> <text x="0" y="0" fill="black">&lol2;</text> </svg>', + true, + true, + 'SVG with encoded script tag in internal entity (reported by Beyond Security)' + ), + array( + '<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>', + false, + false, + 'SVG with external entity' + ), + + // Test good, but strange files that we want to allow + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g> <a xlink:href="http://en.wikipedia.org/wiki/Main_Page"> <path transform="translate(0,496)" id="path6706" d="m 112.09375,107.6875 -5.0625,3.625 -4.3125,5.03125 -0.46875,0.5 -4.09375,3.34375 -9.125,5.28125 -8.625,-3.375 z" style="fill:#cccccc;fill-opacity:1;stroke:#6e6e6e;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;display:inline" /> </a> </g> </svg>', + true, + false, + 'SVG with <a> link to a remote site' + ), + array( + '<svg> <defs> <filter id="filter6226" x="-0.93243687" width="2.8648737" y="-0.24250539" height="1.4850108"> <feGaussianBlur stdDeviation="3.2344681" id="feGaussianBlur6228" /> </filter> <clipPath id="clipPath2436"> <path d="M 0,0 L 0,0 L 0,0 L 0,0 z" id="path2438" /> </clipPath> </defs> <g clip-path="url(#clipPath2436)" id="g2460"> <text id="text2466"> <tspan>12345</tspan> </text> </g> <path style="fill:#346733;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:1, 1;stroke-dashoffset:0;filter:url(\'#filter6226\');fill-opacity:1;opacity:0.79807692" d="M 236.82371,332.63732 C 236.92217,332.63732 z" id="path5618" /> </svg>', + true, + false, + 'SVG with local urls, including filter: in style' + ), + ); + } +} + +class UploadTestHandler extends UploadBase { + public function initializeFromRequest( &$request ) { + } + + public function testTitleValidation( $name ) { + $this->mTitle = false; + $this->mDesiredDestName = $name; + $this->mTitleError = UploadBase::OK; + $this->getTitle(); + + return $this->mTitleError; + } + + /** + * Almost the same as UploadBase::detectScriptInSvg, except it's + * public, works on an xml string instead of filename, and returns + * the result instead of interpreting them. + */ + public function checkSvgString( $svg ) { + $check = new XmlTypeCheck( + $svg, + array( $this, 'checkSvgScriptCallback' ), + false, + array( 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ) + ); + return array( $check->wellFormed, $check->filterMatch ); + } +} diff --git a/tests/phpunit/includes/upload/UploadFromUrlTest.php b/tests/phpunit/includes/upload/UploadFromUrlTest.php new file mode 100644 index 00000000..ec56b63e --- /dev/null +++ b/tests/phpunit/includes/upload/UploadFromUrlTest.php @@ -0,0 +1,328 @@ +<?php + +/** + * @group Broken + * @group Upload + * @group Database + * + * @covers UploadFromUrl + */ +class UploadFromUrlTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableUploads' => true, + 'wgAllowCopyUploads' => true, + 'wgAllowAsyncCopyUploads' => true, + ) ); + wfSetupSession(); + + if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) { + $this->deleteFile( 'UploadFromUrlTest.png' ); + } + } + + protected function doApiRequest( array $params, array $unused = null, + $appendModule = false, User $user = null + ) { + $sessionId = session_id(); + session_write_close(); + + $req = new FauxRequest( $params, true, $_SESSION ); + $module = new ApiMain( $req, true ); + $module->execute(); + + wfSetupSession( $sessionId ); + + return array( $module->getResultData(), $req ); + } + + /** + * Ensure that the job queue is empty before continuing + */ + public function testClearQueue() { + $job = JobQueueGroup::singleton()->pop(); + while ( $job ) { + $job = JobQueueGroup::singleton()->pop(); + } + $this->assertFalse( $job ); + } + + /** + * @depends testClearQueue + */ + public function testSetupUrlDownload( $data ) { + $token = $this->user->getEditToken(); + $exception = false; + + try { + $this->doApiRequest( array( + 'action' => 'upload', + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "The token parameter must be set", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "One of the parameters sessionkey, file, url, statuskey is required", + $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://www.example.com/test.png', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "The filename parameter must be set", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $this->user->removeGroup( 'sysop' ); + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://www.example.com/test.png', + 'filename' => 'UploadFromUrlTest.png', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "Permission denied", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $this->user->addGroup( 'sysop' ); + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'filename' => 'UploadFromUrlTest.png', + 'token' => $token, + ), $data ); + + $this->assertEquals( $data[0]['upload']['result'], 'Queued', 'Queued upload' ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertThat( $job, $this->isInstanceOf( 'UploadFromUrlJob' ), 'Queued upload inserted' ); + } + + /** + * @depends testClearQueue + */ + public function testAsyncUpload( $data ) { + $token = $this->user->getEditToken(); + + $this->user->addGroup( 'users' ); + + $data = $this->doAsyncUpload( $token, true ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + /** + * @depends testClearQueue + */ + public function testAsyncUploadWarning( $data ) { + $token = $this->user->getEditToken(); + + $this->user->addGroup( 'users' ); + + $data = $this->doAsyncUpload( $token ); + + $this->assertEquals( $data[0]['upload']['result'], 'Warning' ); + $this->assertTrue( isset( $data[0]['upload']['sessionkey'] ) ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'sessionkey' => $data[0]['upload']['sessionkey'], + 'filename' => 'UploadFromUrlTest.png', + 'ignorewarnings' => 1, + 'token' => $token, + ) ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + /** + * @depends testClearQueue + */ + public function testSyncDownload( $data ) { + $token = $this->user->getEditToken(); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job, 'Starting with an empty jobqueue' ); + + $this->user->addGroup( 'users' ); + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'ignorewarnings' => true, + 'token' => $token, + ), $data ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job ); + + $this->assertEquals( 'Success', $data[0]['upload']['result'] ); + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + public function testLeaveMessage() { + $token = $this->user->user->getEditToken(); + + $talk = $this->user->user->getTalkPage(); + if ( $talk->exists() ) { + $page = WikiPage::factory( $talk ); + $page->doDeleteArticle( '' ); + } + + $this->assertFalse( + (bool)$talk->getArticleID( Title::GAID_FOR_UPDATE ), + 'User talk does not exist' + ); + + $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + 'ignorewarnings' => 1, + ) ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + $job->run(); + + $this->assertTrue( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + $this->assertTrue( (bool)$talk->getArticleID( Title::GAID_FOR_UPDATE ), 'User talk exists' ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( + 'Using leavemessage without ignorewarnings is not supported', + $e->getMessage() + ); + } + $this->assertTrue( $exception ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job ); + + return; + /* + // Broken until using leavemessage with ignorewarnings is supported + $talkRev = Revision::newFromTitle( $talk ); + $talkSize = $talkRev->getSize(); + + $job->run(); + + $this->assertFalse( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + + $talkRev = Revision::newFromTitle( $talk ); + $this->assertTrue( $talkRev->getSize() > $talkSize, 'New message left' ); + */ + } + + /** + * Helper function to perform an async upload, execute the job and fetch + * the status + * + * @param string $token + * @param bool $ignoreWarnings + * @param bool $leaveMessage + * @return array The result of action=upload&statuskey=key + */ + private function doAsyncUpload( $token, $ignoreWarnings = false, $leaveMessage = false ) { + $params = array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + ); + if ( $ignoreWarnings ) { + $params['ignorewarnings'] = 1; + } + if ( $leaveMessage ) { + $params['leavemessage'] = 1; + } + + $data = $this->doApiRequest( $params ); + $this->assertEquals( $data[0]['upload']['result'], 'Queued' ); + $this->assertTrue( isset( $data[0]['upload']['statuskey'] ) ); + $statusKey = $data[0]['upload']['statuskey']; + + $job = JobQueueGroup::singleton()->pop(); + $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + + $status = $job->run(); + $this->assertTrue( $status ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'statuskey' => $statusKey, + 'token' => $token, + ) ); + + return $data; + } + + protected function deleteFile( $name ) { + $t = Title::newFromText( $name, NS_FILE ); + $this->assertTrue( $t->exists(), "File '$name' exists" ); + + if ( $t->exists() ) { + $file = wfFindFile( $name, array( 'ignoreRedirect' => true ) ); + $empty = ""; + FileDeleteForm::doDelete( $t, $file, $empty, "none", true ); + $page = WikiPage::factory( $t ); + $page->doDeleteArticle( "testing" ); + } + $t = Title::newFromText( $name, NS_FILE ); + + $this->assertFalse( $t->exists(), "File '$name' was deleted" ); + } +} diff --git a/tests/phpunit/includes/upload/UploadStashTest.php b/tests/phpunit/includes/upload/UploadStashTest.php new file mode 100644 index 00000000..d5d1188e --- /dev/null +++ b/tests/phpunit/includes/upload/UploadStashTest.php @@ -0,0 +1,107 @@ +<?php + +/** + * @group Database + * + * @covers UploadStash + */ +class UploadStashTest extends MediaWikiTestCase { + /** + * @var array Array of UploadStashTestUser + */ + public static $users; + + /** + * @var string + */ + private $bug29408File; + + protected function setUp() { + parent::setUp(); + + // Setup a file for bug 29408 + $this->bug29408File = __DIR__ . '/bug29408'; + file_put_contents( $this->bug29408File, "\x00" ); + + self::$users = array( + 'sysop' => new TestUser( + 'Uploadstashtestsysop', + 'Upload Stash Test Sysop', + 'upload_stash_test_sysop@example.com', + array( 'sysop' ) + ), + 'uploader' => new TestUser( + 'Uploadstashtestuser', + 'Upload Stash Test User', + 'upload_stash_test_user@example.com', + array() + ) + ); + } + + protected function tearDown() { + if ( file_exists( $this->bug29408File . "." ) ) { + unlink( $this->bug29408File . "." ); + } + + if ( file_exists( $this->bug29408File ) ) { + unlink( $this->bug29408File ); + } + + parent::tearDown(); + } + + /** + * @todo give this test a real name explaining what is being tested here + */ + public function testBug29408() { + $this->setMwGlobals( 'wgUser', self::$users['uploader']->user ); + + $repo = RepoGroup::singleton()->getLocalRepo(); + $stash = new UploadStash( $repo ); + + // Throws exception caught by PHPUnit on failure + $file = $stash->stashFile( $this->bug29408File ); + // We'll never reach this point if we hit bug 29408 + $this->assertTrue( true, 'Unrecognized file without extension' ); + + $stash->removeFile( $file->getFileKey() ); + } + + public static function provideInvalidRequests() { + return array( + 'Check failure on bad wpFileKey' => + array( new FauxRequest( array( 'wpFileKey' => 'foo' ) ) ), + 'Check failure on bad wpSessionKey' => + array( new FauxRequest( array( 'wpSessionKey' => 'foo' ) ) ), + ); + } + + /** + * @dataProvider provideInvalidRequests + */ + public function testValidRequestWithInvalidRequests( $request ) { + $this->assertFalse( UploadFromStash::isValidRequest( $request ) ); + } + + public static function provideValidRequests() { + return array( + 'Check good wpFileKey' => + array( new FauxRequest( array( 'wpFileKey' => 'testkey-test.test' ) ) ), + 'Check good wpSessionKey' => + array( new FauxRequest( array( 'wpFileKey' => 'testkey-test.test' ) ) ), + 'Check key precedence' => + array( new FauxRequest( array( + 'wpFileKey' => 'testkey-test.test', + 'wpSessionKey' => 'foo' + ) ) ), + ); + } + /** + * @dataProvider provideValidRequests + */ + public function testValidRequestWithValidRequests( $request ) { + $this->assertTrue( UploadFromStash::isValidRequest( $request ) ); + } + +} diff --git a/tests/phpunit/includes/utils/CdbTest.php b/tests/phpunit/includes/utils/CdbTest.php new file mode 100644 index 00000000..487ee1fc --- /dev/null +++ b/tests/phpunit/includes/utils/CdbTest.php @@ -0,0 +1,90 @@ +<?php + +/** + * Test the CDB reader/writer + * @covers CdbWriterPHP + * @covers CdbWriterDBA + */ +class CdbTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + if ( !CdbReader::haveExtension() ) { + $this->markTestSkipped( 'Native CDB support is not available' ); + } + } + + /** + * @group medium + */ + public function testCdb() { + $dir = wfTempDir(); + if ( !is_writable( $dir ) ) { + $this->markTestSkipped( "Temp dir isn't writable" ); + } + + $phpcdbfile = $this->getNewTempFile(); + $dbacdbfile = $this->getNewTempFile(); + + $w1 = new CdbWriterPHP( $phpcdbfile ); + $w2 = new CdbWriterDBA( $dbacdbfile ); + + $data = array(); + for ( $i = 0; $i < 1000; $i++ ) { + $key = $this->randomString(); + $value = $this->randomString(); + $w1->set( $key, $value ); + $w2->set( $key, $value ); + + if ( !isset( $data[$key] ) ) { + $data[$key] = $value; + } + } + + $w1->close(); + $w2->close(); + + $this->assertEquals( + md5_file( $phpcdbfile ), + md5_file( $dbacdbfile ), + 'same hash' + ); + + $r1 = new CdbReaderPHP( $phpcdbfile ); + $r2 = new CdbReaderDBA( $dbacdbfile ); + + foreach ( $data as $key => $value ) { + if ( $key === '' ) { + // Known bug + continue; + } + $v1 = $r1->get( $key ); + $v2 = $r2->get( $key ); + + $v1 = $v1 === false ? '(not found)' : $v1; + $v2 = $v2 === false ? '(not found)' : $v2; + + # cdbAssert( 'Mismatch', $key, $v1, $v2 ); + $this->cdbAssert( "PHP error", $key, $v1, $value ); + $this->cdbAssert( "DBA error", $key, $v2, $value ); + } + } + + private function randomString() { + $len = mt_rand( 0, 10 ); + $s = ''; + for ( $j = 0; $j < $len; $j++ ) { + $s .= chr( mt_rand( 0, 255 ) ); + } + + return $s; + } + + private function cdbAssert( $msg, $key, $v1, $v2 ) { + $this->assertEquals( + $v2, + $v1, + $msg . ', k=' . bin2hex( $key ) + ); + } +} diff --git a/tests/phpunit/includes/utils/IPTest.php b/tests/phpunit/includes/utils/IPTest.php new file mode 100644 index 00000000..ebe347fd --- /dev/null +++ b/tests/phpunit/includes/utils/IPTest.php @@ -0,0 +1,580 @@ +<?php +/** + * Tests for IP validity functions. + * + * Ported from /t/inc/IP.t by avar. + * + * @group IP + * @todo Test methods in this call should be split into a method and a + * dataprovider. + */ + +class IPTest extends MediaWikiTestCase { + /** + * not sure it should be tested with boolean false. hashar 20100924 + * @covers IP::isIPAddress + */ + public function testisIPAddress() { + $this->assertFalse( IP::isIPAddress( false ), 'Boolean false is not an IP' ); + $this->assertFalse( IP::isIPAddress( true ), 'Boolean true is not an IP' ); + $this->assertFalse( IP::isIPAddress( "" ), 'Empty string is not an IP' ); + $this->assertFalse( IP::isIPAddress( 'abc' ), 'Garbage IP string' ); + $this->assertFalse( IP::isIPAddress( ':' ), 'Single ":" is not an IP' ); + $this->assertFalse( IP::isIPAddress( '2001:0DB8::A:1::1' ), 'IPv6 with a double :: occurrence' ); + $this->assertFalse( + IP::isIPAddress( '2001:0DB8::A:1::' ), + 'IPv6 with a double :: occurrence, last at end' + ); + $this->assertFalse( + IP::isIPAddress( '::2001:0DB8::5:1' ), + 'IPv6 with a double :: occurrence, firt at beginning' + ); + $this->assertFalse( IP::isIPAddress( '124.24.52' ), 'IPv4 not enough quads' ); + $this->assertFalse( IP::isIPAddress( '24.324.52.13' ), 'IPv4 out of range' ); + $this->assertFalse( IP::isIPAddress( '.24.52.13' ), 'IPv4 starts with period' ); + $this->assertFalse( IP::isIPAddress( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' ); + $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' ); + $this->assertTrue( IP::isIPAddress( '74.24.52.13/20', 'IPv4 range' ) ); + $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' ); + $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' ); + + $validIPs = array( 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac', + '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ); + foreach ( $validIPs as $ip ) { + $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" ); + } + } + + /** + * @covers IP::isIPv6 + */ + public function testisIPv6() { + $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' ); + $this->assertFalse( + IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + + $this->assertFalse( IP::isIPv6( ':::' ) ); + $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' ); + + $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' ); + $this->assertTrue( IP::isIPv6( '::0' ) ); + $this->assertTrue( IP::isIPv6( '::fc' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) ); + + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' ); + + $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d', 'IPv6 with "::" and 4 words' ) ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) ); + } + + /** + * @covers IP::isIPv4 + */ + public function testisIPv4() { + $this->assertFalse( IP::isIPv4( false ), 'Boolean false is not an IP' ); + $this->assertFalse( IP::isIPv4( true ), 'Boolean true is not an IP' ); + $this->assertFalse( IP::isIPv4( "" ), 'Empty string is not an IP' ); + $this->assertFalse( IP::isIPv4( 'abc' ) ); + $this->assertFalse( IP::isIPv4( ':' ) ); + $this->assertFalse( IP::isIPv4( '124.24.52' ), 'IPv4 not enough quads' ); + $this->assertFalse( IP::isIPv4( '24.324.52.13' ), 'IPv4 out of range' ); + $this->assertFalse( IP::isIPv4( '.24.52.13' ), 'IPv4 starts with period' ); + + $this->assertTrue( IP::isIPv4( '124.24.52.13' ) ); + $this->assertTrue( IP::isIPv4( '1.24.52.13' ) ); + $this->assertTrue( IP::isIPv4( '74.24.52.13/20', 'IPv4 range' ) ); + } + + /** + * @covers IP::isValid + */ + public function testValidIPs() { + foreach ( range( 0, 255 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" ); + } + } + foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) { + $a = sprintf( "%04x", $i ); + $b = sprintf( "%03x", $i ); + $c = sprintf( "%02x", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" ); + } + } + // test with some abbreviations + $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isValid( 'fc:100::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0::' ), + 'IPv6 with 8 words ending with "::"' + ); + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + } + + /** + * @covers IP::isValid + */ + public function testInvalidIPs() { + // Out of range... + foreach ( range( 256, 999 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" ); + } + } + foreach ( range( 'g', 'z' ) as $i ) { + $a = sprintf( "%04s", $i ); + $b = sprintf( "%03s", $i ); + $c = sprintf( "%02s", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" ); + } + } + // Have CIDR + $ipCIDRs = array( + '212.35.31.121/32', + '212.35.31.121/18', + '212.35.31.121/24', + '::ff:d:321:5/96', + 'ff::d3:321:5/116', + 'c:ff:12:1:ea:d:321:5/120', + ); + foreach ( $ipCIDRs as $i ) { + $this->assertFalse( IP::isValid( $i ), + "$i is an invalid IP address because it is a block" ); + } + // Incomplete/garbage + $invalid = array( + 'www.xn--var-xla.net', + '216.17.184.G', + '216.17.184.1.', + '216.17.184', + '216.17.184.', + '256.17.184.1' + ); + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" ); + } + } + + /** + * @covers IP::isValidBlock + */ + public function testValidBlocks() { + $valid = array( + '116.17.184.5/32', + '0.17.184.5/30', + '16.17.184.1/24', + '30.242.52.14/1', + '10.232.52.13/8', + '30.242.52.14/0', + '::e:f:2001/96', + '::c:f:2001/128', + '::10:f:2001/70', + '::fe:f:2001/1', + '::6d:f:2001/8', + '::fe:f:2001/0', + ); + foreach ( $valid as $i ) { + $this->assertTrue( IP::isValidBlock( $i ), "$i is a valid IP block" ); + } + } + + /** + * @covers IP::isValidBlock + */ + public function testInvalidBlocks() { + $invalid = array( + '116.17.184.5/33', + '0.17.184.5/130', + '16.17.184.1/-1', + '10.232.52.13/*', + '7.232.52.13/ab', + '11.232.52.13/', + '::e:f:2001/129', + '::c:f:2001/228', + '::10:f:2001/-1', + '::6d:f:2001/*', + '::86:f:2001/ab', + '::23:f:2001/', + ); + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValidBlock( $i ), "$i is not a valid IP block" ); + } + } + + /** + * Improve IP::sanitizeIP() code coverage + * @todo Most probably incomplete + */ + public function testSanitizeIP() { + $this->assertNull( IP::sanitizeIP( '' ) ); + $this->assertNull( IP::sanitizeIP( ' ' ) ); + } + + /** + * @covers IP::toHex + * @dataProvider provideToHex + */ + public function testToHex( $expected, $input ) { + $result = IP::toHex( $input ); + $this->assertTrue( $result === false || is_string( $result ) ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testToHex() + */ + public static function provideToHex() { + return array( + array( '00000001', '0.0.0.1' ), + array( '01020304', '1.2.3.4' ), + array( '7F000001', '127.0.0.1' ), + array( '80000000', '128.0.0.0' ), + array( 'DEADCAFE', '222.173.202.254' ), + array( 'FFFFFFFF', '255.255.255.255' ), + array( false, 'IN.VA.LI.D' ), + array( 'v6-00000000000000000000000000000001', '::1' ), + array( 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ), + array( 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ), + array( false, 'IN:VA::LI:D' ), + array( false, ':::1' ) + ); + } + + /** + * @covers IP::isPublic + */ + public function testPrivateIPs() { + $private = array( 'fc00::3', 'fc00::ff', '::1', '10.0.0.1', '172.16.0.1', '192.168.0.1' ); + foreach ( $private as $p ) { + $this->assertFalse( IP::isPublic( $p ), "$p is not a public IP address" ); + } + $public = array( '2001:5c0:1000:a::133', 'fc::3', '00FC::' ); + foreach ( $public as $p ) { + $this->assertTrue( IP::isPublic( $p ), "$p is a public IP address" ); + } + } + + // Private wrapper used to test CIDR Parsing. + private function assertFalseCIDR( $CIDR, $msg = '' ) { + $ff = array( false, false ); + $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg ); + } + + // Private wrapper to test network shifting using only dot notation + private function assertNet( $expected, $CIDR ) { + $parse = IP::parseCIDR( $CIDR ); + $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" ); + } + + /** + * @covers IP::hexToQuad + */ + public function testHexToQuad() { + $this->assertEquals( '0.0.0.1', IP::hexToQuad( '00000001' ) ); + $this->assertEquals( '255.0.0.0', IP::hexToQuad( 'FF000000' ) ); + $this->assertEquals( '255.255.255.255', IP::hexToQuad( 'FFFFFFFF' ) ); + $this->assertEquals( '10.188.222.255', IP::hexToQuad( '0ABCDEFF' ) ); + // hex not left-padded... + $this->assertEquals( '0.0.0.0', IP::hexToQuad( '0' ) ); + $this->assertEquals( '0.0.0.1', IP::hexToQuad( '1' ) ); + $this->assertEquals( '0.0.0.255', IP::hexToQuad( 'FF' ) ); + $this->assertEquals( '0.0.255.0', IP::hexToQuad( 'FF00' ) ); + } + + /** + * @covers IP::hexToOctet + */ + public function testHexToOctet() { + $this->assertEquals( '0:0:0:0:0:0:0:1', + IP::hexToOctet( '00000000000000000000000000000001' ) ); + $this->assertEquals( '0:0:0:0:0:0:FF:3', + IP::hexToOctet( '00000000000000000000000000FF0003' ) ); + $this->assertEquals( '0:0:0:0:0:0:FF00:6', + IP::hexToOctet( '000000000000000000000000FF000006' ) ); + $this->assertEquals( '0:0:0:0:0:0:FCCF:FAFF', + IP::hexToOctet( '000000000000000000000000FCCFFAFF' ) ); + $this->assertEquals( 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + IP::hexToOctet( 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ) ); + // hex not left-padded... + $this->assertEquals( '0:0:0:0:0:0:0:0', IP::hexToOctet( '0' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:1', IP::hexToOctet( '1' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:FF', IP::hexToOctet( 'FF' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:FFD0', IP::hexToOctet( 'FFD0' ) ); + $this->assertEquals( '0:0:0:0:0:0:FA00:0', IP::hexToOctet( 'FA000000' ) ); + $this->assertEquals( '0:0:0:0:0:0:FCCF:FAFF', IP::hexToOctet( 'FCCFFAFF' ) ); + } + + /** + * IP::parseCIDR() returns an array containing a signed IP address + * representing the network mask and the bit mask. + * @covers IP::parseCIDR + */ + public function testCIDRParsing() { + $this->assertFalseCIDR( '192.0.2.0', "missing mask" ); + $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" ); + + // Verify if statement + $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" ); + $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" ); + $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" ); + $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); + + // Check internal logic + # 0 mask always result in array(0,0) + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '192.0.0.2/0' ) ); + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '0.0.0.0/0' ) ); + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '255.255.255.255/0' ) ); + + // @todo FIXME: Add more tests. + + # This part test network shifting + $this->assertNet( '192.0.0.0', '192.0.0.2/24' ); + $this->assertNet( '192.168.5.0', '192.168.5.13/24' ); + $this->assertNet( '10.0.0.160', '10.0.0.161/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/30' ); + $this->assertNet( '10.0.0.4', '10.0.0.4/30' ); + $this->assertNet( '172.17.32.0', '172.17.35.48/21' ); + $this->assertNet( '10.128.0.0', '10.135.0.0/9' ); + $this->assertNet( '134.0.0.0', '134.0.5.1/8' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeOnValidIp() { + $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ), + 'Canonicalization of a valid IP returns it unchanged' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeMappedAddress() { + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::ffff:192.0.2.152' ) + ); + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::192.0.2.152' ) + ); + } + + /** + * Issues there are most probably from IP::toHex() or IP::parseRange() + * @covers IP::isInRange + * @dataProvider provideIPsAndRanges + */ + public function testIPIsInRange( $expected, $addr, $range, $message = '' ) { + $this->assertEquals( + $expected, + IP::isInRange( $addr, $range ), + $message + ); + } + + /** Provider for testIPIsInRange() */ + public static function provideIPsAndRanges() { + # Format: (expected boolean, address, range, optional message) + return array( + # IPv4 + array( true, '192.0.2.0', '192.0.2.0/24', 'Network address' ), + array( true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ), + array( true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ), + + array( false, '0.0.0.0', '192.0.2.0/24' ), + array( false, '255.255.255', '192.0.2.0/24' ), + + # IPv6 + array( false, '::1', '2001:DB8::/32' ), + array( false, '::', '2001:DB8::/32' ), + array( false, 'FE80::1', '2001:DB8::/32' ), + + array( true, '2001:DB8::', '2001:DB8::/32' ), + array( true, '2001:0DB8::', '2001:DB8::/32' ), + array( true, '2001:DB8::1', '2001:DB8::/32' ), + array( true, '2001:0DB8::1', '2001:DB8::/32' ), + array( true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + '2001:DB8::/32' ), + + array( false, '2001:0DB8:F::', '2001:DB8::/96' ), + ); + } + + /** + * Test for IP::splitHostAndPort(). + * @dataProvider provideSplitHostAndPort + */ + public function testSplitHostAndPort( $expected, $input, $description ) { + $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description ); + } + + /** + * Provider for IP::splitHostAndPort() + */ + public static function provideSplitHostAndPort() { + return array( + array( false, '[', 'Unclosed square bracket' ), + array( false, '[::', 'Unclosed square bracket 2' ), + array( array( '::', false ), '::', 'Bare IPv6 0' ), + array( array( '::1', false ), '::1', 'Bare IPv6 1' ), + array( array( '::', false ), '[::]', 'Bracketed IPv6 0' ), + array( array( '::1', false ), '[::1]', 'Bracketed IPv6 1' ), + array( array( '::1', 80 ), '[::1]:80', 'Bracketed IPv6 with port' ), + array( false, '::x', 'Double colon but no IPv6' ), + array( array( 'x', 80 ), 'x:80', 'Hostname and port' ), + array( false, 'x:x', 'Hostname and invalid port' ), + array( array( 'x', false ), 'x', 'Plain hostname' ) + ); + } + + /** + * Test for IP::combineHostAndPort() + * @dataProvider provideCombineHostAndPort + */ + public function testCombineHostAndPort( $expected, $input, $description ) { + list( $host, $port, $defaultPort ) = $input; + $this->assertEquals( + $expected, + IP::combineHostAndPort( $host, $port, $defaultPort ), + $description ); + } + + /** + * Provider for IP::combineHostAndPort() + */ + public static function provideCombineHostAndPort() { + return array( + array( '[::1]', array( '::1', 2, 2 ), 'IPv6 default port' ), + array( '[::1]:2', array( '::1', 2, 3 ), 'IPv6 non-default port' ), + array( 'x', array( 'x', 2, 2 ), 'Normal default port' ), + array( 'x:2', array( 'x', 2, 3 ), 'Normal non-default port' ), + ); + } + + /** + * Test for IP::sanitizeRange() + * @dataProvider provideIPCIDRs + */ + public function testSanitizeRange( $input, $expected, $description ) { + $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description ); + } + + /** + * Provider for IP::testSanitizeRange() + */ + public static function provideIPCIDRs() { + return array( + array( '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ), + array( '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ), + array( '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ), + array( '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ), + array( '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ), + array( '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ), + array( '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ), + array( '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ), + ); + } + + /** + * Test for IP::prettifyIP() + * @dataProvider provideIPsToPrettify + */ + public function testPrettifyIP( $ip, $prettified ) { + $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" ); + } + + /** + * Provider for IP::testPrettifyIP() + */ + public static function provideIPsToPrettify() { + return array( + array( '0:0:0:0:0:0:0:0', '::' ), + array( '0:0:0::0:0:0', '::' ), + array( '0:0:0:1:0:0:0:0', '0:0:0:1::' ), + array( '0:0::f', '::f' ), + array( '0::0:0:0:33:fef:b', '::33:fef:b' ), + array( '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ), + array( '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ), + array( 'abbc:2004::0:0:0:0', 'abbc:2004::' ), + array( 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ), + array( '0:0:0:0:0:0:0:0/16', '::/16' ), + array( '0:0:0::0:0:0/64', '::/64' ), + array( '0:0::f/52', '::f/52' ), + array( '::0:0:33:fef:b/52', '::33:fef:b/52' ), + array( '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ), + array( '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ), + array( 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ), + array( 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ), + ); + } +} diff --git a/tests/phpunit/includes/utils/MWCryptHKDFTest.php b/tests/phpunit/includes/utils/MWCryptHKDFTest.php new file mode 100644 index 00000000..7e37534a --- /dev/null +++ b/tests/phpunit/includes/utils/MWCryptHKDFTest.php @@ -0,0 +1,89 @@ +<?php +/** + * + * @group HKDF + */ + +class MWCryptHKDFTest extends MediaWikiTestCase { + + /** + * Test basic usage works + */ + public function testGenerate() { + $a = MWCryptHKDF::generateHex( 64 ); + $b = MWCryptHKDF::generateHex( 64 ); + + $this->assertTrue( strlen( $a ) == 64, "MWCryptHKDF produced fewer bytes than expected" ); + $this->assertTrue( strlen( $b ) == 64, "MWCryptHKDF produced fewer bytes than expected" ); + $this->assertFalse( $a == $b, "Two runs of MWCryptHKDF produced the same result." ); + } + + /** + * @dataProvider providerRfc5869 + */ + public function testRfc5869( $hash, $ikm, $salt, $info, $L, $prk, $okm ) { + $ikm = pack( 'H*', $ikm ); + $salt = pack( 'H*', $salt ); + $info = pack( 'H*', $info ); + $okm = pack( 'H*', $okm ); + $result = MWCryptHKDF::HKDF( $hash, $ikm, $salt, $info, $L ); + $this->assertEquals( $okm, $result ); + } + + /** + * Test vectors from Appendix A on http://tools.ietf.org/html/rfc5869 + */ + public static function providerRfc5869() { + + return array( + // A.1 + array( 'sha256', + '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm + '000102030405060708090a0b0c', // salt + 'f0f1f2f3f4f5f6f7f8f9', // context + 42, // bytes + '077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5', // prk + '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865' // okm + ), + // A.2 + array( 'sha256', + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f', + '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf', + 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', + 82, + '06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244', + 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87' + ), + // A.3 + array( 'sha256', + '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm + '', // salt + '', // context + 42, // bytes + '19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04', // prk + '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8' // okm + ), + // A.4 + array( 'sha1', + '0b0b0b0b0b0b0b0b0b0b0b', // ikm + '000102030405060708090a0b0c', // salt + 'f0f1f2f3f4f5f6f7f8f9', // context + 42, // bytes + '9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243', // prk + '085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896' // okm + ), + // A.5 + array( 'sha1', + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f', // ikm + '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf', // salt + 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', // context + 82, // bytes + '8adae09a2a307059478d309b26c4115a224cfaf6', // prk + '0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4' // okm + ), + ); + + } + + +} diff --git a/tests/phpunit/includes/utils/StringUtilsTest.php b/tests/phpunit/includes/utils/StringUtilsTest.php new file mode 100644 index 00000000..0fdb8e15 --- /dev/null +++ b/tests/phpunit/includes/utils/StringUtilsTest.php @@ -0,0 +1,149 @@ +<?php + +class StringUtilsTest extends MediaWikiTestCase { + + /** + * This tests StringUtils::isUtf8 whenever we have the mbstring extension + * loaded. + * + * @covers StringUtils::isUtf8 + * @dataProvider provideStringsForIsUtf8Check + */ + public function testIsUtf8WithMbstring( $expected, $string ) { + if ( !function_exists( 'mb_check_encoding' ) ) { + $this->markTestSkipped( 'Test requires the mbstring PHP extension' ); + } + $this->assertEquals( $expected, + StringUtils::isUtf8( $string ), + 'Testing string "' . $this->escaped( $string ) . '" with mb_check_encoding' + ); + } + + /** + * This tests StringUtils::isUtf8 making sure we use the pure PHP + * implementation used as a fallback when mb_check_encoding() is + * not available. + * + * @covers StringUtils::isUtf8 + * @dataProvider provideStringsForIsUtf8Check + */ + public function testIsUtf8WithPhpFallbackImplementation( $expected, $string ) { + $this->assertEquals( $expected, + StringUtils::isUtf8( $string, /** disable mbstring: */true ), + 'Testing string "' . $this->escaped( $string ) . '" with pure PHP implementation' + ); + } + + /** + * Print high range characters as a hexadecimal + * @param string $string + * @return string + */ + function escaped( $string ) { + $escaped = ''; + $length = strlen( $string ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $string[$i]; + $val = ord( $char ); + if ( $val > 127 ) { + $escaped .= '\x' . dechex( $val ); + } else { + $escaped .= $char; + } + } + + return $escaped; + } + + /** + * See also "UTF-8 decoder capability and stress test" by + * Markus Kuhn: + * http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + */ + public static function provideStringsForIsUtf8Check() { + // Expected return values for StringUtils::isUtf8() + $PASS = true; + $FAIL = false; + + return array( + 'some ASCII' => array( $PASS, 'Some ASCII' ), + 'euro sign' => array( $PASS, "Euro sign €" ), + + 'first possible sequence 1 byte' => array( $PASS, "\x00" ), + 'first possible sequence 2 bytes' => array( $PASS, "\xc2\x80" ), + 'first possible sequence 3 bytes' => array( $PASS, "\xe0\xa0\x80" ), + 'first possible sequence 4 bytes' => array( $PASS, "\xf0\x90\x80\x80" ), + 'first possible sequence 5 bytes' => array( $FAIL, "\xf8\x88\x80\x80\x80" ), + 'first possible sequence 6 bytes' => array( $FAIL, "\xfc\x84\x80\x80\x80\x80" ), + + 'last possible sequence 1 byte' => array( $PASS, "\x7f" ), + 'last possible sequence 2 bytes' => array( $PASS, "\xdf\xbf" ), + 'last possible sequence 3 bytes' => array( $PASS, "\xef\xbf\xbf" ), + 'last possible sequence 4 bytes (U+1FFFFF)' => array( $FAIL, "\xf7\xbf\xbf\xbf" ), + 'last possible sequence 5 bytes' => array( $FAIL, "\xfb\xbf\xbf\xbf\xbf" ), + 'last possible sequence 6 bytes' => array( $FAIL, "\xfd\xbf\xbf\xbf\xbf\xbf" ), + + 'boundary 1' => array( $PASS, "\xed\x9f\xbf" ), + 'boundary 2' => array( $PASS, "\xee\x80\x80" ), + 'boundary 3' => array( $PASS, "\xef\xbf\xbd" ), + 'boundary 4' => array( $PASS, "\xf2\x80\x80\x80" ), + 'boundary 5 (U+FFFFF)' => array( $PASS, "\xf3\xbf\xbf\xbf" ), + 'boundary 6 (U+100000)' => array( $PASS, "\xf4\x80\x80\x80" ), + 'boundary 7 (U+10FFFF)' => array( $PASS, "\xf4\x8f\xbf\xbf" ), + 'boundary 8 (U+110000)' => array( $FAIL, "\xf4\x90\x80\x80" ), + + 'malformed 1' => array( $FAIL, "\x80" ), + 'malformed 2' => array( $FAIL, "\xbf" ), + 'malformed 3' => array( $FAIL, "\x80\xbf" ), + 'malformed 4' => array( $FAIL, "\x80\xbf\x80" ), + 'malformed 5' => array( $FAIL, "\x80\xbf\x80\xbf" ), + 'malformed 6' => array( $FAIL, "\x80\xbf\x80\xbf\x80" ), + 'malformed 7' => array( $FAIL, "\x80\xbf\x80\xbf\x80\xbf" ), + 'malformed 8' => array( $FAIL, "\x80\xbf\x80\xbf\x80\xbf\x80" ), + + 'last byte missing 1' => array( $FAIL, "\xc0" ), + 'last byte missing 2' => array( $FAIL, "\xe0\x80" ), + 'last byte missing 3' => array( $FAIL, "\xf0\x80\x80" ), + 'last byte missing 4' => array( $FAIL, "\xf8\x80\x80\x80" ), + 'last byte missing 5' => array( $FAIL, "\xfc\x80\x80\x80\x80" ), + 'last byte missing 6' => array( $FAIL, "\xdf" ), + 'last byte missing 7' => array( $FAIL, "\xef\xbf" ), + 'last byte missing 8' => array( $FAIL, "\xf7\xbf\xbf" ), + 'last byte missing 9' => array( $FAIL, "\xfb\xbf\xbf\xbf" ), + 'last byte missing 10' => array( $FAIL, "\xfd\xbf\xbf\xbf\xbf" ), + + 'extra continuation byte 1' => array( $FAIL, "e\xaf" ), + 'extra continuation byte 2' => array( $FAIL, "\xc3\x89\xaf" ), + 'extra continuation byte 3' => array( $FAIL, "\xef\xbc\xa5\xaf" ), + 'extra continuation byte 4' => array( $FAIL, "\xf0\x9d\x99\xb4\xaf" ), + + 'impossible bytes 1' => array( $FAIL, "\xfe" ), + 'impossible bytes 2' => array( $FAIL, "\xff" ), + 'impossible bytes 3' => array( $FAIL, "\xfe\xfe\xff\xff" ), + + 'overlong sequences 1' => array( $FAIL, "\xc0\xaf" ), + 'overlong sequences 2' => array( $FAIL, "\xc1\xaf" ), + 'overlong sequences 3' => array( $FAIL, "\xe0\x80\xaf" ), + 'overlong sequences 4' => array( $FAIL, "\xf0\x80\x80\xaf" ), + 'overlong sequences 5' => array( $FAIL, "\xf8\x80\x80\x80\xaf" ), + 'overlong sequences 6' => array( $FAIL, "\xfc\x80\x80\x80\x80\xaf" ), + + 'maximum overlong sequences 1' => array( $FAIL, "\xc1\xbf" ), + 'maximum overlong sequences 2' => array( $FAIL, "\xe0\x9f\xbf" ), + 'maximum overlong sequences 3' => array( $FAIL, "\xf0\x8f\xbf\xbf" ), + 'maximum overlong sequences 4' => array( $FAIL, "\xf8\x87\xbf\xbf" ), + 'maximum overlong sequences 5' => array( $FAIL, "\xfc\x83\xbf\xbf\xbf\xbf" ), + + 'surrogates 1 (U+D799)' => array( $PASS, "\xed\x9f\xbf" ), + 'surrogates 2 (U+E000)' => array( $PASS, "\xee\x80\x80" ), + 'surrogates 3 (U+D800)' => array( $FAIL, "\xed\xa0\x80" ), + 'surrogates 4 (U+DBFF)' => array( $FAIL, "\xed\xaf\xbf" ), + 'surrogates 5 (U+DC00)' => array( $FAIL, "\xed\xb0\x80" ), + 'surrogates 6 (U+DFFF)' => array( $FAIL, "\xed\xbf\xbf" ), + 'surrogates 7 (U+D800 U+DC00)' => array( $FAIL, "\xed\xa0\x80\xed\xb0\x80" ), + + 'noncharacters 1' => array( $PASS, "\xef\xbf\xbe" ), + 'noncharacters 2' => array( $PASS, "\xef\xbf\xbf" ), + ); + } +} diff --git a/tests/phpunit/includes/utils/UIDGeneratorTest.php b/tests/phpunit/includes/utils/UIDGeneratorTest.php new file mode 100644 index 00000000..50fa3849 --- /dev/null +++ b/tests/phpunit/includes/utils/UIDGeneratorTest.php @@ -0,0 +1,129 @@ +<?php + +class UIDGeneratorTest extends MediaWikiTestCase { + + protected function tearDown() { + // Bug: 44850 + UIDGenerator::unitTestTearDown(); + parent::tearDown(); + } + + /** + * @dataProvider provider_testTimestampedUID + * @covers UIDGenerator::newTimestampedUID128 + * @covers UIDGenerator::newTimestampedUID88 + */ + public function testTimestampedUID( $method, $digitlen, $bits, $tbits, $hostbits ) { + $id = call_user_func( array( 'UIDGenerator', $method ) ); + $this->assertEquals( true, ctype_digit( $id ), "UID made of digit characters" ); + $this->assertLessThanOrEqual( $digitlen, strlen( $id ), + "UID has the right number of digits" ); + $this->assertLessThanOrEqual( $bits, strlen( wfBaseConvert( $id, 10, 2 ) ), + "UID has the right number of bits" ); + + $ids = array(); + for ( $i = 0; $i < 300; $i++ ) { + $ids[] = call_user_func( array( 'UIDGenerator', $method ) ); + } + + $lastId = array_shift( $ids ); + + $this->assertArrayEquals( array_unique( $ids ), $ids, "All generated IDs are unique." ); + + foreach ( $ids as $id ) { + $id_bin = wfBaseConvert( $id, 10, 2 ); + $lastId_bin = wfBaseConvert( $lastId, 10, 2 ); + + $this->assertGreaterThanOrEqual( + substr( $id_bin, 0, $tbits ), + substr( $lastId_bin, 0, $tbits ), + "New ID timestamp ($id_bin) >= prior one ($lastId_bin)." ); + + if ( $hostbits ) { + $this->assertEquals( + substr( $id_bin, 0, -$hostbits ), + substr( $lastId_bin, 0, -$hostbits ), + "Host ID of ($id_bin) is same as prior one ($lastId_bin)." ); + } + + $lastId = $id; + } + } + + /** + * array( method, length, bits, hostbits ) + * NOTE: When adding a new method name here please update the covers tags for the tests! + */ + public static function provider_testTimestampedUID() { + return array( + array( 'newTimestampedUID128', 39, 128, 46, 48 ), + array( 'newTimestampedUID128', 39, 128, 46, 48 ), + array( 'newTimestampedUID88', 27, 88, 46, 32 ), + ); + } + + /** + * @covers UIDGenerator::newUUIDv4 + */ + public function testUUIDv4() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newUUIDv4(); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newRawUUIDv4 + */ + public function testRawUUIDv4() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newRawUUIDv4(); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newRawUUIDv4 + */ + public function testRawUUIDv4QuickRand() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newRawUUIDv4( UIDGenerator::QUICK_RAND ); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newSequentialPerNodeID + */ + public function testNewSequentialID() { + $id1 = UIDGenerator::newSequentialPerNodeID( 'test', 32 ); + $id2 = UIDGenerator::newSequentialPerNodeID( 'test', 32 ); + + $this->assertType( 'float', $id1, "ID returned as float" ); + $this->assertType( 'float', $id2, "ID returned as float" ); + $this->assertGreaterThan( 0, $id1, "ID greater than 1" ); + $this->assertGreaterThan( $id1, $id2, "IDs increasing in value" ); + } + + /** + * @covers UIDGenerator::newSequentialPerNodeIDs + */ + public function testNewSequentialIDs() { + $ids = UIDGenerator::newSequentialPerNodeIDs( 'test', 32, 5 ); + $lastId = null; + foreach ( $ids as $id ) { + $this->assertType( 'float', $id, "ID returned as float" ); + $this->assertGreaterThan( 0, $id, "ID greater than 1" ); + if ( $lastId ) { + $this->assertGreaterThan( $lastId, $id, "IDs increasing in value" ); + } + $lastId = $id; + } + } +} diff --git a/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php b/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php new file mode 100644 index 00000000..34ffb535 --- /dev/null +++ b/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php @@ -0,0 +1,84 @@ +<?php + +/** + * @covers ZipDirectoryReader + * NOTE: this test is more like an integration test than a unit test + */ +class ZipDirectoryReaderTest extends MediaWikiTestCase { + protected $zipDir; + protected $entries; + + protected function setUp() { + parent::setUp(); + $this->zipDir = __DIR__ . '/../../data/zip'; + } + + function zipCallback( $entry ) { + $this->entries[] = $entry; + } + + function readZipAssertError( $file, $error, $assertMessage ) { + $this->entries = array(); + $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", array( $this, 'zipCallback' ) ); + $this->assertTrue( $status->hasMessage( $error ), $assertMessage ); + } + + function readZipAssertSuccess( $file, $assertMessage ) { + $this->entries = array(); + $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", array( $this, 'zipCallback' ) ); + $this->assertTrue( $status->isOK(), $assertMessage ); + } + + public function testEmpty() { + $this->readZipAssertSuccess( 'empty.zip', 'Empty zip' ); + } + + public function testMultiDisk0() { + $this->readZipAssertError( 'split.zip', 'zip-unsupported', + 'Split zip error' ); + } + + public function testNoSignature() { + $this->readZipAssertError( 'nosig.zip', 'zip-wrong-format', + 'No signature should give "wrong format" error' ); + } + + public function testSimple() { + $this->readZipAssertSuccess( 'class.zip', 'Simple ZIP' ); + $this->assertEquals( $this->entries, array( array( + 'name' => 'Class.class', + 'mtime' => '20010115000000', + 'size' => 1, + ) ) ); + } + + public function testBadCentralEntrySignature() { + $this->readZipAssertError( 'wrong-central-entry-sig.zip', 'zip-bad', + 'Bad central entry error' ); + } + + public function testTrailingBytes() { + $this->readZipAssertError( 'trail.zip', 'zip-bad', + 'Trailing bytes error' ); + } + + public function testWrongCDStart() { + $this->readZipAssertError( 'wrong-cd-start-disk.zip', 'zip-unsupported', + 'Wrong CD start disk error' ); + } + + public function testCentralDirectoryGap() { + $this->readZipAssertError( 'cd-gap.zip', 'zip-bad', + 'CD gap error' ); + } + + public function testCentralDirectoryTruncated() { + $this->readZipAssertError( 'cd-truncated.zip', 'zip-bad', + 'CD truncated error (should hit unpack() overrun)' ); + } + + public function testLooksLikeZip64() { + $this->readZipAssertError( 'looks-like-zip64.zip', 'zip-unsupported', + 'A file which looks like ZIP64 but isn\'t, should give error' ); + } +} diff --git a/tests/phpunit/install-phpunit.sh b/tests/phpunit/install-phpunit.sh new file mode 100644 index 00000000..022f998e --- /dev/null +++ b/tests/phpunit/install-phpunit.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +has_binary () { + if [ -z `which $1` ]; then + return 1 + fi + return 0 +} + +if [ `id -u` -ne 0 ]; then + echo '*** ERROR: Must be root to run' + exit 1 +fi + +if ( has_binary phpunit ); then + echo PHPUnit already installed +else if ( has_binary pear ); then + echo Installing phpunit with pear + pear channel-discover pear.phpunit.de + pear channel-discover components.ez.no + pear channel-discover pear.symfony.com + pear update-channels + #Temporary fix for 64597 + pear install --alldeps phpunit/PHPUnit-3.7.35 +else if ( has_binary apt-get ); then + echo Installing phpunit with apt-get + apt-get install phpunit +else if ( has_binary yum ); then + echo Installing phpunit with yum + yum install phpunit +else if ( has_binary port ); then + echo Installing phpunit with macports + port install php5-unit +fi +fi +fi +fi +fi diff --git a/tests/phpunit/languages/LanguageAmTest.php b/tests/phpunit/languages/LanguageAmTest.php new file mode 100644 index 00000000..a644f5e0 --- /dev/null +++ b/tests/phpunit/languages/LanguageAmTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageAm.php */ +class LanguageAmTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageArTest.php b/tests/phpunit/languages/LanguageArTest.php new file mode 100644 index 00000000..7b48f236 --- /dev/null +++ b/tests/phpunit/languages/LanguageArTest.php @@ -0,0 +1,87 @@ +<?php +/** + * Based on LanguagMlTest + * @file + */ + +/** Tests for MediaWiki languages/LanguageAr.php */ +class LanguageArTest extends LanguageClassesTestCase { + /** + * @covers Language::formatNum + * @todo split into a test and a dataprovider + */ + public function testFormatNum() { + $this->assertEquals( '١٬٢٣٤٬٥٦٧', $this->getLang()->formatNum( '1234567' ) ); + $this->assertEquals( '-١٢٫٨٩', $this->getLang()->formatNum( -12.89 ) ); + } + + /** + * Mostly to test the raw ascii feature. + * @dataProvider providerSprintfDate + * @covers Language::sprintfDate + */ + public function testSprintfDate( $format, $date, $expected ) { + $this->assertEquals( $expected, $this->getLang()->sprintfDate( $format, $date ) ); + } + + public static function providerSprintfDate() { + return array( + array( + 'xg "vs" g', + '20120102030410', + 'يناير vs ٣' + ), + array( + 'xmY', + '20120102030410', + '١٤٣٣' + ), + array( + 'xnxmY', + '20120102030410', + '1433' + ), + array( + 'xN xmj xmn xN xmY', + '20120102030410', + ' 7 2 ١٤٣٣' + ), + ); + } + + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'zero', 'one', 'two', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'zero', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'few', 9 ), + array( 'few', 110 ), + array( 'many', 11 ), + array( 'many', 15 ), + array( 'many', 99 ), + array( 'many', 9999 ), + array( 'other', 100 ), + array( 'other', 102 ), + array( 'other', 1000 ), + array( 'other', 1.7 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageArqTest.php b/tests/phpunit/languages/LanguageArqTest.php new file mode 100644 index 00000000..3fa56d78 --- /dev/null +++ b/tests/phpunit/languages/LanguageArqTest.php @@ -0,0 +1,26 @@ +<?php +/** + * Based on LanguageMlTest + * @author Joel Sahleen + * @copyright Copyright © 2014, Joel Sahleen + * @file + */ + +/** Tests for MediaWiki languages/LanguageArq.php */ +class LanguageArqTest extends LanguageClassesTestCase { + /** + * @dataProvider provideNumber + * @covers Language::formatNum + */ + public function testFormatNum( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->formatNum( $value ) ); + } + + public static function provideNumber() { + return array( + array( '1.234.567', '1234567'), + array( '-12,89', -12.89 ), + ); + } + +} diff --git a/tests/phpunit/languages/LanguageBeTest.php b/tests/phpunit/languages/LanguageBeTest.php new file mode 100644 index 00000000..7bd586af --- /dev/null +++ b/tests/phpunit/languages/LanguageBeTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageBe.php */ +class LanguageBeTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 1 ), + array( 'many', 11 ), + array( 'one', 91 ), + array( 'one', 121 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'few', 334 ), + array( 'many', 5 ), + array( 'many', 15 ), + array( 'many', 120 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageBe_taraskTest.php b/tests/phpunit/languages/LanguageBe_taraskTest.php new file mode 100644 index 00000000..4dd5cdd7 --- /dev/null +++ b/tests/phpunit/languages/LanguageBe_taraskTest.php @@ -0,0 +1,97 @@ +<?php + +// @codingStandardsIgnoreStart Ignore Squiz.Classes.ValidClassName.NotCamelCaps +class LanguageBe_taraskTest extends LanguageClassesTestCase { + // @codingStandardsIgnoreEnd + /** + * Make sure the language code we are given is indeed + * be-tarask. This is to ensure LanguageClassesTestCase + * does not give us the wrong language. + */ + public function testBeTaraskTestsUsesBeTaraskCode() { + $this->assertEquals( 'be-tarask', + $this->getLang()->getCode() + ); + } + + /** + * @see bug 23156 & r64981 + * @covers Language::commafy + */ + public function testSearchRightSingleQuotationMarkAsApostroph() { + $this->assertEquals( + "'", + $this->getLang()->normalizeForSearch( '’' ), + 'bug 23156: U+2019 conversion to U+0027' + ); + } + + /** + * @see bug 23156 & r64981 + * @covers Language::commafy + */ + public function testCommafy() { + $this->assertEquals( '1,234,567', $this->getLang()->commafy( '1234567' ) ); + $this->assertEquals( '12,345', $this->getLang()->commafy( '12345' ) ); + } + + /** + * @see bug 23156 & r64981 + * @covers Language::commafy + */ + public function testDoesNotCommafyFourDigitsNumber() { + $this->assertEquals( '1234', $this->getLang()->commafy( '1234' ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 1 ), + array( 'many', 11 ), + array( 'one', 91 ), + array( 'one', 121 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'few', 334 ), + array( 'many', 5 ), + array( 'many', 15 ), + array( 'many', 120 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( '1=one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 11 ), + array( 'other', 91 ), + array( 'other', 121 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageBhoTest.php b/tests/phpunit/languages/LanguageBhoTest.php new file mode 100644 index 00000000..187bfbbc --- /dev/null +++ b/tests/phpunit/languages/LanguageBhoTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageBho.php */ +class LanguageBhoTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageBsTest.php b/tests/phpunit/languages/LanguageBsTest.php new file mode 100644 index 00000000..7aca2ab1 --- /dev/null +++ b/tests/phpunit/languages/LanguageBsTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for Croatian (hrvatski) */ +class LanguageBsTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'one', 21 ), + array( 'few', 24 ), + array( 'other', 25 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageClassesTestCase.php b/tests/phpunit/languages/LanguageClassesTestCase.php new file mode 100644 index 00000000..f93ff7d3 --- /dev/null +++ b/tests/phpunit/languages/LanguageClassesTestCase.php @@ -0,0 +1,74 @@ +<?php +/** + * Helping class to run tests using a clean language instance. + * + * This is intended for the MediaWiki language class tests under + * tests/phpunit/languages. + * + * Before each tests, a new language object is build which you + * can retrieve in your test using the $this->getLang() method: + * + * @par Using the crafted language object: + * @code + * function testHasLanguageObject() { + * $langObject = $this->getLang(); + * $this->assertInstanceOf( 'LanguageFoo', + * $langObject + * ); + * } + * @endcode + */ +abstract class LanguageClassesTestCase extends MediaWikiTestCase { + /** + * Internal language object + * + * A new object is created before each tests thanks to PHPUnit + * setUp() method, it is deleted after each test too. To get + * this object you simply use the getLang method. + * + * You must have setup a language code first. See $LanguageClassCode + * @code + * function testWeAreTheChampions() { + * $this->getLang(); # language object + * } + * @endcode + */ + private $languageObject; + + /** + * @return Language + */ + protected function getLang() { + return $this->languageObject; + } + + /** + * Create a new language object before each test. + */ + protected function setUp() { + parent::setUp(); + $found = preg_match( '/Language(.+)Test/', get_called_class(), $m ); + if ( $found ) { + # Normalize language code since classes uses underscores + $m[1] = strtolower( str_replace( '_', '-', $m[1] ) ); + } else { + # Fallback to english language + $m[1] = 'en'; + wfDebug( + __METHOD__ . " could not extract a language name " + . "out of " . get_called_class() . " failling back to 'en'\n" + ); + } + // @todo validate $m[1] which should be a valid language code + $this->languageObject = Language::factory( $m[1] ); + } + + /** + * Delete the internal language object so each test start + * out with a fresh language instance. + */ + protected function tearDown() { + unset( $this->languageObject ); + parent::tearDown(); + } +} diff --git a/tests/phpunit/languages/LanguageCsTest.php b/tests/phpunit/languages/LanguageCsTest.php new file mode 100644 index 00000000..da9e6b88 --- /dev/null +++ b/tests/phpunit/languages/LanguageCsTest.php @@ -0,0 +1,41 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/Languagecs.php */ +class LanguageCsTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'other', 25 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageCuTest.php b/tests/phpunit/languages/LanguageCuTest.php new file mode 100644 index 00000000..07193172 --- /dev/null +++ b/tests/phpunit/languages/LanguageCuTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageCu.php */ +class LanguageCuTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'one', 11 ), + array( 'other', 20 ), + array( 'two', 22 ), + array( 'few', 223 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageCyTest.php b/tests/phpunit/languages/LanguageCyTest.php new file mode 100644 index 00000000..eaf663a8 --- /dev/null +++ b/tests/phpunit/languages/LanguageCyTest.php @@ -0,0 +1,43 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageCy.php */ +class LanguageCyTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'zero', 'one', 'two', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'zero', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'many', 6 ), + array( 'other', 4 ), + array( 'other', 5 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'other', 22 ), + array( 'other', 223 ), + array( 'other', 200.00 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageDsbTest.php b/tests/phpunit/languages/LanguageDsbTest.php new file mode 100644 index 00000000..94c11bcc --- /dev/null +++ b/tests/phpunit/languages/LanguageDsbTest.php @@ -0,0 +1,41 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageDsb.php */ +class LanguageDsbTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'one', 101 ), + array( 'one', 90001 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'few', 203 ), + array( 'few', 4 ), + array( 'other', 99 ), + array( 'other', 555 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageFrTest.php b/tests/phpunit/languages/LanguageFrTest.php new file mode 100644 index 00000000..46b65011 --- /dev/null +++ b/tests/phpunit/languages/LanguageFrTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageFr.php */ +class LanguageFrTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageGaTest.php b/tests/phpunit/languages/LanguageGaTest.php new file mode 100644 index 00000000..c009f56b --- /dev/null +++ b/tests/phpunit/languages/LanguageGaTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageGa.php */ +class LanguageGaTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageGdTest.php b/tests/phpunit/languages/LanguageGdTest.php new file mode 100644 index 00000000..b89b4df9 --- /dev/null +++ b/tests/phpunit/languages/LanguageGdTest.php @@ -0,0 +1,53 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012-2013, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageGd.php */ +class LanguageGdTest extends LanguageClassesTestCase { + /** + * @dataProvider providerPlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providerPlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'one', 11 ), + array( 'two', 12 ), + array( 'few', 3 ), + array( 'few', 19 ), + array( 'other', 200 ), + ); + } + + /** + * @dataProvider providerPluralExplicit + * @covers Language::convertPlural + */ + public function testExplicitPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other', '11=Form11', '12=Form12' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providerPluralExplicit() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'Form11', 11 ), + array( 'Form12', 12 ), + array( 'few', 3 ), + array( 'few', 19 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageGvTest.php b/tests/phpunit/languages/LanguageGvTest.php new file mode 100644 index 00000000..fc58022a --- /dev/null +++ b/tests/phpunit/languages/LanguageGvTest.php @@ -0,0 +1,44 @@ +<?php +/** + * Test for Manx (Gaelg) language + * + * @author Santhosh Thottingal + * @copyright Copyright © 2013, Santhosh Thottingal + * @file + */ + +class LanguageGvTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'few', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'other', 3 ), + array( 'few', 20 ), + array( 'one', 21 ), + array( 'two', 22 ), + array( 'other', 23 ), + array( 'other', 50 ), + array( 'few', 60 ), + array( 'other', 80 ), + array( 'few', 100 ) + ); + } +} diff --git a/tests/phpunit/languages/LanguageHeTest.php b/tests/phpunit/languages/LanguageHeTest.php new file mode 100644 index 00000000..c382244f --- /dev/null +++ b/tests/phpunit/languages/LanguageHeTest.php @@ -0,0 +1,132 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageHe.php */ +class LanguageHeTest extends LanguageClassesTestCase { + /** + * The most common usage for the plural forms is two forms, + * for singular and plural. In this case, the second form + * is technically dual, but in practice it's used as plural. + * In some cases, usually with expressions of time, three forms + * are needed - singular, dual and plural. + * CLDR also specifies a fourth form for multiples of 10, + * which is very rare. It also has a mistake, because + * the number 10 itself is supposed to be just plural, + * so currently it's overridden in MediaWiki. + */ + + // @todo the below test*PluralForms test methods can be refactored + // to use a single test method and data provider.. + + /** + * @dataProvider provideTwoPluralForms + * @covers Language::convertPlural + */ + public function testTwoPluralForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider provideThreePluralForms + * @covers Language::convertPlural + */ + public function testThreePluralForms( $result, $value ) { + $forms = array( 'one', 'two', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider provideFourPluralForms + * @covers Language::convertPlural + */ + public function testFourPluralForms( $result, $value ) { + $forms = array( 'one', 'two', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider provideFourPluralForms + * @covers Language::convertPlural + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function provideTwoPluralForms() { + return array( + array( 'other', 0 ), // Zero - plural + array( 'one', 1 ), // Singular + array( 'other', 2 ), // No third form provided, use it as plural + array( 'other', 3 ), // Plural - other + array( 'other', 10 ), // No fourth form provided, use it as plural + array( 'other', 20 ), // No fourth form provided, use it as plural + ); + } + + public static function provideThreePluralForms() { + return array( + array( 'other', 0 ), // Zero - plural + array( 'one', 1 ), // Singular + array( 'two', 2 ), // Dual + array( 'other', 3 ), // Plural - other + array( 'other', 10 ), // No fourth form provided, use it as plural + array( 'other', 20 ), // No fourth form provided, use it as plural + ); + } + + public static function provideFourPluralForms() { + return array( + array( 'other', 0 ), // Zero - plural + array( 'one', 1 ), // Singular + array( 'two', 2 ), // Dual + array( 'other', 3 ), // Plural - other + array( 'other', 10 ), // 10 is supposed to be plural (other), not "many" + array( 'many', 20 ), // Fourth form provided - rare, but supported by CLDR + ); + } + + /** + * @dataProvider provideGrammar + * @covers Language::convertGrammar + */ + public function testGrammar( $result, $word, $case ) { + $this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) ); + } + + // The comments in the beginning of the line help avoid RTL problems + // with text editors. + public static function provideGrammar() { + return array( + array( + /* result */'וויקיפדיה', + /* word */'ויקיפדיה', + /* case */'תחילית', + ), + array( + /* result */'וולפגנג', + /* word */'וולפגנג', + /* case */'prefixed', + ), + array( + /* result */'קובץ', + /* word */'הקובץ', + /* case */'תחילית', + ), + array( + /* result */'־Wikipedia', + /* word */'Wikipedia', + /* case */'תחילית', + ), + array( + /* result */'־1995', + /* word */'1995', + /* case */'תחילית', + ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageHiTest.php b/tests/phpunit/languages/LanguageHiTest.php new file mode 100644 index 00000000..f6d2c9e9 --- /dev/null +++ b/tests/phpunit/languages/LanguageHiTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageHi.php */ +class LanguageHiTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageHrTest.php b/tests/phpunit/languages/LanguageHrTest.php new file mode 100644 index 00000000..644c5255 --- /dev/null +++ b/tests/phpunit/languages/LanguageHrTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageHr.php */ +class LanguageHrTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'one', 21 ), + array( 'few', 24 ), + array( 'other', 25 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageHsbTest.php b/tests/phpunit/languages/LanguageHsbTest.php new file mode 100644 index 00000000..f95a43bf --- /dev/null +++ b/tests/phpunit/languages/LanguageHsbTest.php @@ -0,0 +1,41 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageHsb.php */ +class LanguageHsbTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'one', 101 ), + array( 'one', 90001 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'few', 203 ), + array( 'few', 4 ), + array( 'other', 99 ), + array( 'other', 555 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageHuTest.php b/tests/phpunit/languages/LanguageHuTest.php new file mode 100644 index 00000000..ee9197d7 --- /dev/null +++ b/tests/phpunit/languages/LanguageHuTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageHu.php */ +class LanguageHuTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageHyTest.php b/tests/phpunit/languages/LanguageHyTest.php new file mode 100644 index 00000000..92e0ef94 --- /dev/null +++ b/tests/phpunit/languages/LanguageHyTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for Armenian (Հայերեն) */ +class LanguageHyTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageKshTest.php b/tests/phpunit/languages/LanguageKshTest.php new file mode 100644 index 00000000..568a3780 --- /dev/null +++ b/tests/phpunit/languages/LanguageKshTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageKsh.php */ +class LanguageKshTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other', 'zero' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'zero', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageLnTest.php b/tests/phpunit/languages/LanguageLnTest.php new file mode 100644 index 00000000..10b3234f --- /dev/null +++ b/tests/phpunit/languages/LanguageLnTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageLn.php */ +class LanguageLnTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageLtTest.php b/tests/phpunit/languages/LanguageLtTest.php new file mode 100644 index 00000000..30642f62 --- /dev/null +++ b/tests/phpunit/languages/LanguageLtTest.php @@ -0,0 +1,63 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageLt.php */ +class LanguageLtTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 9 ), + array( 'other', 10 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'one', 21 ), + array( 'few', 32 ), + array( 'one', 41 ), + array( 'one', 40001 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testOneFewPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + // This fails for 21, but not sure why. + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 15 ), + array( 'other', 20 ), + array( 'one', 21 ), + array( 'other', 22 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageLvTest.php b/tests/phpunit/languages/LanguageLvTest.php new file mode 100644 index 00000000..7120cfe3 --- /dev/null +++ b/tests/phpunit/languages/LanguageLvTest.php @@ -0,0 +1,44 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for Latvian */ +class LanguageLvTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'zero', 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'zero', 0 ), + array( 'one', 1 ), + array( 'zero', 11 ), + array( 'one', 21 ), + array( 'zero', 411 ), + array( 'other', 2 ), + array( 'other', 9 ), + array( 'zero', 12 ), + array( 'other', 12.345 ), + array( 'zero', 20 ), + array( 'other', 22 ), + array( 'one', 31 ), + array( 'zero', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageMgTest.php b/tests/phpunit/languages/LanguageMgTest.php new file mode 100644 index 00000000..65e8fd7b --- /dev/null +++ b/tests/phpunit/languages/LanguageMgTest.php @@ -0,0 +1,36 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageMg.php */ +class LanguageMgTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 200 ), + array( 'other', 123.3434 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageMkTest.php b/tests/phpunit/languages/LanguageMkTest.php new file mode 100644 index 00000000..ed155263 --- /dev/null +++ b/tests/phpunit/languages/LanguageMkTest.php @@ -0,0 +1,40 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for македонски/Macedonian */ +class LanguageMkTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'one', 11 ), + array( 'one', 21 ), + array( 'one', 411 ), + array( 'other', 12.345 ), + array( 'other', 20 ), + array( 'one', 31 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageMlTest.php b/tests/phpunit/languages/LanguageMlTest.php new file mode 100644 index 00000000..4fa45ce3 --- /dev/null +++ b/tests/phpunit/languages/LanguageMlTest.php @@ -0,0 +1,38 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2011, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageMl.php */ +class LanguageMlTest extends LanguageClassesTestCase { + + /** + * @dataProvider providerFormatNum + * @see bug 29495 + * @covers Language::formatNum + */ + public function testFormatNum( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->formatNum( $value ) ); + } + + public static function providerFormatNum() { + return array( + array( '12,34,567', '1234567' ), + array( '12,345', '12345' ), + array( '1', '1' ), + array( '123', '123' ), + array( '1,234', '1234' ), + array( '12,345.56', '12345.56' ), + array( '12,34,56,79,81,23,45,678', '12345679812345678' ), + array( '.12345', '.12345' ), + array( '-12,00,000', '-1200000' ), + array( '-98', '-98' ), + array( '-98', -98 ), + array( '-1,23,45,678', -12345678 ), + array( '', '' ), + array( '', null ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageMoTest.php b/tests/phpunit/languages/LanguageMoTest.php new file mode 100644 index 00000000..e0e54ca8 --- /dev/null +++ b/tests/phpunit/languages/LanguageMoTest.php @@ -0,0 +1,45 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageMo.php */ +class LanguageMoTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'few', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 19 ), + array( 'other', 20 ), + array( 'other', 99 ), + array( 'other', 100 ), + array( 'few', 101 ), + array( 'few', 119 ), + array( 'other', 120 ), + array( 'other', 200 ), + array( 'few', 201 ), + array( 'few', 219 ), + array( 'other', 220 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageMtTest.php b/tests/phpunit/languages/LanguageMtTest.php new file mode 100644 index 00000000..96d2bc92 --- /dev/null +++ b/tests/phpunit/languages/LanguageMtTest.php @@ -0,0 +1,77 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageMt.php */ +class LanguageMtTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'few', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 10 ), + array( 'many', 11 ), + array( 'many', 19 ), + array( 'other', 20 ), + array( 'other', 99 ), + array( 'other', 100 ), + array( 'other', 101 ), + array( 'few', 102 ), + array( 'few', 110 ), + array( 'many', 111 ), + array( 'many', 119 ), + array( 'other', 120 ), + array( 'other', 201 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 10 ), + array( 'other', 11 ), + array( 'other', 19 ), + array( 'other', 20 ), + array( 'other', 99 ), + array( 'other', 100 ), + array( 'other', 101 ), + array( 'other', 102 ), + array( 'other', 110 ), + array( 'other', 111 ), + array( 'other', 119 ), + array( 'other', 120 ), + array( 'other', 201 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageNlTest.php b/tests/phpunit/languages/LanguageNlTest.php new file mode 100644 index 00000000..26bd691a --- /dev/null +++ b/tests/phpunit/languages/LanguageNlTest.php @@ -0,0 +1,24 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2011, Santhosh Thottingal + * @file + */ + +/** Tests for MediaWiki languages/LanguageNl.php */ +class LanguageNlTest extends LanguageClassesTestCase { + + /** + * @covers Language::formatNum + * @todo split into a test and a dataprovider + */ + public function testFormatNum() { + $this->assertEquals( '1.234.567', $this->getLang()->formatNum( '1234567' ) ); + $this->assertEquals( '12.345', $this->getLang()->formatNum( '12345' ) ); + $this->assertEquals( '1', $this->getLang()->formatNum( '1' ) ); + $this->assertEquals( '123', $this->getLang()->formatNum( '123' ) ); + $this->assertEquals( '1.234', $this->getLang()->formatNum( '1234' ) ); + $this->assertEquals( '12.345,56', $this->getLang()->formatNum( '12345.56' ) ); + $this->assertEquals( ',1234556', $this->getLang()->formatNum( '.1234556' ) ); + } +} diff --git a/tests/phpunit/languages/LanguageNsoTest.php b/tests/phpunit/languages/LanguageNsoTest.php new file mode 100644 index 00000000..18efd736 --- /dev/null +++ b/tests/phpunit/languages/LanguageNsoTest.php @@ -0,0 +1,34 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageNso.php */ +class LanguageNsoTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguagePlTest.php b/tests/phpunit/languages/LanguagePlTest.php new file mode 100644 index 00000000..d180037b --- /dev/null +++ b/tests/phpunit/languages/LanguagePlTest.php @@ -0,0 +1,77 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguagePl.php */ +class LanguagePlTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'many' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'many', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'many', 5 ), + array( 'many', 9 ), + array( 'many', 10 ), + array( 'many', 11 ), + array( 'many', 21 ), + array( 'few', 22 ), + array( 'few', 23 ), + array( 'few', 24 ), + array( 'many', 25 ), + array( 'many', 200 ), + array( 'many', 201 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 3 ), + array( 'other', 4 ), + array( 'other', 5 ), + array( 'other', 9 ), + array( 'other', 10 ), + array( 'other', 11 ), + array( 'other', 21 ), + array( 'other', 22 ), + array( 'other', 23 ), + array( 'other', 24 ), + array( 'other', 25 ), + array( 'other', 200 ), + array( 'other', 201 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageRoTest.php b/tests/phpunit/languages/LanguageRoTest.php new file mode 100644 index 00000000..ae7816bc --- /dev/null +++ b/tests/phpunit/languages/LanguageRoTest.php @@ -0,0 +1,45 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageRo.php */ +class LanguageRoTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'few', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 19 ), + array( 'other', 20 ), + array( 'other', 99 ), + array( 'other', 100 ), + array( 'few', 101 ), + array( 'few', 119 ), + array( 'other', 120 ), + array( 'other', 200 ), + array( 'few', 201 ), + array( 'few', 219 ), + array( 'other', 220 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageRuTest.php b/tests/phpunit/languages/LanguageRuTest.php new file mode 100644 index 00000000..f64fc722 --- /dev/null +++ b/tests/phpunit/languages/LanguageRuTest.php @@ -0,0 +1,115 @@ +<?php +/** + * @author Amir E. Aharoni + * based on LanguageBe_tarask.php + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageRu.php */ +class LanguageRuTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * Test explicit plural forms - n=FormN forms + * @covers Language::convertPlural + */ + public function testExplicitPlural() { + $forms = array( 'one', 'many', 'other', '12=dozen' ); + $this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) ); + $forms = array( 'one', 'many', '100=hundred', 'other', '12=dozen' ); + $this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 1 ), + array( 'many', 11 ), + array( 'one', 91 ), + array( 'one', 121 ), + array( 'other', 2 ), + array( 'other', 3 ), + array( 'other', 4 ), + array( 'other', 334 ), + array( 'many', 5 ), + array( 'many', 15 ), + array( 'many', 120 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( '1=one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'one', 1 ), + array( 'other', 11 ), + array( 'other', 91 ), + array( 'other', 121 ), + ); + } + + /** + * @dataProvider providerGrammar + * @covers Language::convertGrammar + */ + public function testGrammar( $result, $word, $case ) { + $this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) ); + } + + public static function providerGrammar() { + return array( + array( + 'Википедии', + 'Википедия', + 'genitive', + ), + array( + 'Викитеки', + 'Викитека', + 'genitive', + ), + array( + 'Викитеке', + 'Викитека', + 'prepositional', + ), + array( + 'Викисклада', + 'Викисклад', + 'genitive', + ), + array( + 'Викискладе', + 'Викисклад', + 'prepositional', + ), + array( + 'Викиданных', + 'Викиданные', + 'prepositional', + ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSeTest.php b/tests/phpunit/languages/LanguageSeTest.php new file mode 100644 index 00000000..533aa2bc --- /dev/null +++ b/tests/phpunit/languages/LanguageSeTest.php @@ -0,0 +1,53 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageSe.php */ +class LanguageSeTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'other', 3 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 3 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSgsTest.php b/tests/phpunit/languages/LanguageSgsTest.php new file mode 100644 index 00000000..fa49a4dd --- /dev/null +++ b/tests/phpunit/languages/LanguageSgsTest.php @@ -0,0 +1,71 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for Samogitian */ +class LanguageSgsTest extends LanguageClassesTestCase { + /** + * @dataProvider providePluralAllForms + * @covers Language::convertPlural + */ + public function testPluralAllForms( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePluralAllForms + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePluralAllForms() { + return array( + array( 'few', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'other', 3 ), + array( 'few', 10 ), + array( 'few', 11 ), + array( 'few', 12 ), + array( 'few', 19 ), + array( 'other', 20 ), + array( 'few', 100 ), + array( 'one', 101 ), + array( 'few', 111 ), + array( 'few', 112 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 3 ), + array( 'other', 10 ), + array( 'other', 11 ), + array( 'other', 12 ), + array( 'other', 19 ), + array( 'other', 20 ), + array( 'other', 100 ), + array( 'one', 101 ), + array( 'other', 111 ), + array( 'other', 112 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageShTest.php b/tests/phpunit/languages/LanguageShTest.php new file mode 100644 index 00000000..1b390872 --- /dev/null +++ b/tests/phpunit/languages/LanguageShTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for srpskohrvatski / српскохрватски / Serbocroatian */ +class LanguageShTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 10 ), + array( 'other', 11 ), + array( 'other', 12 ), + array( 'one', 101 ), + array( 'few', 102 ), + array( 'other', 111 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSkTest.php b/tests/phpunit/languages/LanguageSkTest.php new file mode 100644 index 00000000..cb8a13b8 --- /dev/null +++ b/tests/phpunit/languages/LanguageSkTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Amir E. Aharoni + * based on LanguageSkTest.php + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageSk.php */ +class LanguageSkTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 11 ), + array( 'other', 20 ), + array( 'other', 25 ), + array( 'other', 200 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSlTest.php b/tests/phpunit/languages/LanguageSlTest.php new file mode 100644 index 00000000..9783dd80 --- /dev/null +++ b/tests/phpunit/languages/LanguageSlTest.php @@ -0,0 +1,44 @@ +<?php +/** + * @author Santhosh Thottingal + * @copyright Copyright © 2012, Amir E. Aharoni + * based on LanguageSkTest.php + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageSl.php */ +class LanguageSlTest extends LanguageClassesTestCase { + /** + * @dataProvider providerPlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providerPlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providerPlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'other', 5 ), + array( 'other', 99 ), + array( 'other', 100 ), + array( 'one', 101 ), + array( 'two', 102 ), + array( 'few', 103 ), + array( 'one', 201 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSmaTest.php b/tests/phpunit/languages/LanguageSmaTest.php new file mode 100644 index 00000000..95cb333c --- /dev/null +++ b/tests/phpunit/languages/LanguageSmaTest.php @@ -0,0 +1,53 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageSma.php */ +class LanguageSmaTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'two', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'two', 2 ), + array( 'other', 3 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'other', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + array( 'other', 3 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageSrTest.php b/tests/phpunit/languages/LanguageSrTest.php new file mode 100644 index 00000000..bfb199f3 --- /dev/null +++ b/tests/phpunit/languages/LanguageSrTest.php @@ -0,0 +1,249 @@ +<?php +/** + * PHPUnit tests for the Serbian language. + * The language can be represented using two scripts: + * - Latin (SR_el) + * - Cyrillic (SR_ec) + * Both representations seems to be bijective, hence MediaWiki can convert + * from one script to the other. + * + * @author Antoine Musso <hashar at free dot fr> + * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr> + * @file + * + * @todo methods in test class should be tidied: + * - Should be split into separate test methods and data providers + * - Tests for LanguageConverter and Language should probably be separate.. + */ + +/** Tests for MediaWiki languages/LanguageSr.php */ +class LanguageSrTest extends LanguageClassesTestCase { + /** + * @covers LanguageConverter::convertTo + */ + public function testEasyConversions() { + $this->assertCyrillic( + 'шђчћжШЂЧЋЖ', + 'Cyrillic guessing characters' + ); + $this->assertLatin( + 'šđč枊ĐČĆŽ', + 'Latin guessing characters' + ); + } + + /** + * @covers LanguageConverter::convertTo + */ + public function testMixedConversions() { + $this->assertCyrillic( + 'шђчћжШЂЧЋЖ - šđčćž', + 'Mostly cyrillic characters' + ); + $this->assertLatin( + 'šđč枊ĐČĆŽ - шђчћж', + 'Mostly latin characters' + ); + } + + /** + * @covers LanguageConverter::convertTo + */ + public function testSameAmountOfLatinAndCyrillicGetConverted() { + $this->assertConverted( + '4 latin: šđčć | 4 cyrillic: шђчћ', + 'sr-ec' + ); + $this->assertConverted( + '4 latin: šđčć | 4 cyrillic: шђчћ', + 'sr-el' + ); + } + + /** + * @author Nikola Smolenski + * @covers LanguageConverter::convertTo + */ + public function testConversionToCyrillic() { + //A simple convertion of Latin to Cyrillic + $this->assertEquals( 'абвг', + $this->convertToCyrillic( 'abvg' ) + ); + //Same as above, but assert that -{}-s must be removed and not converted + $this->assertEquals( 'ljабnjвгdž', + $this->convertToCyrillic( '-{lj}-ab-{nj}-vg-{dž}-' ) + ); + //A simple convertion of Cyrillic to Cyrillic + $this->assertEquals( 'абвг', + $this->convertToCyrillic( 'абвг' ) + ); + //Same as above, but assert that -{}-s must be removed and not converted + $this->assertEquals( 'ljабnjвгdž', + $this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{dž}-' ) + ); + //This text has some Latin, but is recognized as Cyrillic, so it should not be converted + $this->assertEquals( 'abvgшђжчћ', + $this->convertToCyrillic( 'abvgшђжчћ' ) + ); + //Same as above, but assert that -{}-s must be removed + $this->assertEquals( 'љabvgњшђжчћџ', + $this->convertToCyrillic( '-{љ}-abvg-{њ}-шђжчћ-{џ}-' ) + ); + //This text has some Cyrillic, but is recognized as Latin, so it should be converted + $this->assertEquals( 'абвгшђжчћ', + $this->convertToCyrillic( 'абвгšđžčć' ) + ); + //Same as above, but assert that -{}-s must be removed and not converted + $this->assertEquals( 'ljабвгnjшђжчћdž', + $this->convertToCyrillic( '-{lj}-абвг-{nj}-šđžčć-{dž}-' ) + ); + // Roman numerals are not converted + $this->assertEquals( 'а I б II в III г IV шђжчћ', + $this->convertToCyrillic( 'a I b II v III g IV šđžčć' ) + ); + } + + /** + * @covers LanguageConverter::convertTo + */ + public function testConversionToLatin() { + //A simple convertion of Latin to Latin + $this->assertEquals( 'abcd', + $this->convertToLatin( 'abcd' ) + ); + //A simple convertion of Cyrillic to Latin + $this->assertEquals( 'abcd', + $this->convertToLatin( 'абцд' ) + ); + //This text has some Latin, but is recognized as Cyrillic, so it should be converted + $this->assertEquals( 'abcdšđžčć', + $this->convertToLatin( 'abcdшђжчћ' ) + ); + //This text has some Cyrillic, but is recognized as Latin, so it should not be converted + $this->assertEquals( 'абцдšđžčć', + $this->convertToLatin( 'абцдšđžčć' ) + ); + } + + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 1 ), + array( 'other', 11 ), + array( 'one', 91 ), + array( 'one', 121 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'few', 334 ), + array( 'other', 5 ), + array( 'other', 15 ), + array( 'other', 120 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'one', 1 ), + array( 'other', 11 ), + array( 'other', 4 ), + array( 'one', 91 ), + array( 'one', 121 ), + ); + } + + ##### HELPERS ##################################################### + /** + *Wrapper to verify text stay the same after applying conversion + * @param string $text Text to convert + * @param string $variant Language variant 'sr-ec' or 'sr-el' + * @param string $msg Optional message + */ + protected function assertUnConverted( $text, $variant, $msg = '' ) { + $this->assertEquals( + $text, + $this->convertTo( $text, $variant ), + $msg + ); + } + + /** + * Wrapper to verify a text is different once converted to a variant. + * @param string $text Text to convert + * @param string $variant Language variant 'sr-ec' or 'sr-el' + * @param string $msg Optional message + */ + protected function assertConverted( $text, $variant, $msg = '' ) { + $this->assertNotEquals( + $text, + $this->convertTo( $text, $variant ), + $msg + ); + } + + /** + * Verifiy the given Cyrillic text is not converted when using + * using the cyrillic variant and converted to Latin when using + * the Latin variant. + * @param string $text Text to convert + * @param string $msg Optional message + */ + protected function assertCyrillic( $text, $msg = '' ) { + $this->assertUnConverted( $text, 'sr-ec', $msg ); + $this->assertConverted( $text, 'sr-el', $msg ); + } + + /** + * Verifiy the given Latin text is not converted when using + * using the Latin variant and converted to Cyrillic when using + * the Cyrillic variant. + * @param string $text Text to convert + * @param string $msg Optional message + */ + protected function assertLatin( $text, $msg = '' ) { + $this->assertUnConverted( $text, 'sr-el', $msg ); + $this->assertConverted( $text, 'sr-ec', $msg ); + } + + /** Wrapper for converter::convertTo() method*/ + protected function convertTo( $text, $variant ) { + return $this->getLang() + ->mConverter + ->convertTo( + $text, $variant + ); + } + + protected function convertToCyrillic( $text ) { + return $this->convertTo( $text, 'sr-ec' ); + } + + protected function convertToLatin( $text ) { + return $this->convertTo( $text, 'sr-el' ); + } +} diff --git a/tests/phpunit/languages/LanguageTest.php b/tests/phpunit/languages/LanguageTest.php new file mode 100644 index 00000000..cff2e8fd --- /dev/null +++ b/tests/phpunit/languages/LanguageTest.php @@ -0,0 +1,1635 @@ +<?php + +class LanguageTest extends LanguageClassesTestCase { + /** + * @covers Language::convertDoubleWidth + * @covers Language::normalizeForSearch + */ + public function testLanguageConvertDoubleWidthToSingleWidth() { + $this->assertEquals( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + $this->getLang()->normalizeForSearch( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ), + 'convertDoubleWidth() with the full alphabet and digits' + ); + } + + /** + * @dataProvider provideFormattableTimes + * @covers Language::formatTimePeriod + */ + public function testFormatTimePeriod( $seconds, $format, $expected, $desc ) { + $this->assertEquals( $expected, $this->getLang()->formatTimePeriod( $seconds, $format ), $desc ); + } + + public static function provideFormattableTimes() { + return array( + array( + 9.45, + array(), + '9.5 s', + 'formatTimePeriod() rounding (<10s)' + ), + array( + 9.45, + array( 'noabbrevs' => true ), + '9.5 seconds', + 'formatTimePeriod() rounding (<10s)' + ), + array( + 9.95, + array(), + '10 s', + 'formatTimePeriod() rounding (<10s)' + ), + array( + 9.95, + array( 'noabbrevs' => true ), + '10 seconds', + 'formatTimePeriod() rounding (<10s)' + ), + array( + 59.55, + array(), + '1 min 0 s', + 'formatTimePeriod() rounding (<60s)' + ), + array( + 59.55, + array( 'noabbrevs' => true ), + '1 minute 0 seconds', + 'formatTimePeriod() rounding (<60s)' + ), + array( + 119.55, + array(), + '2 min 0 s', + 'formatTimePeriod() rounding (<1h)' + ), + array( + 119.55, + array( 'noabbrevs' => true ), + '2 minutes 0 seconds', + 'formatTimePeriod() rounding (<1h)' + ), + array( + 3599.55, + array(), + '1 h 0 min 0 s', + 'formatTimePeriod() rounding (<1h)' + ), + array( + 3599.55, + array( 'noabbrevs' => true ), + '1 hour 0 minutes 0 seconds', + 'formatTimePeriod() rounding (<1h)' + ), + array( + 7199.55, + array(), + '2 h 0 min 0 s', + 'formatTimePeriod() rounding (>=1h)' + ), + array( + 7199.55, + array( 'noabbrevs' => true ), + '2 hours 0 minutes 0 seconds', + 'formatTimePeriod() rounding (>=1h)' + ), + array( + 7199.55, + 'avoidseconds', + '2 h 0 min', + 'formatTimePeriod() rounding (>=1h), avoidseconds' + ), + array( + 7199.55, + array( 'avoid' => 'avoidseconds', 'noabbrevs' => true ), + '2 hours 0 minutes', + 'formatTimePeriod() rounding (>=1h), avoidseconds' + ), + array( + 7199.55, + 'avoidminutes', + '2 h 0 min', + 'formatTimePeriod() rounding (>=1h), avoidminutes' + ), + array( + 7199.55, + array( 'avoid' => 'avoidminutes', 'noabbrevs' => true ), + '2 hours 0 minutes', + 'formatTimePeriod() rounding (>=1h), avoidminutes' + ), + array( + 172799.55, + 'avoidseconds', + '48 h 0 min', + 'formatTimePeriod() rounding (=48h), avoidseconds' + ), + array( + 172799.55, + array( 'avoid' => 'avoidseconds', 'noabbrevs' => true ), + '48 hours 0 minutes', + 'formatTimePeriod() rounding (=48h), avoidseconds' + ), + array( + 259199.55, + 'avoidminutes', + '3 d 0 h', + 'formatTimePeriod() rounding (>48h), avoidminutes' + ), + array( + 259199.55, + array( 'avoid' => 'avoidminutes', 'noabbrevs' => true ), + '3 days 0 hours', + 'formatTimePeriod() rounding (>48h), avoidminutes' + ), + array( + 176399.55, + 'avoidseconds', + '2 d 1 h 0 min', + 'formatTimePeriod() rounding (>48h), avoidseconds' + ), + array( + 176399.55, + array( 'avoid' => 'avoidseconds', 'noabbrevs' => true ), + '2 days 1 hour 0 minutes', + 'formatTimePeriod() rounding (>48h), avoidseconds' + ), + array( + 176399.55, + 'avoidminutes', + '2 d 1 h', + 'formatTimePeriod() rounding (>48h), avoidminutes' + ), + array( + 176399.55, + array( 'avoid' => 'avoidminutes', 'noabbrevs' => true ), + '2 days 1 hour', + 'formatTimePeriod() rounding (>48h), avoidminutes' + ), + array( + 259199.55, + 'avoidseconds', + '3 d 0 h 0 min', + 'formatTimePeriod() rounding (>48h), avoidseconds' + ), + array( + 259199.55, + array( 'avoid' => 'avoidseconds', 'noabbrevs' => true ), + '3 days 0 hours 0 minutes', + 'formatTimePeriod() rounding (>48h), avoidseconds' + ), + array( + 172801.55, + 'avoidseconds', + '2 d 0 h 0 min', + 'formatTimePeriod() rounding, (>48h), avoidseconds' + ), + array( + 172801.55, + array( 'avoid' => 'avoidseconds', 'noabbrevs' => true ), + '2 days 0 hours 0 minutes', + 'formatTimePeriod() rounding, (>48h), avoidseconds' + ), + array( + 176460.55, + array(), + '2 d 1 h 1 min 1 s', + 'formatTimePeriod() rounding, recursion, (>48h)' + ), + array( + 176460.55, + array( 'noabbrevs' => true ), + '2 days 1 hour 1 minute 1 second', + 'formatTimePeriod() rounding, recursion, (>48h)' + ), + ); + } + + /** + * @covers Language::truncate + */ + public function testTruncate() { + $this->assertEquals( + "XXX", + $this->getLang()->truncate( "1234567890", 0, 'XXX' ), + 'truncate prefix, len 0, small ellipsis' + ); + + $this->assertEquals( + "12345XXX", + $this->getLang()->truncate( "1234567890", 8, 'XXX' ), + 'truncate prefix, small ellipsis' + ); + + $this->assertEquals( + "123456789", + $this->getLang()->truncate( "123456789", 5, 'XXXXXXXXXXXXXXX' ), + 'truncate prefix, large ellipsis' + ); + + $this->assertEquals( + "XXX67890", + $this->getLang()->truncate( "1234567890", -8, 'XXX' ), + 'truncate suffix, small ellipsis' + ); + + $this->assertEquals( + "123456789", + $this->getLang()->truncate( "123456789", -5, 'XXXXXXXXXXXXXXX' ), + 'truncate suffix, large ellipsis' + ); + $this->assertEquals( + "123XXX", + $this->getLang()->truncate( "123 ", 9, 'XXX' ), + 'truncate prefix, with spaces' + ); + $this->assertEquals( + "12345XXX", + $this->getLang()->truncate( "12345 8", 11, 'XXX' ), + 'truncate prefix, with spaces and non-space ending' + ); + $this->assertEquals( + "XXX234", + $this->getLang()->truncate( "1 234", -8, 'XXX' ), + 'truncate suffix, with spaces' + ); + $this->assertEquals( + "12345XXX", + $this->getLang()->truncate( "1234567890", 5, 'XXX', false ), + 'truncate without adjustment' + ); + } + + /** + * @dataProvider provideHTMLTruncateData + * @covers Language::truncateHTML + */ + public function testTruncateHtml( $len, $ellipsis, $input, $expected ) { + // Actual HTML... + $this->assertEquals( + $expected, + $this->getLang()->truncateHTML( $input, $len, $ellipsis ) + ); + } + + /** + * @return array Format is ($len, $ellipsis, $input, $expected) + */ + public static function provideHTMLTruncateData() { + return array( + array( 0, 'XXX', "1234567890", "XXX" ), + array( 8, 'XXX', "1234567890", "12345XXX" ), + array( 5, 'XXXXXXXXXXXXXXX', '1234567890', "1234567890" ), + array( 2, '***', + '<p><span style="font-weight:bold;"></span></p>', + '<p><span style="font-weight:bold;"></span></p>', + ), + array( 2, '***', + '<p><span style="font-weight:bold;">123456789</span></p>', + '<p><span style="font-weight:bold;">***</span></p>', + ), + array( 2, '***', + '<p><span style="font-weight:bold;"> 23456789</span></p>', + '<p><span style="font-weight:bold;">***</span></p>', + ), + array( 3, '***', + '<p><span style="font-weight:bold;">123456789</span></p>', + '<p><span style="font-weight:bold;">***</span></p>', + ), + array( 4, '***', + '<p><span style="font-weight:bold;">123456789</span></p>', + '<p><span style="font-weight:bold;">1***</span></p>', + ), + array( 5, '***', + '<tt><span style="font-weight:bold;">123456789</span></tt>', + '<tt><span style="font-weight:bold;">12***</span></tt>', + ), + array( 6, '***', + '<p><a href="www.mediawiki.org">123456789</a></p>', + '<p><a href="www.mediawiki.org">123***</a></p>', + ), + array( 6, '***', + '<p><a href="www.mediawiki.org">12 456789</a></p>', + '<p><a href="www.mediawiki.org">12 ***</a></p>', + ), + array( 7, '***', + '<small><span style="font-weight:bold;">123<p id="#moo">456</p>789</span></small>', + '<small><span style="font-weight:bold;">123<p id="#moo">4***</p></span></small>', + ), + array( 8, '***', + '<div><span style="font-weight:bold;">123<span>4</span>56789</span></div>', + '<div><span style="font-weight:bold;">123<span>4</span>5***</span></div>', + ), + array( 9, '***', + '<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>', + '<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>', + ), + array( 10, '***', + '<p><font style="font-weight:bold;">123456789</font></p>', + '<p><font style="font-weight:bold;">123456789</font></p>', + ), + ); + } + + /** + * Test Language::isWellFormedLanguageTag() + * @dataProvider provideWellFormedLanguageTags + * @covers Language::isWellFormedLanguageTag + */ + public function testWellFormedLanguageTag( $code, $message = '' ) { + $this->assertTrue( + Language::isWellFormedLanguageTag( $code ), + "validating code $code $message" + ); + } + + /** + * The test cases are based on the tests in the GaBuZoMeu parser + * written by Stéphane Bortzmeyer <bortzmeyer@nic.fr> + * and distributed as free software, under the GNU General Public Licence. + * http://www.bortzmeyer.org/gabuzomeu-parsing-language-tags.html + */ + public static function provideWellFormedLanguageTags() { + return array( + array( 'fr', 'two-letter code' ), + array( 'fr-latn', 'two-letter code with lower case script code' ), + array( 'fr-Latn-FR', 'two-letter code with title case script code and uppercase country code' ), + array( 'fr-Latn-419', 'two-letter code with title case script code and region number' ), + array( 'fr-FR', 'two-letter code with uppercase' ), + array( 'ax-TZ', 'Not in the registry, but well-formed' ), + array( 'fr-shadok', 'two-letter code with variant' ), + array( 'fr-y-myext-myext2', 'non-x singleton' ), + array( 'fra-Latn', 'ISO 639 can be 3-letters' ), + array( 'fra', 'three-letter language code' ), + array( 'fra-FX', 'three-letter language code with country code' ), + array( 'i-klingon', 'grandfathered with singleton' ), + array( 'I-kLINgon', 'tags are case-insensitive...' ), + array( 'no-bok', 'grandfathered without singleton' ), + array( 'i-enochian', 'Grandfathered' ), + array( 'x-fr-CH', 'private use' ), + array( 'es-419', 'two-letter code with region number' ), + array( 'en-Latn-GB-boont-r-extended-sequence-x-private', 'weird, but well-formed' ), + array( 'ab-x-abc-x-abc', 'anything goes after x' ), + array( 'ab-x-abc-a-a', 'anything goes after x, including several non-x singletons' ), + array( 'i-default', 'grandfathered' ), + array( 'abcd-Latn', 'Language of 4 chars reserved for future use' ), + array( 'AaBbCcDd-x-y-any-x', 'Language of 5-8 chars, registered' ), + array( 'de-CH-1901', 'with country and year' ), + array( 'en-US-x-twain', 'with country and singleton' ), + array( 'zh-cmn', 'three-letter variant' ), + array( 'zh-cmn-Hant', 'three-letter variant and script' ), + array( 'zh-cmn-Hant-HK', 'three-letter variant, script and country' ), + array( 'xr-p-lze', 'Extension' ), + ); + } + + /** + * Negative test for Language::isWellFormedLanguageTag() + * @dataProvider provideMalformedLanguageTags + * @covers Language::isWellFormedLanguageTag + */ + public function testMalformedLanguageTag( $code, $message = '' ) { + $this->assertFalse( + Language::isWellFormedLanguageTag( $code ), + "validating that code $code is a malformed language tag - $message" + ); + } + + /** + * The test cases are based on the tests in the GaBuZoMeu parser + * written by Stéphane Bortzmeyer <bortzmeyer@nic.fr> + * and distributed as free software, under the GNU General Public Licence. + * http://www.bortzmeyer.org/gabuzomeu-parsing-language-tags.html + */ + public static function provideMalformedLanguageTags() { + return array( + array( 'f', 'language too short' ), + array( 'f-Latn', 'language too short with script' ), + array( 'xr-lxs-qut', 'variants too short' ), # extlangS + array( 'fr-Latn-F', 'region too short' ), + array( 'a-value', 'language too short with region' ), + array( 'tlh-a-b-foo', 'valid three-letter with wrong variant' ), + array( + 'i-notexist', + 'grandfathered but not registered: invalid, even if we only test well-formedness' + ), + array( 'abcdefghi-012345678', 'numbers too long' ), + array( 'ab-abc-abc-abc-abc', 'invalid extensions' ), + array( 'ab-abcd-abc', 'invalid extensions' ), + array( 'ab-ab-abc', 'invalid extensions' ), + array( 'ab-123-abc', 'invalid extensions' ), + array( 'a-Hant-ZH', 'short language with valid extensions' ), + array( 'a1-Hant-ZH', 'invalid character in language' ), + array( 'ab-abcde-abc', 'invalid extensions' ), + array( 'ab-1abc-abc', 'invalid characters in extensions' ), + array( 'ab-ab-abcd', 'invalid order of extensions' ), + array( 'ab-123-abcd', 'invalid order of extensions' ), + array( 'ab-abcde-abcd', 'invalid extensions' ), + array( 'ab-1abc-abcd', 'invalid characters in extensions' ), + array( 'ab-a-b', 'extensions too short' ), + array( 'ab-a-x', 'extensions too short, even with singleton' ), + array( 'ab--ab', 'two separators' ), + array( 'ab-abc-', 'separator in the end' ), + array( '-ab-abc', 'separator in the beginning' ), + array( 'abcd-efg', 'language too long' ), + array( 'aabbccddE', 'tag too long' ), + array( 'pa_guru', 'A tag with underscore is invalid in strict mode' ), + array( 'de-f', 'subtag too short' ), + ); + } + + /** + * Negative test for Language::isWellFormedLanguageTag() + * @covers Language::isWellFormedLanguageTag + */ + public function testLenientLanguageTag() { + $this->assertTrue( + Language::isWellFormedLanguageTag( 'pa_guru', true ), + 'pa_guru is a well-formed language tag in lenient mode' + ); + } + + /** + * Test Language::isValidBuiltInCode() + * @dataProvider provideLanguageCodes + * @covers Language::isValidBuiltInCode + */ + public function testBuiltInCodeValidation( $code, $expected, $message = '' ) { + $this->assertEquals( $expected, + (bool)Language::isValidBuiltInCode( $code ), + "validating code $code $message" + ); + } + + public static function provideLanguageCodes() { + return array( + array( 'fr', true, 'Two letters, minor case' ), + array( 'EN', false, 'Two letters, upper case' ), + array( 'tyv', true, 'Three letters' ), + array( 'tokipona', true, 'long language code' ), + array( 'be-tarask', true, 'With dash' ), + array( 'be-x-old', true, 'With extension (two dashes)' ), + array( 'be_tarask', false, 'Reject underscores' ), + ); + } + + /** + * Test Language::isKnownLanguageTag() + * @dataProvider provideKnownLanguageTags + * @covers Language::isKnownLanguageTag + */ + public function testKnownLanguageTag( $code, $message = '' ) { + $this->assertTrue( + (bool)Language::isKnownLanguageTag( $code ), + "validating code $code - $message" + ); + } + + public static function provideKnownLanguageTags() { + return array( + array( 'fr', 'simple code' ), + array( 'bat-smg', 'an MW legacy tag' ), + array( 'sgs', 'an internal standard MW name, for which a legacy tag is used externally' ), + ); + } + + /** + * @covers Language::isKnownLanguageTag + */ + public function testKnownCldrLanguageTag() { + if ( !class_exists( 'LanguageNames' ) ) { + $this->markTestSkipped( 'The LanguageNames class is not available. ' + . 'The CLDR extension is probably not installed.' ); + } + + $this->assertTrue( + (bool)Language::isKnownLanguageTag( 'pal' ), + 'validating code "pal" an ancient language, which probably will ' + . 'not appear in Names.php, but appears in CLDR in English' + ); + } + + /** + * Negative tests for Language::isKnownLanguageTag() + * @dataProvider provideUnKnownLanguageTags + * @covers Language::isKnownLanguageTag + */ + public function testUnknownLanguageTag( $code, $message = '' ) { + $this->assertFalse( + (bool)Language::isKnownLanguageTag( $code ), + "checking that code $code is invalid - $message" + ); + } + + public static function provideUnknownLanguageTags() { + return array( + array( 'mw', 'non-existent two-letter code' ), + array( 'foo"<bar', 'very invalid language code' ), + ); + } + + /** + * Test too short timestamp + * @expectedException MWException + * @covers Language::sprintfDate + */ + public function testSprintfDateTooShortTimestamp() { + $this->getLang()->sprintfDate( 'xiY', '1234567890123' ); + } + + /** + * Test too long timestamp + * @expectedException MWException + * @covers Language::sprintfDate + */ + public function testSprintfDateTooLongTimestamp() { + $this->getLang()->sprintfDate( 'xiY', '123456789012345' ); + } + + /** + * Test too short timestamp + * @expectedException MWException + * @covers Language::sprintfDate + */ + public function testSprintfDateNotAllDigitTimestamp() { + $this->getLang()->sprintfDate( 'xiY', '-1234567890123' ); + } + + /** + * @dataProvider provideSprintfDateSamples + * @covers Language::sprintfDate + */ + public function testSprintfDate( $format, $ts, $expected, $msg ) { + $ttl = null; + $this->assertEquals( + $expected, + $this->getLang()->sprintfDate( $format, $ts, null, $ttl ), + "sprintfDate('$format', '$ts'): $msg" + ); + if ( $ttl ) { + $dt = new DateTime( $ts ); + $lastValidTS = $dt->add( new DateInterval( 'PT' . ( $ttl - 1 ) . 'S' ) )->format( 'YmdHis' ); + $this->assertEquals( + $expected, + $this->getLang()->sprintfDate( $format, $lastValidTS, null ), + "sprintfDate('$format', '$ts'): TTL $ttl too high (output was different at $lastValidTS)" + ); + } else { + // advance the time enough to make all of the possible outputs different (except possibly L) + $dt = new DateTime( $ts ); + $newTS = $dt->add( new DateInterval( 'P1Y1M8DT13H1M1S' ) )->format( 'YmdHis' ); + $this->assertEquals( + $expected, + $this->getLang()->sprintfDate( $format, $newTS, null ), + "sprintfDate('$format', '$ts'): Missing TTL (output was different at $newTS)" + ); + } + } + + /** + * sprintfDate should always use UTC when no zone is given. + * @dataProvider provideSprintfDateSamples + * @covers Language::sprintfDate + */ + public function testSprintfDateNoZone( $format, $ts, $expected, $ignore, $msg ) { + $oldTZ = date_default_timezone_get(); + $res = date_default_timezone_set( 'Asia/Seoul' ); + if ( !$res ) { + $this->markTestSkipped( "Error setting Timezone" ); + } + + $this->assertEquals( + $expected, + $this->getLang()->sprintfDate( $format, $ts ), + "sprintfDate('$format', '$ts'): $msg" + ); + + date_default_timezone_set( $oldTZ ); + } + + /** + * sprintfDate should use passed timezone + * @dataProvider provideSprintfDateSamples + * @covers Language::sprintfDate + */ + public function testSprintfDateTZ( $format, $ts, $ignore, $expected, $msg ) { + $tz = new DateTimeZone( 'Asia/Seoul' ); + if ( !$tz ) { + $this->markTestSkipped( "Error getting Timezone" ); + } + + $this->assertEquals( + $expected, + $this->getLang()->sprintfDate( $format, $ts, $tz ), + "sprintfDate('$format', '$ts', 'Asia/Seoul'): $msg" + ); + } + + public static function provideSprintfDateSamples() { + return array( + array( + 'xiY', + '20111212000000', + '1390', // note because we're testing English locale we get Latin-standard digits + '1390', + 'Iranian calendar full year' + ), + array( + 'xiy', + '20111212000000', + '90', + '90', + 'Iranian calendar short year' + ), + array( + 'o', + '20120101235000', + '2011', + '2011', + 'ISO 8601 (week) year' + ), + array( + 'W', + '20120101235000', + '52', + '52', + 'Week number' + ), + array( + 'W', + '20120102235000', + '1', + '1', + 'Week number' + ), + array( + 'o-\\WW-N', + '20091231235000', + '2009-W53-4', + '2009-W53-4', + 'leap week' + ), + // What follows is mostly copied from + // https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions#.23time + array( + 'Y', + '20120102090705', + '2012', + '2012', + 'Full year' + ), + array( + 'y', + '20120102090705', + '12', + '12', + '2 digit year' + ), + array( + 'L', + '20120102090705', + '1', + '1', + 'Leap year' + ), + array( + 'n', + '20120102090705', + '1', + '1', + 'Month index, not zero pad' + ), + array( + 'N', + '20120102090705', + '01', + '01', + 'Month index. Zero pad' + ), + array( + 'M', + '20120102090705', + 'Jan', + 'Jan', + 'Month abbrev' + ), + array( + 'F', + '20120102090705', + 'January', + 'January', + 'Full month' + ), + array( + 'xg', + '20120102090705', + 'January', + 'January', + 'Genitive month name (same in EN)' + ), + array( + 'j', + '20120102090705', + '2', + '2', + 'Day of month (not zero pad)' + ), + array( + 'd', + '20120102090705', + '02', + '02', + 'Day of month (zero-pad)' + ), + array( + 'z', + '20120102090705', + '1', + '1', + 'Day of year (zero-indexed)' + ), + array( + 'D', + '20120102090705', + 'Mon', + 'Mon', + 'Day of week (abbrev)' + ), + array( + 'l', + '20120102090705', + 'Monday', + 'Monday', + 'Full day of week' + ), + array( + 'N', + '20120101090705', + '7', + '7', + 'Day of week (Mon=1, Sun=7)' + ), + array( + 'w', + '20120101090705', + '0', + '0', + 'Day of week (Sun=0, Sat=6)' + ), + array( + 'N', + '20120102090705', + '1', + '1', + 'Day of week' + ), + array( + 'a', + '20120102090705', + 'am', + 'am', + 'am vs pm' + ), + array( + 'A', + '20120102120000', + 'PM', + 'PM', + 'AM vs PM' + ), + array( + 'a', + '20120102000000', + 'am', + 'am', + 'AM vs PM' + ), + array( + 'g', + '20120102090705', + '9', + '9', + '12 hour, not Zero' + ), + array( + 'h', + '20120102090705', + '09', + '09', + '12 hour, zero padded' + ), + array( + 'G', + '20120102090705', + '9', + '9', + '24 hour, not zero' + ), + array( + 'H', + '20120102090705', + '09', + '09', + '24 hour, zero' + ), + array( + 'H', + '20120102110705', + '11', + '11', + '24 hour, zero' + ), + array( + 'i', + '20120102090705', + '07', + '07', + 'Minutes' + ), + array( + 's', + '20120102090705', + '05', + '05', + 'seconds' + ), + array( + 'U', + '20120102090705', + '1325495225', + '1325462825', + 'unix time' + ), + array( + 't', + '20120102090705', + '31', + '31', + 'Days in current month' + ), + array( + 'c', + '20120102090705', + '2012-01-02T09:07:05+00:00', + '2012-01-02T09:07:05+09:00', + 'ISO 8601 timestamp' + ), + array( + 'r', + '20120102090705', + 'Mon, 02 Jan 2012 09:07:05 +0000', + 'Mon, 02 Jan 2012 09:07:05 +0900', + 'RFC 5322' + ), + array( + 'e', + '20120102090705', + 'UTC', + 'Asia/Seoul', + 'Timezone identifier' + ), + array( + 'I', + '19880602090705', + '0', + '1', + 'DST indicator' + ), + array( + 'O', + '20120102090705', + '+0000', + '+0900', + 'Timezone offset' + ), + array( + 'P', + '20120102090705', + '+00:00', + '+09:00', + 'Timezone offset with colon' + ), + array( + 'T', + '20120102090705', + 'UTC', + 'KST', + 'Timezone abbreviation' + ), + array( + 'Z', + '20120102090705', + '0', + '32400', + 'Timezone offset in seconds' + ), + array( + 'xmj xmF xmn xmY', + '20120102090705', + '7 Safar 2 1433', + '7 Safar 2 1433', + 'Islamic' + ), + array( + 'xij xiF xin xiY', + '20120102090705', + '12 Dey 10 1390', + '12 Dey 10 1390', + 'Iranian' + ), + array( + 'xjj xjF xjn xjY', + '20120102090705', + '7 Tevet 4 5772', + '7 Tevet 4 5772', + 'Hebrew' + ), + array( + 'xjt', + '20120102090705', + '29', + '29', + 'Hebrew number of days in month' + ), + array( + 'xjx', + '20120102090705', + 'Tevet', + 'Tevet', + 'Hebrew genitive month name (No difference in EN)' + ), + array( + 'xkY', + '20120102090705', + '2555', + '2555', + 'Thai year' + ), + array( + 'xoY', + '20120102090705', + '101', + '101', + 'Minguo' + ), + array( + 'xtY', + '20120102090705', + '平成24', + '平成24', + 'nengo' + ), + array( + 'xrxkYY', + '20120102090705', + 'MMDLV2012', + 'MMDLV2012', + 'Roman numerals' + ), + array( + 'xhxjYY', + '20120102090705', + 'ה\'תשע"ב2012', + 'ה\'תשע"ב2012', + 'Hebrew numberals' + ), + array( + 'xnY', + '20120102090705', + '2012', + '2012', + 'Raw numerals (doesn\'t mean much in EN)' + ), + array( + '[[Y "(yea"\\r)]] \\"xx\\"', + '20120102090705', + '[[2012 (year)]] "x"', + '[[2012 (year)]] "x"', + 'Various escaping' + ), + + ); + } + + /** + * @dataProvider provideFormatSizes + * @covers Language::formatSize + */ + public function testFormatSize( $size, $expected, $msg ) { + $this->assertEquals( + $expected, + $this->getLang()->formatSize( $size ), + "formatSize('$size'): $msg" + ); + } + + public static function provideFormatSizes() { + return array( + array( + 0, + "0 B", + "Zero bytes" + ), + array( + 1024, + "1 KB", + "1 kilobyte" + ), + array( + 1024 * 1024, + "1 MB", + "1,024 megabytes" + ), + array( + 1024 * 1024 * 1024, + "1 GB", + "1 gigabytes" + ), + array( + pow( 1024, 4 ), + "1 TB", + "1 terabyte" + ), + array( + pow( 1024, 5 ), + "1 PB", + "1 petabyte" + ), + array( + pow( 1024, 6 ), + "1 EB", + "1,024 exabyte" + ), + array( + pow( 1024, 7 ), + "1 ZB", + "1 zetabyte" + ), + array( + pow( 1024, 8 ), + "1 YB", + "1 yottabyte" + ), + // How big!? THIS BIG! + ); + } + + /** + * @dataProvider provideFormatBitrate + * @covers Language::formatBitrate + */ + public function testFormatBitrate( $bps, $expected, $msg ) { + $this->assertEquals( + $expected, + $this->getLang()->formatBitrate( $bps ), + "formatBitrate('$bps'): $msg" + ); + } + + public static function provideFormatBitrate() { + return array( + array( + 0, + "0 bps", + "0 bits per second" + ), + array( + 999, + "999 bps", + "999 bits per second" + ), + array( + 1000, + "1 kbps", + "1 kilobit per second" + ), + array( + 1000 * 1000, + "1 Mbps", + "1 megabit per second" + ), + array( + pow( 10, 9 ), + "1 Gbps", + "1 gigabit per second" + ), + array( + pow( 10, 12 ), + "1 Tbps", + "1 terabit per second" + ), + array( + pow( 10, 15 ), + "1 Pbps", + "1 petabit per second" + ), + array( + pow( 10, 18 ), + "1 Ebps", + "1 exabit per second" + ), + array( + pow( 10, 21 ), + "1 Zbps", + "1 zetabit per second" + ), + array( + pow( 10, 24 ), + "1 Ybps", + "1 yottabit per second" + ), + array( + pow( 10, 27 ), + "1,000 Ybps", + "1,000 yottabits per second" + ), + ); + } + + /** + * @dataProvider provideFormatDuration + * @covers Language::formatDuration + */ + public function testFormatDuration( $duration, $expected, $intervals = array() ) { + $this->assertEquals( + $expected, + $this->getLang()->formatDuration( $duration, $intervals ), + "formatDuration('$duration'): $expected" + ); + } + + public static function provideFormatDuration() { + return array( + array( + 0, + '0 seconds', + ), + array( + 1, + '1 second', + ), + array( + 2, + '2 seconds', + ), + array( + 60, + '1 minute', + ), + array( + 2 * 60, + '2 minutes', + ), + array( + 3600, + '1 hour', + ), + array( + 2 * 3600, + '2 hours', + ), + array( + 24 * 3600, + '1 day', + ), + array( + 2 * 86400, + '2 days', + ), + array( + // ( 365 + ( 24 * 3 + 25 ) / 400 ) * 86400 = 31556952 + ( 365 + ( 24 * 3 + 25 ) / 400.0 ) * 86400, + '1 year', + ), + array( + 2 * 31556952, + '2 years', + ), + array( + 10 * 31556952, + '1 decade', + ), + array( + 20 * 31556952, + '2 decades', + ), + array( + 100 * 31556952, + '1 century', + ), + array( + 200 * 31556952, + '2 centuries', + ), + array( + 1000 * 31556952, + '1 millennium', + ), + array( + 2000 * 31556952, + '2 millennia', + ), + array( + 9001, + '2 hours, 30 minutes and 1 second' + ), + array( + 3601, + '1 hour and 1 second' + ), + array( + 31556952 + 2 * 86400 + 9000, + '1 year, 2 days, 2 hours and 30 minutes' + ), + array( + 42 * 1000 * 31556952 + 42, + '42 millennia and 42 seconds' + ), + array( + 60, + '60 seconds', + array( 'seconds' ), + ), + array( + 61, + '61 seconds', + array( 'seconds' ), + ), + array( + 1, + '1 second', + array( 'seconds' ), + ), + array( + 31556952 + 2 * 86400 + 9000, + '1 year, 2 days and 150 minutes', + array( 'years', 'days', 'minutes' ), + ), + array( + 42, + '0 days', + array( 'years', 'days' ), + ), + array( + 31556952 + 2 * 86400 + 9000, + '1 year, 2 days and 150 minutes', + array( 'minutes', 'days', 'years' ), + ), + array( + 42, + '0 days', + array( 'days', 'years' ), + ), + ); + } + + /** + * @dataProvider provideCheckTitleEncodingData + * @covers Language::checkTitleEncoding + */ + public function testCheckTitleEncoding( $s ) { + $this->assertEquals( + $s, + $this->getLang()->checkTitleEncoding( $s ), + "checkTitleEncoding('$s')" + ); + } + + public static function provideCheckTitleEncodingData() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + return array( + array( "" ), + array( "United States of America" ), // 7bit ASCII + array( rawurldecode( "S%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e" ) ), + array( + rawurldecode( + "Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn" + ) + ), + // The following two data sets come from bug 36839. They fail if checkTitleEncoding uses a regexp to test for + // valid UTF-8 encoding and the pcre.recursion_limit is low (like, say, 1024). They succeed if checkTitleEncoding + // uses mb_check_encoding for its test. + array( + rawurldecode( + "Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn%7C" + . "Catherine%20Willows%7CDavid%20Hodges%7CDavid%20Phillips%7CGil%20Grissom%7CGreg%20Sanders%7CHodges%7C" + . "Internet%20Movie%20Database%7CJim%20Brass%7CLady%20Heather%7C" + . "Les%20Experts%20(s%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e)%7CLes%20Experts%20:%20Manhattan%7C" + . "Les%20Experts%20:%20Miami%7CListe%20des%20personnages%20des%20Experts%7C" + . "Liste%20des%20%C3%A9pisodes%20des%20Experts%7CMod%C3%A8le%20discussion:Palette%20Les%20Experts%7C" + . "Nick%20Stokes%7CPersonnage%20de%20fiction%7CPersonnage%20fictif%7CPersonnage%20de%20fiction%7C" + . "Personnages%20r%C3%A9currents%20dans%20Les%20Experts%7CRaymond%20Langston%7CRiley%20Adams%7C" + . "Saison%201%20des%20Experts%7CSaison%2010%20des%20Experts%7CSaison%2011%20des%20Experts%7C" + . "Saison%2012%20des%20Experts%7CSaison%202%20des%20Experts%7CSaison%203%20des%20Experts%7C" + . "Saison%204%20des%20Experts%7CSaison%205%20des%20Experts%7CSaison%206%20des%20Experts%7C" + . "Saison%207%20des%20Experts%7CSaison%208%20des%20Experts%7CSaison%209%20des%20Experts%7C" + . "Sara%20Sidle%7CSofia%20Curtis%7CS%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e%7CWallace%20Langham%7C" + . "Warrick%20Brown%7CWendy%20Simms%7C%C3%89tats-Unis" + ), + ), + array( + rawurldecode( + "Mod%C3%A8le%3AArrondissements%20homonymes%7CMod%C3%A8le%3ABandeau%20standard%20pour%20page%20d'homonymie%7C" + . "Mod%C3%A8le%3ABatailles%20homonymes%7CMod%C3%A8le%3ACantons%20homonymes%7C" + . "Mod%C3%A8le%3ACommunes%20fran%C3%A7aises%20homonymes%7CMod%C3%A8le%3AFilms%20homonymes%7C" + . "Mod%C3%A8le%3AGouvernements%20homonymes%7CMod%C3%A8le%3AGuerres%20homonymes%7CMod%C3%A8le%3AHomonymie%7C" + . "Mod%C3%A8le%3AHomonymie%20bateau%7CMod%C3%A8le%3AHomonymie%20d'%C3%A9tablissements%20scolaires%20ou" + . "%20universitaires%7CMod%C3%A8le%3AHomonymie%20d'%C3%AEles%7CMod%C3%A8le%3AHomonymie%20de%20clubs%20sportifs%7C" + . "Mod%C3%A8le%3AHomonymie%20de%20comt%C3%A9s%7CMod%C3%A8le%3AHomonymie%20de%20monument%7C" + . "Mod%C3%A8le%3AHomonymie%20de%20nom%20romain%7CMod%C3%A8le%3AHomonymie%20de%20parti%20politique%7C" + . "Mod%C3%A8le%3AHomonymie%20de%20route%7CMod%C3%A8le%3AHomonymie%20dynastique%7C" + . "Mod%C3%A8le%3AHomonymie%20vid%C3%A9oludique%7CMod%C3%A8le%3AHomonymie%20%C3%A9difice%20religieux%7C" + . "Mod%C3%A8le%3AInternationalisation%7CMod%C3%A8le%3AIsom%C3%A9rie%7CMod%C3%A8le%3AParonymie%7C" + . "Mod%C3%A8le%3APatronyme%7CMod%C3%A8le%3APatronyme%20basque%7CMod%C3%A8le%3APatronyme%20italien%7C" + . "Mod%C3%A8le%3APatronymie%7CMod%C3%A8le%3APersonnes%20homonymes%7CMod%C3%A8le%3ASaints%20homonymes%7C" + . "Mod%C3%A8le%3ATitres%20homonymes%7CMod%C3%A8le%3AToponymie%7CMod%C3%A8le%3AUnit%C3%A9s%20homonymes%7C" + . "Mod%C3%A8le%3AVilles%20homonymes%7CMod%C3%A8le%3A%C3%89difices%20religieux%20homonymes" + ) + ) + ); + // @codingStandardsIgnoreEnd + } + + /** + * @dataProvider provideRomanNumeralsData + * @covers Language::romanNumeral + */ + public function testRomanNumerals( $num, $numerals ) { + $this->assertEquals( + $numerals, + Language::romanNumeral( $num ), + "romanNumeral('$num')" + ); + } + + public static function provideRomanNumeralsData() { + return array( + array( 1, 'I' ), + array( 2, 'II' ), + array( 3, 'III' ), + array( 4, 'IV' ), + array( 5, 'V' ), + array( 6, 'VI' ), + array( 7, 'VII' ), + array( 8, 'VIII' ), + array( 9, 'IX' ), + array( 10, 'X' ), + array( 20, 'XX' ), + array( 30, 'XXX' ), + array( 40, 'XL' ), + array( 49, 'XLIX' ), + array( 50, 'L' ), + array( 60, 'LX' ), + array( 70, 'LXX' ), + array( 80, 'LXXX' ), + array( 90, 'XC' ), + array( 99, 'XCIX' ), + array( 100, 'C' ), + array( 200, 'CC' ), + array( 300, 'CCC' ), + array( 400, 'CD' ), + array( 500, 'D' ), + array( 600, 'DC' ), + array( 700, 'DCC' ), + array( 800, 'DCCC' ), + array( 900, 'CM' ), + array( 999, 'CMXCIX' ), + array( 1000, 'M' ), + array( 1989, 'MCMLXXXIX' ), + array( 2000, 'MM' ), + array( 3000, 'MMM' ), + array( 4000, 'MMMM' ), + array( 5000, 'MMMMM' ), + array( 6000, 'MMMMMM' ), + array( 7000, 'MMMMMMM' ), + array( 8000, 'MMMMMMMM' ), + array( 9000, 'MMMMMMMMM' ), + array( 9999, 'MMMMMMMMMCMXCIX' ), + array( 10000, 'MMMMMMMMMM' ), + ); + } + + /** + * @dataProvider providePluralData + * @covers Language::convertPlural + */ + public function testConvertPlural( $expected, $number, $forms ) { + $chosen = $this->getLang()->convertPlural( $number, $forms ); + $this->assertEquals( $expected, $chosen ); + } + + public static function providePluralData() { + // Params are: [expected text, number given, [the plural forms]] + return array( + array( 'plural', 0, array( + 'singular', 'plural' + ) ), + array( 'explicit zero', 0, array( + '0=explicit zero', 'singular', 'plural' + ) ), + array( 'explicit one', 1, array( + 'singular', 'plural', '1=explicit one', + ) ), + array( 'singular', 1, array( + 'singular', 'plural', '0=explicit zero', + ) ), + array( 'plural', 3, array( + '0=explicit zero', '1=explicit one', 'singular', 'plural' + ) ), + array( 'explicit eleven', 11, array( + 'singular', 'plural', '11=explicit eleven', + ) ), + array( 'plural', 12, array( + 'singular', 'plural', '11=explicit twelve', + ) ), + array( 'plural', 12, array( + 'singular', 'plural', '=explicit form', + ) ), + array( 'other', 2, array( + 'kissa=kala', '1=2=3', 'other', + ) ), + array( '', 2, array( + '0=explicit zero', '1=explicit one', + ) ), + ); + } + + /** + * @covers Language::translateBlockExpiry() + * @dataProvider provideTranslateBlockExpiry + */ + public function testTranslateBlockExpiry( $expectedData, $str, $desc ) { + $lang = $this->getLang(); + if ( is_array( $expectedData ) ) { + list( $func, $arg ) = $expectedData; + $expected = $lang->$func( $arg ); + } else { + $expected = $expectedData; + } + $this->assertEquals( $expected, $lang->translateBlockExpiry( $str ), $desc ); + } + + public static function provideTranslateBlockExpiry() { + return array( + array( '2 hours', '2 hours', 'simple data from ipboptions' ), + array( 'indefinite', 'infinite', 'infinite from ipboptions' ), + array( 'indefinite', 'infinity', 'alternative infinite from ipboptions' ), + array( 'indefinite', 'indefinite', 'another alternative infinite from ipboptions' ), + array( array( 'formatDuration', 1023 * 60 * 60 ), '1023 hours', 'relative' ), + array( array( 'formatDuration', -1023 ), '-1023 seconds', 'negative relative' ), + array( array( 'formatDuration', 0 ), 'now', 'now' ), + array( + array( 'timeanddate', '20120102070000' ), + '2012-1-1 7:00 +1 day', + 'mixed, handled as absolute' + ), + array( array( 'timeanddate', '19910203040506' ), '1991-2-3 4:05:06', 'absolute' ), + array( array( 'timeanddate', '19700101000000' ), '1970-1-1 0:00:00', 'absolute at epoch' ), + array( array( 'timeanddate', '19691231235959' ), '1969-12-31 23:59:59', 'time before epoch' ), + array( 'dummy', 'dummy', 'return garbage as is' ), + ); + } + + /** + * @dataProvider parseFormattedNumberProvider + */ + public function testParseFormattedNumber( $langCode, $number ) { + $lang = Language::factory( $langCode ); + + $localisedNum = $lang->formatNum( $number ); + $normalisedNum = $lang->parseFormattedNumber( $localisedNum ); + + $this->assertEquals( $number, $normalisedNum ); + } + + public function parseFormattedNumberProvider() { + return array( + array( 'de', 377.01 ), + array( 'fa', 334 ), + array( 'fa', 382.772 ), + array( 'ar', 1844 ), + array( 'lzh', 3731 ), + array( 'zh-classical', 7432 ) + ); + } + + /** + * @covers Language::commafy() + * @dataProvider provideCommafyData + */ + public function testCommafy( $number, $numbersWithCommas ) { + $this->assertEquals( + $numbersWithCommas, + $this->getLang()->commafy( $number ), + "commafy('$number')" + ); + } + + public static function provideCommafyData() { + return array( + array( -1, '-1' ), + array( 10, '10' ), + array( 100, '100' ), + array( 1000, '1,000' ), + array( 10000, '10,000' ), + array( 100000, '100,000' ), + array( 1000000, '1,000,000' ), + array( -1.0001, '-1.0001' ), + array( 1.0001, '1.0001' ), + array( 10.0001, '10.0001' ), + array( 100.0001, '100.0001' ), + array( 1000.0001, '1,000.0001' ), + array( 10000.0001, '10,000.0001' ), + array( 100000.0001, '100,000.0001' ), + array( 1000000.0001, '1,000,000.0001' ), + array( '200000000000000000000', '200,000,000,000,000,000,000' ), + array( '-200000000000000000000', '-200,000,000,000,000,000,000' ), + ); + } + + /** + * @covers Language::listToText + */ + public function testListToText() { + $lang = $this->getLang(); + $and = $lang->getMessageFromDB( 'and' ); + $s = $lang->getMessageFromDB( 'word-separator' ); + $c = $lang->getMessageFromDB( 'comma-separator' ); + + $this->assertEquals( '', $lang->listToText( array() ) ); + $this->assertEquals( 'a', $lang->listToText( array( 'a' ) ) ); + $this->assertEquals( "a{$and}{$s}b", $lang->listToText( array( 'a', 'b' ) ) ); + $this->assertEquals( "a{$c}b{$and}{$s}c", $lang->listToText( array( 'a', 'b', 'c' ) ) ); + $this->assertEquals( "a{$c}b{$c}c{$and}{$s}d", $lang->listToText( array( 'a', 'b', 'c', 'd' ) ) ); + } + + /** + * @dataProvider provideIsSupportedLanguage + * @covers Language::isSupportedLanguage + */ + public function testIsSupportedLanguage( $code, $expected, $comment ) { + $this->assertEquals( $expected, Language::isSupportedLanguage( $code ), $comment ); + } + + public static function provideIsSupportedLanguage() { + return array( + array( 'en', true, 'is supported language' ), + array( 'fi', true, 'is supported language' ), + array( 'bunny', false, 'is not supported language' ), + array( 'FI', false, 'is not supported language, input should be in lower case' ), + ); + } + + /** + * @dataProvider provideGetParentLanguage + * @covers Language::getParentLanguage + */ + public function testGetParentLanguage( $code, $expected, $comment ) { + $lang = Language::factory( $code ); + if ( is_null( $expected ) ) { + $this->assertNull( $lang->getParentLanguage(), $comment ); + } else { + $this->assertEquals( $expected, $lang->getParentLanguage()->getCode(), $comment ); + } + } + + public static function provideGetParentLanguage() { + return array( + array( 'zh-cn', 'zh', 'zh is the parent language of zh-cn' ), + array( 'zh', 'zh', 'zh is defined as the parent language of zh, ' + . 'because zh converter can convert zh-cn to zh' ), + array( 'zh-invalid', null, 'do not be fooled by arbitrarily composed language codes' ), + array( 'en-gb', null, 'en does not have converter' ), + array( 'en', null, 'en does not have converter. Although FakeConverter ' + . 'handles en -> en conversion but it is useless' ), + ); + } + + /** + * @dataProvider provideGetNamespaceAliases + * @covers Language::getNamespaceAliases + */ + public function testGetNamespaceAliases( $languageCode, $subset ) { + $language = Language::factory( $languageCode ); + $aliases = $language->getNamespaceAliases(); + foreach ( $subset as $alias => $nsId ) { + $this->assertEquals( $nsId, $aliases[$alias] ); + } + } + + public static function provideGetNamespaceAliases() { + // TODO: Add tests for NS_PROJECT_TALK and GenderNamespaces + return array( + array( + 'zh', + array( + '文件' => NS_FILE, + '檔案' => NS_FILE, + ), + ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageTiTest.php b/tests/phpunit/languages/LanguageTiTest.php new file mode 100644 index 00000000..e225af97 --- /dev/null +++ b/tests/phpunit/languages/LanguageTiTest.php @@ -0,0 +1,34 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageTi.php */ +class LanguageTiTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageTlTest.php b/tests/phpunit/languages/LanguageTlTest.php new file mode 100644 index 00000000..7ac51c69 --- /dev/null +++ b/tests/phpunit/languages/LanguageTlTest.php @@ -0,0 +1,34 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageTl.php */ +class LanguageTlTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageTrTest.php b/tests/phpunit/languages/LanguageTrTest.php new file mode 100644 index 00000000..2c9905f7 --- /dev/null +++ b/tests/phpunit/languages/LanguageTrTest.php @@ -0,0 +1,61 @@ +<?php +/** + * @author Antoine Musso + * @copyright Copyright © 2011, Antoine Musso + * @file + */ + +/** Tests for MediaWiki languages/LanguageTr.php */ +class LanguageTrTest extends LanguageClassesTestCase { + + /** + * See @bug 28040 + * Credits to irc://irc.freenode.net/wikipedia-tr users: + * - berm + * - []LuCkY[] + * - Emperyan + * @see http://en.wikipedia.org/wiki/Dotted_and_dotless_I + * @dataProvider provideDottedAndDotlessI + * @covers Language::ucfirst + * @covers Language::lcfirst + */ + public function testDottedAndDotlessI( $func, $input, $inputCase, $expected ) { + if ( $func == 'ucfirst' ) { + $res = $this->getLang()->ucfirst( $input ); + } elseif ( $func == 'lcfirst' ) { + $res = $this->getLang()->lcfirst( $input ); + } else { + throw new MWException( __METHOD__ . " given an invalid function name '$func'" ); + } + + $msg = "Converting $inputCase case '$input' with $func should give '$expected'"; + + $this->assertEquals( $expected, $res, $msg ); + } + + public static function provideDottedAndDotlessI() { + return array( + # function, input, input case, expected + # Case changed: + array( 'ucfirst', 'ı', 'lower', 'I' ), + array( 'ucfirst', 'i', 'lower', 'İ' ), + array( 'lcfirst', 'I', 'upper', 'ı' ), + array( 'lcfirst', 'İ', 'upper', 'i' ), + + # Already using the correct case + array( 'ucfirst', 'I', 'upper', 'I' ), + array( 'ucfirst', 'İ', 'upper', 'İ' ), + array( 'lcfirst', 'ı', 'lower', 'ı' ), + array( 'lcfirst', 'i', 'lower', 'i' ), + + # A real example taken from bug 28040 using + # http://tr.wikipedia.org/wiki/%C4%B0Phone + array( 'lcfirst', 'iPhone', 'lower', 'iPhone' ), + + # next case is valid in Turkish but are different words if we + # consider IPhone is English! + array( 'lcfirst', 'IPhone', 'upper', 'ıPhone' ), + + ); + } +} diff --git a/tests/phpunit/languages/LanguageUkTest.php b/tests/phpunit/languages/LanguageUkTest.php new file mode 100644 index 00000000..9051bcff --- /dev/null +++ b/tests/phpunit/languages/LanguageUkTest.php @@ -0,0 +1,72 @@ +<?php +/** + * @author Amir E. Aharoni + * based on LanguageBe_tarask.php + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for Ukrainian */ +class LanguageUkTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'few', 'many', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * Test explicit plural forms - n=FormN forms + * @covers Language::convertPlural + */ + public function testExplicitPlural() { + $forms = array( 'one', 'few', 'many', 'other', '12=dozen' ); + $this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) ); + $forms = array( 'one', 'few', 'many', '100=hundred', 'other', '12=dozen' ); + $this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 1 ), + array( 'many', 11 ), + array( 'one', 91 ), + array( 'one', 121 ), + array( 'few', 2 ), + array( 'few', 3 ), + array( 'few', 4 ), + array( 'few', 334 ), + array( 'many', 5 ), + array( 'many', 15 ), + array( 'many', 120 ), + ); + } + + /** + * @dataProvider providePluralTwoForms + * @covers Language::convertPlural + */ + public function testPluralTwoForms( $result, $value ) { + $forms = array( '1=one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + public static function providePluralTwoForms() { + return array( + array( 'one', 1 ), + array( 'other', 11 ), + array( 'other', 91 ), + array( 'other', 121 ), + ); + } +} diff --git a/tests/phpunit/languages/LanguageUzTest.php b/tests/phpunit/languages/LanguageUzTest.php new file mode 100644 index 00000000..4881103f --- /dev/null +++ b/tests/phpunit/languages/LanguageUzTest.php @@ -0,0 +1,124 @@ +<?php +/** + * PHPUnit tests for the Uzbek language. + * The language can be represented using two scripts: + * - Latin (uz-latn) + * - Cyrillic (uz-cyrl) + * + * @author Robin Pepermans + * @author Antoine Musso <hashar at free dot fr> + * @copyright Copyright © 2012, Robin Pepermans + * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr> + * @file + * + * @todo methods in test class should be tidied: + * - Should be split into separate test methods and data providers + * - Tests for LanguageConverter and Language should probably be separate.. + */ + +/** Tests for MediaWiki languages/LanguageUz.php */ +class LanguageUzTest extends LanguageClassesTestCase { + + /** + * @author Nikola Smolenski + * @covers LanguageConverter::convertTo + */ + public function testConversionToCyrillic() { + // A convertion of Latin to Cyrillic + $this->assertEquals( 'абвгғ', + $this->convertToCyrillic( 'abvggʻ' ) + ); + // Same as above, but assert that -{}-s must be removed and not converted + $this->assertEquals( 'ljабnjвгўоdb', + $this->convertToCyrillic( '-{lj}-ab-{nj}-vgoʻo-{db}-' ) + ); + // A simple convertion of Cyrillic to Cyrillic + $this->assertEquals( 'абвг', + $this->convertToCyrillic( 'абвг' ) + ); + // Same as above, but assert that -{}-s must be removed and not converted + $this->assertEquals( 'ljабnjвгdaž', + $this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{da}-ž' ) + ); + } + + /** + * @covers LanguageConverter::convertTo + */ + public function testConversionToLatin() { + // A simple convertion of Latin to Latin + $this->assertEquals( 'abdef', + $this->convertToLatin( 'abdef' ) + ); + // A convertion of Cyrillic to Latin + $this->assertEquals( 'gʻabtsdOʻQyo', + $this->convertToLatin( 'ғабцдЎҚё' ) + ); + } + + ##### HELPERS ##################################################### + /** + * Wrapper to verify text stay the same after applying conversion + * @param string $text Text to convert + * @param string $variant Language variant 'uz-cyrl' or 'uz-latn' + * @param string $msg Optional message + */ + protected function assertUnConverted( $text, $variant, $msg = '' ) { + $this->assertEquals( + $text, + $this->convertTo( $text, $variant ), + $msg + ); + } + + /** + * Wrapper to verify a text is different once converted to a variant. + * @param string $text Text to convert + * @param string $variant Language variant 'uz-cyrl' or 'uz-latn' + * @param string $msg Optional message + */ + protected function assertConverted( $text, $variant, $msg = '' ) { + $this->assertNotEquals( + $text, + $this->convertTo( $text, $variant ), + $msg + ); + } + + /** + * Verifiy the given Cyrillic text is not converted when using + * using the cyrillic variant and converted to Latin when using + * the Latin variant. + * @param string $text Text to convert + * @param string $msg Optional message + */ + protected function assertCyrillic( $text, $msg = '' ) { + $this->assertUnConverted( $text, 'uz-cyrl', $msg ); + $this->assertConverted( $text, 'uz-latn', $msg ); + } + + /** + * Verifiy the given Latin text is not converted when using + * using the Latin variant and converted to Cyrillic when using + * the Cyrillic variant. + * @param string $text Text to convert + * @param string $msg Optional message + */ + protected function assertLatin( $text, $msg = '' ) { + $this->assertUnConverted( $text, 'uz-latn', $msg ); + $this->assertConverted( $text, 'uz-cyrl', $msg ); + } + + /** Wrapper for converter::convertTo() method*/ + protected function convertTo( $text, $variant ) { + return $this->getLang()->mConverter->convertTo( $text, $variant ); + } + + protected function convertToCyrillic( $text ) { + return $this->convertTo( $text, 'uz-cyrl' ); + } + + protected function convertToLatin( $text ) { + return $this->convertTo( $text, 'uz-latn' ); + } +} diff --git a/tests/phpunit/languages/LanguageWaTest.php b/tests/phpunit/languages/LanguageWaTest.php new file mode 100644 index 00000000..d05196c0 --- /dev/null +++ b/tests/phpunit/languages/LanguageWaTest.php @@ -0,0 +1,34 @@ +<?php +/** + * @author Amir E. Aharoni + * @copyright Copyright © 2012, Amir E. Aharoni + * @file + */ + +/** Tests for MediaWiki languages/classes/LanguageWa.php */ +class LanguageWaTest extends LanguageClassesTestCase { + /** + * @dataProvider providePlural + * @covers Language::convertPlural + */ + public function testPlural( $result, $value ) { + $forms = array( 'one', 'other' ); + $this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) ); + } + + /** + * @dataProvider providePlural + * @covers Language::getPluralRuleType + */ + public function testGetPluralRuleType( $result, $value ) { + $this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) ); + } + + public static function providePlural() { + return array( + array( 'one', 0 ), + array( 'one', 1 ), + array( 'other', 2 ), + ); + } +} diff --git a/tests/phpunit/languages/SpecialPageAliasTest.php b/tests/phpunit/languages/SpecialPageAliasTest.php new file mode 100644 index 00000000..f6d6bc96 --- /dev/null +++ b/tests/phpunit/languages/SpecialPageAliasTest.php @@ -0,0 +1,63 @@ +<?php + +/** + * Verifies that special page aliases are valid, with no slashes. + * + * @group Language + * @group SpecialPageAliases + * @group SystemTest + * @group medium + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class SpecialPageAliasTest extends MediaWikiTestCase { + + /** + * @dataProvider validSpecialPageAliasesProvider + */ + public function testValidSpecialPageAliases( $code, $specialPageAliases ) { + foreach ( $specialPageAliases as $specialPage => $aliases ) { + foreach ( $aliases as $alias ) { + $msg = "$specialPage alias '$alias' in $code is valid with no slashes"; + $this->assertRegExp( '/^[^\/]*$/', $msg ); + } + } + } + + public function validSpecialPageAliasesProvider() { + $codes = array_keys( Language::fetchLanguageNames( 'mwfile' ) ); + + $data = array(); + + foreach ( $codes as $code ) { + $specialPageAliases = $this->getSpecialPageAliases( $code ); + + if ( $specialPageAliases !== array() ) { + $data[] = array( $code, $specialPageAliases ); + } + } + + return $data; + } + + /** + * @param string $code + * + * @return array + */ + protected function getSpecialPageAliases( $code ) { + $file = Language::getMessagesFileName( $code ); + + if ( is_readable( $file ) ) { + include $file; + + if ( isset( $specialPageAliases ) && $specialPageAliases !== null ) { + return $specialPageAliases; + } + } + + return array(); + } + +} diff --git a/tests/phpunit/languages/utils/CLDRPluralRuleEvaluatorTest.php b/tests/phpunit/languages/utils/CLDRPluralRuleEvaluatorTest.php new file mode 100644 index 00000000..8e3b1145 --- /dev/null +++ b/tests/phpunit/languages/utils/CLDRPluralRuleEvaluatorTest.php @@ -0,0 +1,151 @@ +<?php +/** + * @author Niklas Laxström + * @file + */ + +/** + * @covers CLDRPluralRuleEvaluator + */ +class CLDRPluralRuleEvaluatorTest extends MediaWikiTestCase { + /** + * @dataProvider validTestCases + */ + function testValidRules( $expected, $rules, $number, $comment ) { + $result = CLDRPluralRuleEvaluator::evaluate( $number, (array)$rules ); + $this->assertEquals( $expected, $result, $comment ); + } + + /** + * @dataProvider invalidTestCases + * @expectedException CLDRPluralRuleError + */ + function testInvalidRules( $rules, $comment ) { + CLDRPluralRuleEvaluator::evaluate( 1, (array)$rules ); + } + + function validTestCases() { + $tests = array( + # expected, rule, number, comment + array( 0, 'n is 1', 1, 'integer number and is' ), + array( 0, 'n is 1', "1", 'string integer number and is' ), + array( 0, 'n is 1', 1.0, 'float number and is' ), + array( 0, 'n is 1', "1.0", 'string float number and is' ), + array( 1, 'n is 1', 1.1, 'float number and is' ), + array( 1, 'n is 1', 2, 'float number and is' ), + + array( 0, 'n in 1,3,5', 3, '' ), + array( 1, 'n not in 1,3,5', 5, '' ), + + array( 1, 'n in 1,3,5', 2, '' ), + array( 0, 'n not in 1,3,5', 4, '' ), + + array( 0, 'n in 1..3', 2, '' ), + array( 0, 'n in 1..3', 3, 'in is inclusive' ), + array( 1, 'n in 1..3', 0, '' ), + + array( 1, 'n not in 1..3', 2, '' ), + array( 1, 'n not in 1..3', 3, 'in is inclusive' ), + array( 0, 'n not in 1..3', 0, '' ), + + array( 1, 'n is not 1 and n is not 2 and n is not 3', 1, 'and relation' ), + array( 0, 'n is not 1 and n is not 2 and n is not 4', 3, 'and relation' ), + + array( 0, 'n is not 1 or n is 1', 1, 'or relation' ), + array( 1, 'n is 1 or n is 2', 3, 'or relation' ), + + array( 0, 'n is 1', 1, 'extra whitespace' ), + + array( 0, 'n mod 3 is 1', 7, 'mod' ), + array( 0, 'n mod 3 is not 1', 4.3, 'mod with floats' ), + + array( 0, 'n within 1..3', 2, 'within with integer' ), + array( 0, 'n within 1..3', 2.5, 'within with float' ), + array( 0, 'n in 1..3', 2, 'in with integer' ), + array( 1, 'n in 1..3', 2.5, 'in with float' ), + + array( 0, 'n in 3 or n is 4 and n is 5', 3, 'and binds more tightly than or' ), + array( 1, 'n is 3 or n is 4 and n is 5', 4, 'and binds more tightly than or' ), + + array( 0, 'n mod 10 in 3..4,9 and n mod 100 not in 10..19,70..79,90..99', 24, 'breton rule' ), + array( 1, 'n mod 10 in 3..4,9 and n mod 100 not in 10..19,70..79,90..99', 25, 'breton rule' ), + + array( 0, 'n within 0..2 and n is not 2', 0, 'french rule' ), + array( 0, 'n within 0..2 and n is not 2', 1, 'french rule' ), + array( 0, 'n within 0..2 and n is not 2', 1.2, 'french rule' ), + array( 1, 'n within 0..2 and n is not 2', 2, 'french rule' ), + + array( 1, 'n in 3..10,13..19', 2, 'scottish rule - ranges with comma' ), + array( 0, 'n in 3..10,13..19', 4, 'scottish rule - ranges with comma' ), + array( 1, 'n in 3..10,13..19', 12.999, 'scottish rule - ranges with comma' ), + array( 0, 'n in 3..10,13..19', 13, 'scottish rule - ranges with comma' ), + + array( 0, '5 mod 3 is n', 2, 'n as result of mod - no need to pass' ), + + # Revision 33 new operand examples + # expected, rule, number, comment + array( 0, 'i is 1', '1.00', 'new operand i' ), + array( 0, 'v is 2', '1.00', 'new operand v' ), + array( 0, 'w is 0', '1.00', 'new operand w' ), + array( 0, 'f is 0', '1.00', 'new operand f' ), + array( 0, 't is 0', '1.00', 'new operand t' ), + + array( 0, 'i is 1', '1.30', 'new operand i' ), + array( 0, 'v is 2', '1.30', 'new operand v' ), + array( 0, 'w is 1', '1.30', 'new operand w' ), + array( 0, 'f is 30', '1.30', 'new operand f' ), + array( 0, 't is 3', '1.30', 'new operand t' ), + + array( 0, 'i is 1', '1.03', 'new operand i' ), + array( 0, 'v is 2', '1.03', 'new operand v' ), + array( 0, 'w is 2', '1.03', 'new operand w' ), + array( 0, 'f is 3', '1.03', 'new operand f' ), + array( 0, 't is 3', '1.03', 'new operand t' ), + + # Revision 33 new operator aliases + # expected, rule, number, comment + array( 0, 'n % 3 is 1', 7, 'new % operator' ), + array( 0, 'n = 1,3,5', 3, 'new = operator' ), + array( 1, 'n != 1,3,5', 5, 'new != operator' ), + + # Revision 33 samples + # expected, rule, number, comment + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 0, 'n in 1,3,5@integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …', 3, 'samples' ), + // @codingStandardsIgnoreEnd + + # Revision 33 some test cases from CLDR + array( 0, 'i = 1 and v = 0 or i = 0 and t = 1', '0.1', 'pt one' ), + array( 0, 'i = 1 and v = 0 or i = 0 and t = 1', '0.01', 'pt one' ), + array( 0, 'i = 1 and v = 0 or i = 0 and t = 1', '0.10', 'pt one' ), + array( 0, 'i = 1 and v = 0 or i = 0 and t = 1', '0.010', 'pt one' ), + array( 0, 'i = 1 and v = 0 or i = 0 and t = 1', '0.100', 'pt one' ), + array( 1, 'i = 1 and v = 0 or i = 0 and t = 1', '0.0', 'pt other' ), + array( 1, 'i = 1 and v = 0 or i = 0 and t = 1', '0.2', 'pt other' ), + array( 1, 'i = 1 and v = 0 or i = 0 and t = 1', '10.0', 'pt other' ), + array( 1, 'i = 1 and v = 0 or i = 0 and t = 1', '100.0', 'pt other' ), + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '2', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '4', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '22', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '102', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '0.2', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '0.4', 'bs few' ), + array( 0, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '10.2', 'bs few' ), + array( 1, 'v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14', '10.0', 'bs other' ), + // @codingStandardsIgnoreEnd + ); + + return $tests; + } + + function invalidTestCases() { + $tests = array( + array( 'n mod mod 5 is 1', 'mod mod' ), + array( 'n', 'just n' ), + array( 'n is in 5', 'is in' ), + ); + + return $tests; + } +} diff --git a/tests/phpunit/maintenance/DumpTestCase.php b/tests/phpunit/maintenance/DumpTestCase.php new file mode 100644 index 00000000..8b6aef53 --- /dev/null +++ b/tests/phpunit/maintenance/DumpTestCase.php @@ -0,0 +1,386 @@ +<?php + +/** + * Base TestCase for dumps + */ +abstract class DumpTestCase extends MediaWikiLangTestCase { + + /** + * exception to be rethrown once in sound PHPUnit surrounding + * + * As the current MediaWikiTestCase::run is not robust enough to recover + * from thrown exceptions directly, we cannot throw frow within + * self::addDBData, although it would be appropriate. Hence, we catch the + * exception and store it until we are in setUp and may finally rethrow + * the exception without crashing the test suite. + * + * @var Exception|null + */ + protected $exceptionFromAddDBData = null; + + /** + * Holds the xmlreader used for analyzing an xml dump + * + * @var XMLReader|null + */ + protected $xml = null; + + /** + * Adds a revision to a page, while returning the resuting revision's id + * + * @param Page $page Page to add the revision to + * @param string $text Revisions text + * @param string $summary Revisions summare + * @return array + * @throws MWException + */ + protected function addRevision( Page $page, $text, $summary ) { + $status = $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + $summary + ); + + if ( $status->isGood() ) { + $value = $status->getValue(); + $revision = $value['revision']; + $revision_id = $revision->getId(); + $text_id = $revision->getTextId(); + + if ( ( $revision_id > 0 ) && ( $text_id > 0 ) ) { + return array( $revision_id, $text_id ); + } + } + + throw new MWException( "Could not determine revision id (" . $status->getWikiText() . ")" ); + } + + /** + * gunzips the given file and stores the result in the original file name + * + * @param string $fname Filename to read the gzipped data from and stored + * the gunzipped data into + */ + protected function gunzip( $fname ) { + $gzipped_contents = file_get_contents( $fname ); + if ( $gzipped_contents === false ) { + $this->fail( "Could not get contents of $fname" ); + } + + $contents = gzdecode( $gzipped_contents ); + + $this->assertEquals( + strlen( $contents ), + file_put_contents( $fname, $contents ), + '# bytes written' + ); + } + + /** + * Default set up function. + * + * Clears $wgUser, and reports errors from addDBData to PHPUnit + */ + protected function setUp() { + parent::setUp(); + + // Check if any Exception is stored for rethrowing from addDBData + // @see self::exceptionFromAddDBData + if ( $this->exceptionFromAddDBData !== null ) { + throw $this->exceptionFromAddDBData; + } + + $this->setMwGlobals( 'wgUser', new User() ); + } + + /** + * Checks for test output consisting only of lines containing ETA announcements + */ + function expectETAOutput() { + // Newer PHPUnits require assertion about the output using PHPUnit's own + // expectOutput[...] functions. However, the PHPUnit shipped prediactes + // do not allow to check /each/ line of the output using /readable/ REs. + // So we ... + // + // 1. ... add a dummy output checking to make PHPUnit not complain + // about unchecked test output + $this->expectOutputRegex( '//' ); + + // 2. Do the real output checking on our own. + $lines = explode( "\n", $this->getActualOutput() ); + $this->assertGreaterThan( 1, count( $lines ), "Minimal lines of produced output" ); + $this->assertEquals( '', array_pop( $lines ), "Output ends in LF" ); + $timestamp_re = "[0-9]{4}-[01][0-9]-[0-3][0-9] [0-2][0-9]:[0-5][0-9]:[0-6][0-9]"; + foreach ( $lines as $line ) { + $this->assertRegExp( + "/$timestamp_re: .* \(ID [0-9]+\) [0-9]* pages .*, [0-9]* revs .*, ETA/", + $line + ); + } + } + + /** + * Step the current XML reader until node end of given name is found. + * + * @param string $name Name of the closing element to look for + * (e.g.: "mediawiki" when looking for </mediawiki>) + * + * @return bool True if the end node could be found. false otherwise. + */ + protected function skipToNodeEnd( $name ) { + while ( $this->xml->read() ) { + if ( $this->xml->nodeType == XMLReader::END_ELEMENT && + $this->xml->name == $name + ) { + return true; + } + } + + return false; + } + + /** + * Step the current XML reader to the first element start after the node + * end of a given name. + * + * @param string $name Name of the closing element to look for + * (e.g.: "mediawiki" when looking for </mediawiki>) + * + * @return bool True if new element after the closing of $name could be + * found. false otherwise. + */ + protected function skipPastNodeEnd( $name ) { + $this->assertTrue( $this->skipToNodeEnd( $name ), + "Skipping to end of $name" ); + while ( $this->xml->read() ) { + if ( $this->xml->nodeType == XMLReader::ELEMENT ) { + return true; + } + } + + return false; + } + + /** + * Opens an XML file to analyze and optionally skips past siteinfo. + * + * @param string $fname Name of file to analyze + * @param bool $skip_siteinfo (optional) If true, step the xml reader + * to the first element after </siteinfo> + */ + protected function assertDumpStart( $fname, $skip_siteinfo = true ) { + $this->xml = new XMLReader(); + $this->assertTrue( $this->xml->open( $fname ), + "Opening temporary file $fname via XMLReader failed" ); + if ( $skip_siteinfo ) { + $this->assertTrue( $this->skipPastNodeEnd( "siteinfo" ), + "Skipping past end of siteinfo" ); + } + } + + /** + * Asserts that the xml reader is at the final closing tag of an xml file and + * closes the reader. + * + * @param string $name (optional) the name of the final tag + * (e.g.: "mediawiki" for </mediawiki>) + */ + protected function assertDumpEnd( $name = "mediawiki" ) { + $this->assertNodeEnd( $name, false ); + if ( $this->xml->read() ) { + $this->skipWhitespace(); + } + $this->assertEquals( $this->xml->nodeType, XMLReader::NONE, + "No proper entity left to parse" ); + $this->xml->close(); + } + + /** + * Steps the xml reader over white space + */ + protected function skipWhitespace() { + $cont = true; + while ( $cont && ( ( $this->xml->nodeType == XMLReader::WHITESPACE ) + || ( $this->xml->nodeType == XMLReader::SIGNIFICANT_WHITESPACE ) ) ) { + $cont = $this->xml->read(); + } + } + + /** + * Asserts that the xml reader is at an element of given name, and optionally + * skips past it. + * + * @param string $name The name of the element to check for + * (e.g.: "mediawiki" for <mediawiki>) + * @param bool $skip (optional) if true, skip past the found element + */ + protected function assertNodeStart( $name, $skip = true ) { + $this->assertEquals( $name, $this->xml->name, "Node name" ); + $this->assertEquals( XMLReader::ELEMENT, $this->xml->nodeType, "Node type" ); + if ( $skip ) { + $this->assertTrue( $this->xml->read(), "Skipping past start tag" ); + } + } + + /** + * Asserts that the xml reader is at an closing element of given name, and optionally + * skips past it. + * + * @param string $name The name of the closing element to check for + * (e.g.: "mediawiki" for </mediawiki>) + * @param bool $skip (optional) if true, skip past the found element + */ + protected function assertNodeEnd( $name, $skip = true ) { + $this->assertEquals( $name, $this->xml->name, "Node name" ); + $this->assertEquals( XMLReader::END_ELEMENT, $this->xml->nodeType, "Node type" ); + if ( $skip ) { + $this->assertTrue( $this->xml->read(), "Skipping past end tag" ); + } + } + + /** + * Asserts that the xml reader is at an element of given tag that contains a given text, + * and skips over the element. + * + * @param string $name The name of the element to check for + * (e.g.: "mediawiki" for <mediawiki>...</mediawiki>) + * @param string|bool $text If string, check if it equals the elements text. + * If false, ignore the element's text + * @param bool $skip_ws (optional) if true, skip past white spaces that trail the + * closing element. + */ + protected function assertTextNode( $name, $text, $skip_ws = true ) { + $this->assertNodeStart( $name ); + + if ( $text !== false ) { + $this->assertEquals( $text, $this->xml->value, "Text of node " . $name ); + } + $this->assertTrue( $this->xml->read(), "Skipping past processed text of " . $name ); + $this->assertNodeEnd( $name ); + + if ( $skip_ws ) { + $this->skipWhitespace(); + } + } + + /** + * Asserts that the xml reader is at the start of a page element and skips over the first + * tags, after checking them. + * + * Besides the opening page element, this function also checks for and skips over the + * title, ns, and id tags. Hence after this function, the xml reader is at the first + * revision of the current page. + * + * @param int $id Id of the page to assert + * @param int $ns Number of namespage to assert + * @param string $name Title of the current page + */ + protected function assertPageStart( $id, $ns, $name ) { + + $this->assertNodeStart( "page" ); + $this->skipWhitespace(); + + $this->assertTextNode( "title", $name ); + $this->assertTextNode( "ns", $ns ); + $this->assertTextNode( "id", $id ); + } + + /** + * Asserts that the xml reader is at the page's closing element and skips to the next + * element. + */ + protected function assertPageEnd() { + $this->assertNodeEnd( "page" ); + $this->skipWhitespace(); + } + + /** + * Asserts that the xml reader is at a revision and checks its representation before + * skipping over it. + * + * @param int $id Id of the revision + * @param string $summary Summary of the revision + * @param int $text_id Id of the revision's text + * @param int $text_bytes Number of bytes in the revision's text + * @param string $text_sha1 The base36 SHA-1 of the revision's text + * @param string|bool $text (optional) The revision's string, or false to check for a + * revision stub + * @param int|bool $parentid (optional) id of the parent revision + * @param string $model The expected content model id (default: CONTENT_MODEL_WIKITEXT) + * @param string $format The expected format model id (default: CONTENT_FORMAT_WIKITEXT) + */ + protected function assertRevision( $id, $summary, $text_id, $text_bytes, + $text_sha1, $text = false, $parentid = false, + $model = CONTENT_MODEL_WIKITEXT, $format = CONTENT_FORMAT_WIKITEXT + ) { + $this->assertNodeStart( "revision" ); + $this->skipWhitespace(); + + $this->assertTextNode( "id", $id ); + if ( $parentid !== false ) { + $this->assertTextNode( "parentid", $parentid ); + } + $this->assertTextNode( "timestamp", false ); + + $this->assertNodeStart( "contributor" ); + $this->skipWhitespace(); + $this->assertTextNode( "ip", false ); + $this->assertNodeEnd( "contributor" ); + $this->skipWhitespace(); + + $this->assertTextNode( "comment", $summary ); + $this->skipWhitespace(); + + if ( $this->xml->name == "text" ) { + // note: <text> tag may occur here or at the very end. + $text_found = true; + $this->assertText( $id, $text_id, $text_bytes, $text ); + } else { + $text_found = false; + } + + $this->assertTextNode( "sha1", $text_sha1 ); + + $this->assertTextNode( "model", $model ); + $this->skipWhitespace(); + + $this->assertTextNode( "format", $format ); + $this->skipWhitespace(); + + if ( !$text_found ) { + $this->assertText( $id, $text_id, $text_bytes, $text ); + } + + $this->assertNodeEnd( "revision" ); + $this->skipWhitespace(); + } + + protected function assertText( $id, $text_id, $text_bytes, $text ) { + $this->assertNodeStart( "text", false ); + if ( $text_bytes !== false ) { + $this->assertEquals( $this->xml->getAttribute( "bytes" ), $text_bytes, + "Attribute 'bytes' of revision " . $id ); + } + + if ( $text === false ) { + // Testing for a stub + $this->assertEquals( $this->xml->getAttribute( "id" ), $text_id, + "Text id of revision " . $id ); + $this->assertFalse( $this->xml->hasValue, "Revision has text" ); + $this->assertTrue( $this->xml->read(), "Skipping text start tag" ); + if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT ) + && ( $this->xml->name == "text" ) + ) { + + $this->xml->read(); + } + $this->skipWhitespace(); + } else { + // Testing for a real dump + $this->assertTrue( $this->xml->read(), "Skipping text start tag" ); + $this->assertEquals( $text, $this->xml->value, "Text of revision " . $id ); + $this->assertTrue( $this->xml->read(), "Skipping past text" ); + $this->assertNodeEnd( "text" ); + $this->skipWhitespace(); + } + } +} diff --git a/tests/phpunit/maintenance/MaintenanceTest.php b/tests/phpunit/maintenance/MaintenanceTest.php new file mode 100644 index 00000000..e2fc8247 --- /dev/null +++ b/tests/phpunit/maintenance/MaintenanceTest.php @@ -0,0 +1,830 @@ +<?php + +// It would be great if we were able to use PHPUnit's getMockForAbstractClass +// instead of the MaintenanceFixup hack below. However, we cannot do +// without changing the visibility and without working around hacks in +// Maintenance.php +// +// For the same reason, we cannot just use FakeMaintenance. + +/** + * makes parts of the API of Maintenance that is hidden by protected visibily + * visible for testing, and makes up for a stream closing hack in Maintenance.php. + * + * This class is solely used for being able to test Maintenance right now + * without having to apply major refactorings to fix some design issues in + * Maintenance.php. Before adding more functions here, please consider whether + * this approach is correct, or a refactoring Maintenance to separate concers + * is more appropriate. + * + * Upon refactoring, keep in mind that besides the maintenance scrits themselves + * and tests right here, also at least Extension:Maintenance make use of + * Maintenance. + * + * Due to a hack in Maintenance.php using register_shutdown_function, be sure to + * finally call simulateShutdown on MaintenanceFixup instance before a test + * ends. + * + */ +class MaintenanceFixup extends Maintenance { + + // --- Making up for the register_shutdown_function hack in Maintenance.php + + /** + * The test case that generated this instance. + * + * This member is motivated by allowing the destructor to check whether or not + * the test failed, in order to avoid unnecessary nags about omitted shutdown + * simulation. + * But as it is already available, we also usi it to flagging tests as failed + * + * @var MediaWikiTestCase + */ + private $testCase; + + /** + * shutdownSimulated === true if simulateShutdown has done it's work + * + * @var bool + */ + private $shutdownSimulated = false; + + /** + * Simulates what Maintenance wants to happen at script's end. + */ + public function simulateShutdown() { + + if ( $this->shutdownSimulated ) { + $this->testCase->fail( __METHOD__ . " called more than once" ); + } + + // The cleanup action. + $this->outputChanneled( false ); + + // Bookkeeping that we simulated the clean up. + $this->shutdownSimulated = true; + } + + // Note that the "public" here does not change visibility + public function outputChanneled( $msg, $channel = null ) { + if ( $this->shutdownSimulated ) { + if ( $msg !== false ) { + $this->testCase->fail( "Already past simulated shutdown, but msg is " + . "not false. Did the hack in Maintenance.php change? Please " + . "adapt the test case or Maintenance.php" ); + } + + // The current call is the one registered via register_shutdown_function. + // We can safely ignore it, as we simulated this one via simulateShutdown + // before (if we did not, the destructor of this instance will warn about + // it) + return; + } + + call_user_func_array( array( "parent", __FUNCTION__ ), func_get_args() ); + } + + /** + * Safety net around register_shutdown_function of Maintenance.php + */ + public function __destruct() { + if ( !$this->shutdownSimulated ) { + // Someone generated a MaintenanceFixup instance without calling + // simulateShutdown. We'd have to raise a PHPUnit exception to correctly + // flag this illegal usage. However, we are already in a destruktor, which + // would trigger undefined behavior. Hence, we can only report to the + // error output :( Hopefully people read the PHPUnit output. + $name = $this->testCase->getName(); + fwrite( STDERR, "ERROR! Instance of " . __CLASS__ . " for test $name " + . "destructed without calling simulateShutdown method. Call " + . "simulateShutdown on the instance before it gets destructed." ); + } + + // The following guard is required, as PHP does not offer default destructors :( + if ( is_callable( "parent::__destruct" ) ) { + parent::__destruct(); + } + } + + public function __construct( MediaWikiTestCase $testCase ) { + parent::__construct(); + $this->testCase = $testCase; + } + + // --- Making protected functions visible for test + + public function output( $out, $channel = null ) { + // Just to make PHP not nag about signature mismatches, we copied + // Maintenance::output signature. However, we do not use (or rely on) + // those variables. Instead we pass to Maintenance::output whatever we + // receive at runtime. + return call_user_func_array( array( "parent", __FUNCTION__ ), func_get_args() ); + } + + // --- Requirements for getting instance of abstract class + + public function execute() { + $this->testCase->fail( __METHOD__ . " called unexpectedly" ); + } +} + +/** + * @covers Maintenance + */ +class MaintenanceTest extends MediaWikiTestCase { + + /** + * The main Maintenance instance that is used for testing. + * + * @var MaintenanceFixup + */ + private $m; + + protected function setUp() { + parent::setUp(); + $this->m = new MaintenanceFixup( $this ); + } + + protected function tearDown() { + if ( $this->m ) { + $this->m->simulateShutdown(); + $this->m = null; + } + parent::tearDown(); + } + + /** + * asserts the output before and after simulating shutdown + * + * This function simulates shutdown of self::m. + * + * @param string $preShutdownOutput Expected output before simulating shutdown + * @param bool $expectNLAppending Whether or not shutdown simulation is expected + * to add a newline to the output. If false, $preShutdownOutput is the + * expected output after shutdown simulation. Otherwise, + * $preShutdownOutput with an appended newline is the expected output + * after shutdown simulation. + */ + private function assertOutputPrePostShutdown( $preShutdownOutput, $expectNLAppending ) { + + $this->assertEquals( $preShutdownOutput, $this->getActualOutput(), + "Output before shutdown simulation" ); + + $this->m->simulateShutdown(); + $this->m = null; + + $postShutdownOutput = $preShutdownOutput . ( $expectNLAppending ? "\n" : "" ); + $this->expectOutputString( $postShutdownOutput ); + } + + // Although the following tests do not seem to be too consistent (compare for + // example the newlines within the test.*StringString tests, or the + // test.*Intermittent.* tests), the objective of these tests is not to describe + // consistent behavior, but rather currently existing behavior. + + function testOutputEmpty() { + $this->m->output( "" ); + $this->assertOutputPrePostShutdown( "", false ); + } + + function testOutputString() { + $this->m->output( "foo" ); + $this->assertOutputPrePostShutdown( "foo", false ); + } + + function testOutputStringString() { + $this->m->output( "foo" ); + $this->m->output( "bar" ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputStringNL() { + $this->m->output( "foo\n" ); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testOutputStringNLNL() { + $this->m->output( "foo\n\n" ); + $this->assertOutputPrePostShutdown( "foo\n\n", false ); + } + + function testOutputStringNLString() { + $this->m->output( "foo\nbar" ); + $this->assertOutputPrePostShutdown( "foo\nbar", false ); + } + + function testOutputStringNLStringNL() { + $this->m->output( "foo\nbar\n" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputStringNLStringNLLinewise() { + $this->m->output( "foo\n" ); + $this->m->output( "bar\n" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputStringNLStringNLArbitrary() { + $this->m->output( "" ); + $this->m->output( "foo" ); + $this->m->output( "" ); + $this->m->output( "\n" ); + $this->m->output( "ba" ); + $this->m->output( "" ); + $this->m->output( "r\n" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputStringNLStringNLArbitraryAgain() { + $this->m->output( "" ); + $this->m->output( "foo" ); + $this->m->output( "" ); + $this->m->output( "\nb" ); + $this->m->output( "a" ); + $this->m->output( "" ); + $this->m->output( "r\n" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputWNullChannelEmpty() { + $this->m->output( "", null ); + $this->assertOutputPrePostShutdown( "", false ); + } + + function testOutputWNullChannelString() { + $this->m->output( "foo", null ); + $this->assertOutputPrePostShutdown( "foo", false ); + } + + function testOutputWNullChannelStringString() { + $this->m->output( "foo", null ); + $this->m->output( "bar", null ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputWNullChannelStringNL() { + $this->m->output( "foo\n", null ); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testOutputWNullChannelStringNLNL() { + $this->m->output( "foo\n\n", null ); + $this->assertOutputPrePostShutdown( "foo\n\n", false ); + } + + function testOutputWNullChannelStringNLString() { + $this->m->output( "foo\nbar", null ); + $this->assertOutputPrePostShutdown( "foo\nbar", false ); + } + + function testOutputWNullChannelStringNLStringNL() { + $this->m->output( "foo\nbar\n", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputWNullChannelStringNLStringNLLinewise() { + $this->m->output( "foo\n", null ); + $this->m->output( "bar\n", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputWNullChannelStringNLStringNLArbitrary() { + $this->m->output( "", null ); + $this->m->output( "foo", null ); + $this->m->output( "", null ); + $this->m->output( "\n", null ); + $this->m->output( "ba", null ); + $this->m->output( "", null ); + $this->m->output( "r\n", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputWNullChannelStringNLStringNLArbitraryAgain() { + $this->m->output( "", null ); + $this->m->output( "foo", null ); + $this->m->output( "", null ); + $this->m->output( "\nb", null ); + $this->m->output( "a", null ); + $this->m->output( "", null ); + $this->m->output( "r\n", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputWChannelString() { + $this->m->output( "foo", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo", true ); + } + + function testOutputWChannelStringNL() { + $this->m->output( "foo\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo", true ); + } + + function testOutputWChannelStringNLNL() { + // If this test fails, note that output takes strings with double line + // endings (although output's implementation in this situation calls + // outputChanneled with a string ending in a nl ... which is not allowed + // according to the documentation of outputChanneled) + $this->m->output( "foo\n\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\n", true ); + } + + function testOutputWChannelStringNLString() { + $this->m->output( "foo\nbar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputWChannelStringNLStringNL() { + $this->m->output( "foo\nbar\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputWChannelStringNLStringNLLinewise() { + $this->m->output( "foo\n", "bazChannel" ); + $this->m->output( "bar\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + function testOutputWChannelStringNLStringNLArbitrary() { + $this->m->output( "", "bazChannel" ); + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "", "bazChannel" ); + $this->m->output( "\n", "bazChannel" ); + $this->m->output( "ba", "bazChannel" ); + $this->m->output( "", "bazChannel" ); + $this->m->output( "r\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + function testOutputWChannelStringNLStringNLArbitraryAgain() { + $this->m->output( "", "bazChannel" ); + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "", "bazChannel" ); + $this->m->output( "\nb", "bazChannel" ); + $this->m->output( "a", "bazChannel" ); + $this->m->output( "", "bazChannel" ); + $this->m->output( "r\n", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputWMultipleChannelsChannelChange() { + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "bar", "bazChannel" ); + $this->m->output( "qux", "quuxChannel" ); + $this->m->output( "corge", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar\nqux\ncorge", true ); + } + + function testOutputWMultipleChannelsChannelChangeNL() { + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "bar\n", "bazChannel" ); + $this->m->output( "qux\n", "quuxChannel" ); + $this->m->output( "corge", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar\nqux\ncorge", true ); + } + + function testOutputWAndWOChannelStringStartWO() { + $this->m->output( "foo" ); + $this->m->output( "bar", "bazChannel" ); + $this->m->output( "qux" ); + $this->m->output( "quux", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar\nquxquux", true ); + } + + function testOutputWAndWOChannelStringStartW() { + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "bar" ); + $this->m->output( "qux", "bazChannel" ); + $this->m->output( "quux" ); + $this->assertOutputPrePostShutdown( "foo\nbarqux\nquux", false ); + } + + function testOutputWChannelTypeSwitch() { + $this->m->output( "foo", 1 ); + $this->m->output( "bar", 1.0 ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputIntermittentEmpty() { + $this->m->output( "foo" ); + $this->m->output( "" ); + $this->m->output( "bar" ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputIntermittentFalse() { + $this->m->output( "foo" ); + $this->m->output( false ); + $this->m->output( "bar" ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputIntermittentFalseAfterOtherChannel() { + $this->m->output( "qux", "quuxChannel" ); + $this->m->output( "foo" ); + $this->m->output( false ); + $this->m->output( "bar" ); + $this->assertOutputPrePostShutdown( "qux\nfoobar", false ); + } + + function testOutputWNullChannelIntermittentEmpty() { + $this->m->output( "foo", null ); + $this->m->output( "", null ); + $this->m->output( "bar", null ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputWNullChannelIntermittentFalse() { + $this->m->output( "foo", null ); + $this->m->output( false, null ); + $this->m->output( "bar", null ); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testOutputWChannelIntermittentEmpty() { + $this->m->output( "foo", "bazChannel" ); + $this->m->output( "", "bazChannel" ); + $this->m->output( "bar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + function testOutputWChannelIntermittentFalse() { + $this->m->output( "foo", "bazChannel" ); + $this->m->output( false, "bazChannel" ); + $this->m->output( "bar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + // Note that (per documentation) outputChanneled does take strings that end + // in \n, hence we do not test such strings. + + function testOutputChanneledEmpty() { + $this->m->outputChanneled( "" ); + $this->assertOutputPrePostShutdown( "\n", false ); + } + + function testOutputChanneledString() { + $this->m->outputChanneled( "foo" ); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testOutputChanneledStringString() { + $this->m->outputChanneled( "foo" ); + $this->m->outputChanneled( "bar" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledStringNLString() { + $this->m->outputChanneled( "foo\nbar" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledStringNLStringNLArbitraryAgain() { + $this->m->outputChanneled( "" ); + $this->m->outputChanneled( "foo" ); + $this->m->outputChanneled( "" ); + $this->m->outputChanneled( "\nb" ); + $this->m->outputChanneled( "a" ); + $this->m->outputChanneled( "" ); + $this->m->outputChanneled( "r" ); + $this->assertOutputPrePostShutdown( "\nfoo\n\n\nb\na\n\nr\n", false ); + } + + function testOutputChanneledWNullChannelEmpty() { + $this->m->outputChanneled( "", null ); + $this->assertOutputPrePostShutdown( "\n", false ); + } + + function testOutputChanneledWNullChannelString() { + $this->m->outputChanneled( "foo", null ); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testOutputChanneledWNullChannelStringString() { + $this->m->outputChanneled( "foo", null ); + $this->m->outputChanneled( "bar", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledWNullChannelStringNLString() { + $this->m->outputChanneled( "foo\nbar", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledWNullChannelStringNLStringNLArbitraryAgain() { + $this->m->outputChanneled( "", null ); + $this->m->outputChanneled( "foo", null ); + $this->m->outputChanneled( "", null ); + $this->m->outputChanneled( "\nb", null ); + $this->m->outputChanneled( "a", null ); + $this->m->outputChanneled( "", null ); + $this->m->outputChanneled( "r", null ); + $this->assertOutputPrePostShutdown( "\nfoo\n\n\nb\na\n\nr\n", false ); + } + + function testOutputChanneledWChannelString() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo", true ); + } + + function testOutputChanneledWChannelStringNLString() { + $this->m->outputChanneled( "foo\nbar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputChanneledWChannelStringString() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "bar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + function testOutputChanneledWChannelStringNLStringNLArbitraryAgain() { + $this->m->outputChanneled( "", "bazChannel" ); + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "", "bazChannel" ); + $this->m->outputChanneled( "\nb", "bazChannel" ); + $this->m->outputChanneled( "a", "bazChannel" ); + $this->m->outputChanneled( "", "bazChannel" ); + $this->m->outputChanneled( "r", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputChanneledWMultipleChannelsChannelChange() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "bar", "bazChannel" ); + $this->m->outputChanneled( "qux", "quuxChannel" ); + $this->m->outputChanneled( "corge", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar\nqux\ncorge", true ); + } + + function testOutputChanneledWMultipleChannelsChannelChangeEnclosedNull() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "bar", null ); + $this->m->outputChanneled( "qux", null ); + $this->m->outputChanneled( "corge", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar\nqux\ncorge", true ); + } + + function testOutputChanneledWMultipleChannelsChannelAfterNullChange() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "bar", null ); + $this->m->outputChanneled( "qux", null ); + $this->m->outputChanneled( "corge", "quuxChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar\nqux\ncorge", true ); + } + + function testOutputChanneledWAndWOChannelStringStartWO() { + $this->m->outputChanneled( "foo" ); + $this->m->outputChanneled( "bar", "bazChannel" ); + $this->m->outputChanneled( "qux" ); + $this->m->outputChanneled( "quux", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar\nqux\nquux", true ); + } + + function testOutputChanneledWAndWOChannelStringStartW() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "bar" ); + $this->m->outputChanneled( "qux", "bazChannel" ); + $this->m->outputChanneled( "quux" ); + $this->assertOutputPrePostShutdown( "foo\nbar\nqux\nquux\n", false ); + } + + function testOutputChanneledWChannelTypeSwitch() { + $this->m->outputChanneled( "foo", 1 ); + $this->m->outputChanneled( "bar", 1.0 ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testOutputChanneledWOChannelIntermittentEmpty() { + $this->m->outputChanneled( "foo" ); + $this->m->outputChanneled( "" ); + $this->m->outputChanneled( "bar" ); + $this->assertOutputPrePostShutdown( "foo\n\nbar\n", false ); + } + + function testOutputChanneledWOChannelIntermittentFalse() { + $this->m->outputChanneled( "foo" ); + $this->m->outputChanneled( false ); + $this->m->outputChanneled( "bar" ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledWNullChannelIntermittentEmpty() { + $this->m->outputChanneled( "foo", null ); + $this->m->outputChanneled( "", null ); + $this->m->outputChanneled( "bar", null ); + $this->assertOutputPrePostShutdown( "foo\n\nbar\n", false ); + } + + function testOutputChanneledWNullChannelIntermittentFalse() { + $this->m->outputChanneled( "foo", null ); + $this->m->outputChanneled( false, null ); + $this->m->outputChanneled( "bar", null ); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testOutputChanneledWChannelIntermittentEmpty() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( "", "bazChannel" ); + $this->m->outputChanneled( "bar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foobar", true ); + } + + function testOutputChanneledWChannelIntermittentFalse() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->outputChanneled( false, "bazChannel" ); + $this->m->outputChanneled( "bar", "bazChannel" ); + $this->assertOutputPrePostShutdown( "foo\nbar", true ); + } + + function testCleanupChanneledClean() { + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "", false ); + } + + function testCleanupChanneledAfterOutput() { + $this->m->output( "foo" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo", false ); + } + + function testCleanupChanneledAfterOutputWNullChannel() { + $this->m->output( "foo", null ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo", false ); + } + + function testCleanupChanneledAfterOutputWChannel() { + $this->m->output( "foo", "bazChannel" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterNLOutput() { + $this->m->output( "foo\n" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterNLOutputWNullChannel() { + $this->m->output( "foo\n", null ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterNLOutputWChannel() { + $this->m->output( "foo\n", "bazChannel" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterOutputChanneledWOChannel() { + $this->m->outputChanneled( "foo" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterOutputChanneledWNullChannel() { + $this->m->outputChanneled( "foo", null ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testCleanupChanneledAfterOutputChanneledWChannel() { + $this->m->outputChanneled( "foo", "bazChannel" ); + $this->m->cleanupChanneled(); + $this->assertOutputPrePostShutdown( "foo\n", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutput() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->output( "foo" ); + $m2->output( "bar" ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutputWNullChannel() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->output( "foo", null ); + $m2->output( "bar", null ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutputWChannel() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->output( "foo", "bazChannel" ); + $m2->output( "bar", "bazChannel" ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar\n", true ); + } + + function testMultipleMaintenanceObjectsInteractionOutputWNullChannelNL() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->output( "foo\n", null ); + $m2->output( "bar\n", null ); + + $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutputWChannelNL() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->output( "foo\n", "bazChannel" ); + $m2->output( "bar\n", "bazChannel" ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar\n", true ); + } + + function testMultipleMaintenanceObjectsInteractionOutputChanneled() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->outputChanneled( "foo" ); + $m2->outputChanneled( "bar" ); + + $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutputChanneledWNullChannel() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->outputChanneled( "foo", null ); + $m2->outputChanneled( "bar", null ); + + $this->assertEquals( "foo\nbar\n", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foo\nbar\n", false ); + } + + function testMultipleMaintenanceObjectsInteractionOutputChanneledWChannel() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->outputChanneled( "foo", "bazChannel" ); + $m2->outputChanneled( "bar", "bazChannel" ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before shutdown simulation (m2)" ); + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar\n", true ); + } + + function testMultipleMaintenanceObjectsInteractionCleanupChanneledWChannel() { + $m2 = new MaintenanceFixup( $this ); + + $this->m->outputChanneled( "foo", "bazChannel" ); + $m2->outputChanneled( "bar", "bazChannel" ); + + $this->assertEquals( "foobar", $this->getActualOutput(), + "Output before first cleanup" ); + $this->m->cleanupChanneled(); + $this->assertEquals( "foobar\n", $this->getActualOutput(), + "Output after first cleanup" ); + $m2->cleanupChanneled(); + $this->assertEquals( "foobar\n\n", $this->getActualOutput(), + "Output after second cleanup" ); + + $m2->simulateShutdown(); + $this->assertOutputPrePostShutdown( "foobar\n\n", false ); + } + + /** + * @covers Maintenance::getConfig + */ + public function testGetConfig() { + $this->assertInstanceOf( 'Config', $this->m->getConfig() ); + $this->assertSame( ConfigFactory::getDefaultInstance()->makeConfig( 'main' ), $this->m->getConfig() ); + } + + /** + * @covers Maintenance::setConfig + */ + public function testSetConfig() { + $conf = $this->getMock( 'Config' ); + $this->m->setConfig( $conf ); + $this->assertSame( $conf, $this->m->getConfig() ); + } +} diff --git a/tests/phpunit/maintenance/backupPrefetchTest.php b/tests/phpunit/maintenance/backupPrefetchTest.php new file mode 100644 index 00000000..5e0fe89d --- /dev/null +++ b/tests/phpunit/maintenance/backupPrefetchTest.php @@ -0,0 +1,277 @@ +<?php + +require_once __DIR__ . "/../../../maintenance/backupPrefetch.inc"; + +/** + * Tests for BaseDump + * + * @group Dump + * @covers BaseDump + */ +class BaseDumpTest extends MediaWikiTestCase { + + /** + * @var BaseDump The BaseDump instance used within a test. + * + * If set, this BaseDump gets automatically closed in tearDown. + */ + private $dump = null; + + protected function tearDown() { + if ( $this->dump !== null ) { + $this->dump->close(); + } + + // Bug 37458, parent teardown need to be done after closing the + // dump or it might cause some permissions errors. + parent::tearDown(); + } + + /** + * asserts that a prefetch yields an expected string + * + * @param string|null $expected The exepcted result of the prefetch + * @param int $page The page number to prefetch the text for + * @param int $revision The revision number to prefetch the text for + */ + private function assertPrefetchEquals( $expected, $page, $revision ) { + $this->assertEquals( $expected, $this->dump->prefetch( $page, $revision ), + "Prefetch of page $page revision $revision" ); + } + + function testSequential() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 ); + $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 ); + } + + function testSynchronizeRevisionMissToRevision() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( null, 2, 3 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 ); + } + + function testSynchronizeRevisionMissToPage() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( null, 2, 40 ); + $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 ); + } + + function testSynchronizePageMiss() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( null, 3, 40 ); + $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 ); + } + + function testPageMissAtEnd() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( null, 6, 40 ); + } + + function testRevisionMissAtEnd() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( null, 4, 40 ); + } + + function testSynchronizePageMissAtStart() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( null, 0, 2 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + } + + function testSynchronizeRevisionMissAtStart() { + $fname = $this->setUpPrefetch(); + $this->dump = new BaseDump( $fname ); + + $this->assertPrefetchEquals( null, 1, -2 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + } + + function testSequentialAcrossFiles() { + $fname1 = $this->setUpPrefetch( array( 1 ) ); + $fname2 = $this->setUpPrefetch( array( 2, 4 ) ); + $this->dump = new BaseDump( $fname1 . ";" . $fname2 ); + + $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + $this->assertPrefetchEquals( "BackupDumperTestP2Text4 some additional Text", 2, 5 ); + $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 ); + } + + function testSynchronizeSkipAcrossFile() { + $fname1 = $this->setUpPrefetch( array( 1 ) ); + $fname2 = $this->setUpPrefetch( array( 2 ) ); + $fname3 = $this->setUpPrefetch( array( 4 ) ); + $this->dump = new BaseDump( $fname1 . ";" . $fname2 . ";" . $fname3 ); + + $this->assertPrefetchEquals( "BackupDumperTestP1Text1", 1, 1 ); + $this->assertPrefetchEquals( "Talk about BackupDumperTestP1 Text1", 4, 8 ); + } + + function testSynchronizeMissInWholeFirstFile() { + $fname1 = $this->setUpPrefetch( array( 1 ) ); + $fname2 = $this->setUpPrefetch( array( 2 ) ); + $this->dump = new BaseDump( $fname1 . ";" . $fname2 ); + + $this->assertPrefetchEquals( "BackupDumperTestP2Text1", 2, 2 ); + } + + /** + * Constructs a temporary file that can be used for prefetching + * + * The temporary file is removed by DumpBackup upon tearDown. + * + * @param array $requested_pages The indices of the page parts that should + * go into the prefetch file. 1,2,4 are available. + * @return string The file name of the created temporary file + */ + private function setUpPrefetch( $requested_pages = array( 1, 2, 4 ) ) { + // The file name, where we store the prepared prefetch file + $fname = $this->getNewTempFile(); + + // The header of every prefetch file + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $header = '<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.7/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.7/ http://www.mediawiki.org/xml/export-0.7.xsd" version="0.7" xml:lang="en"> + <siteinfo> + <sitename>wikisvn</sitename> + <base>http://localhost/wiki-svn/index.php/Main_Page</base> + <generator>MediaWiki 1.21alpha</generator> + <case>first-letter</case> + <namespaces> + <namespace key="-2" case="first-letter">Media</namespace> + <namespace key="-1" case="first-letter">Special</namespace> + <namespace key="0" case="first-letter" /> + <namespace key="1" case="first-letter">Talk</namespace> + <namespace key="2" case="first-letter">User</namespace> + <namespace key="3" case="first-letter">User talk</namespace> + <namespace key="4" case="first-letter">Wikisvn</namespace> + <namespace key="5" case="first-letter">Wikisvn talk</namespace> + <namespace key="6" case="first-letter">File</namespace> + <namespace key="7" case="first-letter">File talk</namespace> + <namespace key="8" case="first-letter">MediaWiki</namespace> + <namespace key="9" case="first-letter">MediaWiki talk</namespace> + <namespace key="10" case="first-letter">Template</namespace> + <namespace key="11" case="first-letter">Template talk</namespace> + <namespace key="12" case="first-letter">Help</namespace> + <namespace key="13" case="first-letter">Help talk</namespace> + <namespace key="14" case="first-letter">Category</namespace> + <namespace key="15" case="first-letter">Category talk</namespace> + </namespaces> + </siteinfo> +'; + // @codingStandardsIgnoreEnd + + // An array holding the pages that are available for prefetch + $available_pages = array(); + + // Simple plain page + $available_pages[1] = ' <page> + <title>BackupDumperTestP1 + 0 + 1 + + 1 + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP1Summary1 + 0bolhl6ol7i6x0e7yq91gxgaan39j87 + BackupDumperTestP1Text1 + 1 + 1 + + +'; + // Page with more than one revisions. Hole in rev ids. + $available_pages[2] = ' + BackupDumperTestP2 + 0 + 2 + + 2 + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary1 + jprywrymfhysqllua29tj3sc7z39dl2 + BackupDumperTestP2Text1 + 1 + 1 + + + 5 + 2 + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary4 extra + 6o1ciaxa6pybnqprmungwofc4lv00wv + BackupDumperTestP2Text4 some additional Text + 1 + 1 + + +'; + // Page with id higher than previous id + 1 + $available_pages[4] = ' + Talk:BackupDumperTestP1 + 1 + 4 + + 8 + 2012-04-01T16:46:05Z + + 127.0.0.1 + + Talk BackupDumperTestP1 Summary1 + nktofwzd0tl192k3zfepmlzxoax1lpe + 1 + 1 + Talk about BackupDumperTestP1 Text1 + + +'; + + // The common ending for all files + $tail = ' +'; + + // Putting together the content of the prefetch files + $content = $header; + foreach ( $requested_pages as $i ) { + $this->assertTrue( array_key_exists( $i, $available_pages ), + "Check for availability of requested page " . $i ); + $content .= $available_pages[$i]; + } + $content .= $tail; + + $this->assertEquals( strlen( $content ), file_put_contents( + $fname, $content ), "Length of prepared prefetch" ); + + return $fname; + } +} diff --git a/tests/phpunit/maintenance/backupTextPassTest.php b/tests/phpunit/maintenance/backupTextPassTest.php new file mode 100644 index 00000000..a37a97c7 --- /dev/null +++ b/tests/phpunit/maintenance/backupTextPassTest.php @@ -0,0 +1,584 @@ +tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'text'; + + $ns = $this->getDefaultWikitextNS(); + + try { + // Simple page + $title = Title::newFromText( 'BackupDumperTestP1', $ns ); + $page = WikiPage::factory( $title ); + list( $this->revId1_1, $this->textId1_1 ) = $this->addRevision( $page, + "BackupDumperTestP1Text1", "BackupDumperTestP1Summary1" ); + $this->pageId1 = $page->getId(); + + // Page with more than one revision + $title = Title::newFromText( 'BackupDumperTestP2', $ns ); + $page = WikiPage::factory( $title ); + list( $this->revId2_1, $this->textId2_1 ) = $this->addRevision( $page, + "BackupDumperTestP2Text1", "BackupDumperTestP2Summary1" ); + list( $this->revId2_2, $this->textId2_2 ) = $this->addRevision( $page, + "BackupDumperTestP2Text2", "BackupDumperTestP2Summary2" ); + list( $this->revId2_3, $this->textId2_3 ) = $this->addRevision( $page, + "BackupDumperTestP2Text3", "BackupDumperTestP2Summary3" ); + list( $this->revId2_4, $this->textId2_4 ) = $this->addRevision( $page, + "BackupDumperTestP2Text4 some additional Text ", + "BackupDumperTestP2Summary4 extra " ); + $this->pageId2 = $page->getId(); + + // Deleted page. + $title = Title::newFromText( 'BackupDumperTestP3', $ns ); + $page = WikiPage::factory( $title ); + list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page, + "BackupDumperTestP3Text1", "BackupDumperTestP2Summary1" ); + list( $this->revId3_2, $this->textId3_2 ) = $this->addRevision( $page, + "BackupDumperTestP3Text2", "BackupDumperTestP2Summary2" ); + $this->pageId3 = $page->getId(); + $page->doDeleteArticle( "Testing ;)" ); + + // Page from non-default namespace + + if ( $ns === NS_TALK ) { + // @todo work around this. + throw new MWException( "The default wikitext namespace is the talk namespace. " + . " We can't currently deal with that." ); + } + + $title = Title::newFromText( 'BackupDumperTestP1', NS_TALK ); + $page = WikiPage::factory( $title ); + list( $this->revId4_1, $this->textId4_1 ) = $this->addRevision( $page, + "Talk about BackupDumperTestP1 Text1", + "Talk BackupDumperTestP1 Summary1" ); + $this->pageId4 = $page->getId(); + } catch ( Exception $e ) { + // We'd love to pass $e directly. However, ... see + // documentation of exceptionFromAddDBData in + // DumpTestCase + $this->exceptionFromAddDBData = $e; + } + } + + protected function setUp() { + parent::setUp(); + + // Since we will restrict dumping by page ranges (to allow + // working tests, even if the db gets prepopulated by a base + // class), we have to assert, that the page id are consecutively + // increasing + $this->assertEquals( + array( $this->pageId2, $this->pageId3, $this->pageId4 ), + array( $this->pageId1 + 1, $this->pageId2 + 1, $this->pageId3 + 1 ), + "Page ids increasing without holes" ); + } + + function testPlain() { + // Setting up the dump + $nameStub = $this->setUpStub(); + $nameFull = $this->getNewTempFile(); + $dumper = new TextPassDumper( array( "--stub=file:" . $nameStub, + "--output=file:" . $nameFull ) ); + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + + // Checking for correctness of the dumped data + $this->assertDumpStart( $nameFull ); + + // Page 1 + $this->assertPageStart( $this->pageId1, NS_MAIN, "BackupDumperTestP1" ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87", + "BackupDumperTestP1Text1" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, NS_MAIN, "BackupDumperTestP2" ); + $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1", + $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2", + "BackupDumperTestP2Text1" ); + $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", + $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95", + "BackupDumperTestP2Text2", $this->revId2_1 ); + $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3", + $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r", + "BackupDumperTestP2Text3", $this->revId2_2 ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv", + "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( $this->pageId4, NS_TALK, "Talk:BackupDumperTestP1" ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe", + "Talk about BackupDumperTestP1 Text1" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + function testPrefetchPlain() { + // The mapping between ids and text, for the hits of the prefetch mock + $prefetchMap = array( + array( $this->pageId1, $this->revId1_1, "Prefetch_________1Text1" ), + array( $this->pageId2, $this->revId2_3, "Prefetch_________2Text3" ) + ); + + // The mock itself + $prefetchMock = $this->getMock( 'BaseDump', array( 'prefetch' ), array(), '', false ); + $prefetchMock->expects( $this->exactly( 6 ) ) + ->method( 'prefetch' ) + ->will( $this->returnValueMap( $prefetchMap ) ); + + // Setting up of the dump + $nameStub = $this->setUpStub(); + $nameFull = $this->getNewTempFile(); + $dumper = new TextPassDumper( array( "--stub=file:" + . $nameStub, "--output=file:" . $nameFull ) ); + $dumper->prefetch = $prefetchMock; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + + // Checking for correctness of the dumped data + $this->assertDumpStart( $nameFull ); + + // Page 1 + $this->assertPageStart( $this->pageId1, NS_MAIN, "BackupDumperTestP1" ); + // Prefetch kicks in. This is still the SHA-1 of the original text, + // But the actual text (with different SHA-1) comes from prefetch. + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87", + "Prefetch_________1Text1" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, NS_MAIN, "BackupDumperTestP2" ); + $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1", + $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2", + "BackupDumperTestP2Text1" ); + $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", + $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95", + "BackupDumperTestP2Text2", $this->revId2_1 ); + // Prefetch kicks in. This is still the SHA-1 of the original text, + // But the actual text (with different SHA-1) comes from prefetch. + $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3", + $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r", + "Prefetch_________2Text3", $this->revId2_2 ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv", + "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( $this->pageId4, NS_TALK, "Talk:BackupDumperTestP1" ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe", + "Talk about BackupDumperTestP1 Text1" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + /** + * Ensures that checkpoint dumps are used and written, by successively increasing the + * stub size and dumping until the duration crosses a threshold. + * + * @param string $checkpointFormat Either "file" for plain text or "gzip" for gzipped + * checkpoint files. + */ + private function checkpointHelper( $checkpointFormat = "file" ) { + // Getting temporary names + $nameStub = $this->getNewTempFile(); + $nameOutputDir = $this->getNewTempDirectory(); + + $stderr = fopen( 'php://output', 'a' ); + if ( $stderr === false ) { + $this->fail( "Could not open stream for stderr" ); + } + + $iterations = 32; // We'll start with that many iterations of revisions in stub + $lastDuration = 0; + $minDuration = 2; // We want the dump to take at least this many seconds + $checkpointAfter = 0.5; // Generate checkpoint after this many seconds + + // Until a dump takes at least $minDuration seconds, perform a dump and check + // duration. If the dump did not take long enough increase the iteration + // count, to generate a bigger stub file next time. + while ( $lastDuration < $minDuration ) { + + // Setting up the dump + wfRecursiveRemoveDir( $nameOutputDir ); + $this->assertTrue( wfMkdirParents( $nameOutputDir ), + "Creating temporary output directory " ); + $this->setUpStub( $nameStub, $iterations ); + $dumper = new TextPassDumper( array( "--stub=file:" . $nameStub, + "--output=" . $checkpointFormat . ":" . $nameOutputDir . "/full", + "--maxtime=1" /*This is in minutes. Fixup is below*/, + "--checkpointfile=checkpoint-%s-%s.xml.gz" ) ); + $dumper->setDb( $this->db ); + $dumper->maxTimeAllowed = $checkpointAfter; // Patching maxTime from 1 minute + $dumper->stderr = $stderr; + + // The actual dump and taking time + $ts_before = microtime( true ); + $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + $ts_after = microtime( true ); + $lastDuration = $ts_after - $ts_before; + + // Handling increasing the iteration count for the stubs + if ( $lastDuration < $minDuration ) { + $old_iterations = $iterations; + if ( $lastDuration > 0.2 ) { + // lastDuration is big enough, to allow an educated guess + $factor = ( $minDuration + 0.5 ) / $lastDuration; + if ( ( $factor > 1.1 ) && ( $factor < 100 ) ) { + // educated guess is reasonable + $iterations = (int)( $iterations * $factor ); + } + } + + if ( $old_iterations == $iterations ) { + // Heuristics were not applied, so we just *2. + $iterations *= 2; + } + + $this->assertLessThan( 50000, $iterations, + "Emergency stop against infinitely increasing iteration " + . "count ( last duration: $lastDuration )" ); + } + } + + // The dump (hopefully) did take long enough to produce more than one + // checkpoint file. + // + // We now check all the checkpoint files for validity. + + $files = scandir( $nameOutputDir ); + $this->assertTrue( asort( $files ), "Sorting files in temporary directory" ); + $fileOpened = false; + $lookingForPage = 1; + $checkpointFiles = 0; + + // Each run of the following loop body tries to handle exactly 1 /page/ (not + // iteration of stub content). $i is only increased after having treated page 4. + for ( $i = 0; $i < $iterations; ) { + + // 1. Assuring a file is opened and ready. Skipping across header if + // necessary. + if ( !$fileOpened ) { + $this->assertNotEmpty( $files, "No more existing dump files, " + . "but not yet all pages found" ); + $fname = array_shift( $files ); + while ( $fname == "." || $fname == ".." ) { + $this->assertNotEmpty( $files, "No more existing dump" + . " files, but not yet all pages found" ); + $fname = array_shift( $files ); + } + if ( $checkpointFormat == "gzip" ) { + $this->gunzip( $nameOutputDir . "/" . $fname ); + } + $this->assertDumpStart( $nameOutputDir . "/" . $fname ); + $fileOpened = true; + $checkpointFiles++; + } + + // 2. Performing a single page check + switch ( $lookingForPage ) { + case 1: + // Page 1 + $this->assertPageStart( $this->pageId1 + $i * self::$numOfPages, NS_MAIN, + "BackupDumperTestP1" ); + $this->assertRevision( $this->revId1_1 + $i * self::$numOfRevs, "BackupDumperTestP1Summary1", + $this->textId1_1, false, "0bolhl6ol7i6x0e7yq91gxgaan39j87", + "BackupDumperTestP1Text1" ); + $this->assertPageEnd(); + + $lookingForPage = 2; + break; + + case 2: + // Page 2 + $this->assertPageStart( $this->pageId2 + $i * self::$numOfPages, NS_MAIN, + "BackupDumperTestP2" ); + $this->assertRevision( $this->revId2_1 + $i * self::$numOfRevs, "BackupDumperTestP2Summary1", + $this->textId2_1, false, "jprywrymfhysqllua29tj3sc7z39dl2", + "BackupDumperTestP2Text1" ); + $this->assertRevision( $this->revId2_2 + $i * self::$numOfRevs, "BackupDumperTestP2Summary2", + $this->textId2_2, false, "b7vj5ks32po5m1z1t1br4o7scdwwy95", + "BackupDumperTestP2Text2", $this->revId2_1 + $i * self::$numOfRevs ); + $this->assertRevision( $this->revId2_3 + $i * self::$numOfRevs, "BackupDumperTestP2Summary3", + $this->textId2_3, false, "jfunqmh1ssfb8rs43r19w98k28gg56r", + "BackupDumperTestP2Text3", $this->revId2_2 + $i * self::$numOfRevs ); + $this->assertRevision( $this->revId2_4 + $i * self::$numOfRevs, + "BackupDumperTestP2Summary4 extra", + $this->textId2_4, false, "6o1ciaxa6pybnqprmungwofc4lv00wv", + "BackupDumperTestP2Text4 some additional Text", + $this->revId2_3 + $i * self::$numOfRevs ); + $this->assertPageEnd(); + + $lookingForPage = 4; + break; + + case 4: + // Page 4 + $this->assertPageStart( $this->pageId4 + $i * self::$numOfPages, NS_TALK, + "Talk:BackupDumperTestP1" ); + $this->assertRevision( $this->revId4_1 + $i * self::$numOfRevs, + "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, false, "nktofwzd0tl192k3zfepmlzxoax1lpe", + "Talk about BackupDumperTestP1 Text1" ); + $this->assertPageEnd(); + + $lookingForPage = 1; + + // We dealt with the whole iteration. + $i++; + break; + + default: + $this->fail( "Bad setting for lookingForPage ($lookingForPage)" ); + } + + // 3. Checking for the end of the current checkpoint file + if ( $this->xml->nodeType == XMLReader::END_ELEMENT + && $this->xml->name == "mediawiki" + ) { + $this->assertDumpEnd(); + $fileOpened = false; + } + } + + // Assuring we completely read all files ... + $this->assertFalse( $fileOpened, "Currently read file still open?" ); + $this->assertEmpty( $files, "Remaining unchecked files" ); + + // ... and have dealt with more than one checkpoint file + $this->assertGreaterThan( + 1, + $checkpointFiles, + "expected more than 1 checkpoint to have been created. " + . "Checkpoint interval is $checkpointAfter seconds, maybe your computer is too fast?" + ); + + $this->expectETAOutput(); + } + + /** + * @group large + */ + function testCheckpointPlain() { + $this->checkpointHelper(); + } + + /** + * tests for working checkpoint generation in gzip format work. + * + * We keep this test in addition to the simpler self::testCheckpointPlain, as there + * were once problems when the used sinks were DumpPipeOutputs. + * + * xmldumps-backup typically uses bzip2 instead of gzip. However, as bzip2 requires + * PHP extensions, we go for gzip instead, which triggers the same relevant code + * paths while still being testable on more systems. + * + * @group large + */ + function testCheckpointGzip() { + $this->checkHasGzip(); + $this->checkpointHelper( "gzip" ); + } + + /** + * Creates a stub file that is used for testing the text pass of dumps + * + * @param string $fname (Optional) Absolute name of the file to write + * the stub into. If this parameter is null, a new temporary + * file is generated that is automatically removed upon tearDown. + * @param int $iterations (Optional) specifies how often the block + * of 3 pages should go into the stub file. The page and + * revision id increase further and further, while the text + * id of the first iteration is reused. The pages and revision + * of iteration > 1 have no corresponding representation in the database. + * @return string Absolute filename of the stub + */ + private function setUpStub( $fname = null, $iterations = 1 ) { + if ( $fname === null ) { + $fname = $this->getNewTempFile(); + } + $header = ' + + wikisvn + http://localhost/wiki-svn/index.php/Main_Page + MediaWiki 1.21alpha + first-letter + + Media + Special + + Talk + User + User talk + Wikisvn + Wikisvn talk + File + File talk + MediaWiki + MediaWiki talk + Template + Template talk + Help + Help talk + Category + Category talk + + +'; + $tail = ' +'; + + $content = $header; + $iterations = intval( $iterations ); + for ( $i = 0; $i < $iterations; $i++ ) { + + $page1 = ' + BackupDumperTestP1 + 0 + ' . ( $this->pageId1 + $i * self::$numOfPages ) . ' + + ' . ( $this->revId1_1 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP1Summary1 + 0bolhl6ol7i6x0e7yq91gxgaan39j87 + wikitext + text/x-wiki + + + +'; + $page2 = ' + BackupDumperTestP2 + 0 + ' . ( $this->pageId2 + $i * self::$numOfPages ) . ' + + ' . ( $this->revId2_1 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary1 + jprywrymfhysqllua29tj3sc7z39dl2 + wikitext + text/x-wiki + + + + ' . ( $this->revId2_2 + $i * self::$numOfRevs ) . ' + ' . ( $this->revId2_1 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary2 + b7vj5ks32po5m1z1t1br4o7scdwwy95 + wikitext + text/x-wiki + + + + ' . ( $this->revId2_3 + $i * self::$numOfRevs ) . ' + ' . ( $this->revId2_2 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary3 + jfunqmh1ssfb8rs43r19w98k28gg56r + wikitext + text/x-wiki + + + + ' . ( $this->revId2_4 + $i * self::$numOfRevs ) . ' + ' . ( $this->revId2_3 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + BackupDumperTestP2Summary4 extra + 6o1ciaxa6pybnqprmungwofc4lv00wv + wikitext + text/x-wiki + + + +'; + // page 3 not in stub + + $page4 = ' + Talk:BackupDumperTestP1 + 1 + ' . ( $this->pageId4 + $i * self::$numOfPages ) . ' + + ' . ( $this->revId4_1 + $i * self::$numOfRevs ) . ' + 2012-04-01T16:46:05Z + + 127.0.0.1 + + Talk BackupDumperTestP1 Summary1 + nktofwzd0tl192k3zfepmlzxoax1lpe + wikitext + text/x-wiki + + + +'; + $content .= $page1 . $page2 . $page4; + } + $content .= $tail; + $this->assertEquals( strlen( $content ), file_put_contents( + $fname, $content ), "Length of prepared stub" ); + + return $fname; + } +} diff --git a/tests/phpunit/maintenance/backup_LogTest.php b/tests/phpunit/maintenance/backup_LogTest.php new file mode 100644 index 00000000..7ca45960 --- /dev/null +++ b/tests/phpunit/maintenance/backup_LogTest.php @@ -0,0 +1,225 @@ +setPerformer( $user ); + $logEntry->setTarget( Title::newFromText( $title, $ns ) ); + if ( $comment !== null ) { + $logEntry->setComment( $comment ); + } + if ( $parameters !== null ) { + $logEntry->setParameters( $parameters ); + } + + return $logEntry->insert(); + } + + function addDBData() { + $this->tablesUsed[] = 'logging'; + $this->tablesUsed[] = 'user'; + + try { + $user1 = User::newFromName( 'BackupDumperLogUserA' ); + $this->userId1 = $user1->getId(); + if ( $this->userId1 === 0 ) { + $user1->addToDatabase(); + $this->userId1 = $user1->getId(); + } + $this->assertGreaterThan( 0, $this->userId1 ); + + $user2 = User::newFromName( 'BackupDumperLogUserB' ); + $this->userId2 = $user2->getId(); + if ( $this->userId2 === 0 ) { + $user2->addToDatabase(); + $this->userId2 = $user2->getId(); + } + $this->assertGreaterThan( 0, $this->userId2 ); + + $this->logId1 = $this->addLogEntry( 'type', 'subtype', + $user1, NS_MAIN, "PageA" ); + $this->assertGreaterThan( 0, $this->logId1 ); + + $this->logId2 = $this->addLogEntry( 'supress', 'delete', + $user2, NS_TALK, "PageB", "SomeComment" ); + $this->assertGreaterThan( 0, $this->logId2 ); + + $this->logId3 = $this->addLogEntry( 'move', 'delete', + $user2, NS_MAIN, "PageA", "SomeOtherComment", + array( 'key1' => 1, 3 => 'value3' ) ); + $this->assertGreaterThan( 0, $this->logId3 ); + } catch ( Exception $e ) { + // We'd love to pass $e directly. However, ... see + // documentation of exceptionFromAddDBData in + // DumpTestCase + $this->exceptionFromAddDBData = $e; + } + } + + /** + * asserts that the xml reader is at the beginning of a log entry and skips over + * it while analyzing it. + * + * @param int $id Id of the log entry + * @param string $user_name User name of the log entry's performer + * @param int $user_id User id of the log entry 's performer + * @param string|null $comment Comment of the log entry. If null, the comment text is ignored. + * @param string $type Type of the log entry + * @param string $subtype Subtype of the log entry + * @param string $title Title of the log entry's target + * @param array $parameters (optional) unserialized data accompanying the log entry + */ + private function assertLogItem( $id, $user_name, $user_id, $comment, $type, + $subtype, $title, $parameters = array() + ) { + + $this->assertNodeStart( "logitem" ); + $this->skipWhitespace(); + + $this->assertTextNode( "id", $id ); + $this->assertTextNode( "timestamp", false ); + + $this->assertNodeStart( "contributor" ); + $this->skipWhitespace(); + $this->assertTextNode( "username", $user_name ); + $this->assertTextNode( "id", $user_id ); + $this->assertNodeEnd( "contributor" ); + $this->skipWhitespace(); + + if ( $comment !== null ) { + $this->assertTextNode( "comment", $comment ); + } + $this->assertTextNode( "type", $type ); + $this->assertTextNode( "action", $subtype ); + $this->assertTextNode( "logtitle", $title ); + + $this->assertNodeStart( "params" ); + $parameters_xml = unserialize( $this->xml->value ); + $this->assertEquals( $parameters, $parameters_xml ); + $this->assertTrue( $this->xml->read(), "Skipping past processed text of params" ); + $this->assertNodeEnd( "params" ); + $this->skipWhitespace(); + + $this->assertNodeEnd( "logitem" ); + $this->skipWhitespace(); + } + + function testPlain() { + global $wgContLang; + + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + $dumper->startId = $this->logId1; + $dumper->endId = $this->logId3 + 1; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::LOGS, WikiExporter::TEXT ); + + // Analyzing the dumped data + $this->assertDumpStart( $fname ); + + $this->assertLogItem( $this->logId1, "BackupDumperLogUserA", + $this->userId1, null, "type", "subtype", "PageA" ); + + $this->assertNotNull( $wgContLang, "Content language object validation" ); + $namespace = $wgContLang->getNsText( NS_TALK ); + $this->assertInternalType( 'string', $namespace ); + $this->assertGreaterThan( 0, strlen( $namespace ) ); + $this->assertLogItem( $this->logId2, "BackupDumperLogUserB", + $this->userId2, "SomeComment", "supress", "delete", + $namespace . ":PageB" ); + + $this->assertLogItem( $this->logId3, "BackupDumperLogUserB", + $this->userId2, "SomeOtherComment", "move", "delete", + "PageA", array( 'key1' => 1, 3 => 'value3' ) ); + + $this->assertDumpEnd(); + } + + function testXmlDumpsBackupUseCaseLogging() { + global $wgContLang; + + $this->checkHasGzip(); + + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=gzip:" . $fname, + "--reporting=2" ) ); + $dumper->startId = $this->logId1; + $dumper->endId = $this->logId3 + 1; + $dumper->setDb( $this->db ); + + // xmldumps-backup demands reporting, although this is currently not + // implemented in BackupDumper, when dumping logging data. We + // nevertheless capture the output of the dump process already now, + // to be able to alert (once dumping produces reports) that this test + // needs updates. + $dumper->stderr = fopen( 'php://output', 'a' ); + if ( $dumper->stderr === false ) { + $this->fail( "Could not open stream for stderr" ); + } + + // Performing the dump + $dumper->dump( WikiExporter::LOGS, WikiExporter::TEXT ); + + $this->assertTrue( fclose( $dumper->stderr ), "Closing stderr handle" ); + + // Analyzing the dumped data + $this->gunzip( $fname ); + + $this->assertDumpStart( $fname ); + + $this->assertLogItem( $this->logId1, "BackupDumperLogUserA", + $this->userId1, null, "type", "subtype", "PageA" ); + + $this->assertNotNull( $wgContLang, "Content language object validation" ); + $namespace = $wgContLang->getNsText( NS_TALK ); + $this->assertInternalType( 'string', $namespace ); + $this->assertGreaterThan( 0, strlen( $namespace ) ); + $this->assertLogItem( $this->logId2, "BackupDumperLogUserB", + $this->userId2, "SomeComment", "supress", "delete", + $namespace . ":PageB" ); + + $this->assertLogItem( $this->logId3, "BackupDumperLogUserB", + $this->userId2, "SomeOtherComment", "move", "delete", + "PageA", array( 'key1' => 1, 3 => 'value3' ) ); + + $this->assertDumpEnd(); + + // Currently, no reporting is implemented. Alert via failure, once + // this changes. + // If reporting for log dumps has been implemented, please update + // the following statement to catch good output + $this->expectOutputString( '' ); + } +} diff --git a/tests/phpunit/maintenance/backup_PageTest.php b/tests/phpunit/maintenance/backup_PageTest.php new file mode 100644 index 00000000..0cb0cdb6 --- /dev/null +++ b/tests/phpunit/maintenance/backup_PageTest.php @@ -0,0 +1,428 @@ +setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ), + ) ); + + $this->tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'text'; + + try { + $this->namespace = $this->getDefaultWikitextNS(); + $this->talk_namespace = NS_TALK; + + if ( $this->namespace === $this->talk_namespace ) { + // @todo work around this. + throw new MWException( "The default wikitext namespace is the talk namespace. " + . " We can't currently deal with that." ); + } + + $this->pageTitle1 = Title::newFromText( 'BackupDumperTestP1', $this->namespace ); + $page = WikiPage::factory( $this->pageTitle1 ); + list( $this->revId1_1, $this->textId1_1 ) = $this->addRevision( $page, + "BackupDumperTestP1Text1", "BackupDumperTestP1Summary1" ); + $this->pageId1 = $page->getId(); + + $this->pageTitle2 = Title::newFromText( 'BackupDumperTestP2', $this->namespace ); + $page = WikiPage::factory( $this->pageTitle2 ); + list( $this->revId2_1, $this->textId2_1 ) = $this->addRevision( $page, + "BackupDumperTestP2Text1", "BackupDumperTestP2Summary1" ); + list( $this->revId2_2, $this->textId2_2 ) = $this->addRevision( $page, + "BackupDumperTestP2Text2", "BackupDumperTestP2Summary2" ); + list( $this->revId2_3, $this->textId2_3 ) = $this->addRevision( $page, + "BackupDumperTestP2Text3", "BackupDumperTestP2Summary3" ); + list( $this->revId2_4, $this->textId2_4 ) = $this->addRevision( $page, + "BackupDumperTestP2Text4 some additional Text ", + "BackupDumperTestP2Summary4 extra " ); + $this->pageId2 = $page->getId(); + + $this->pageTitle3 = Title::newFromText( 'BackupDumperTestP3', $this->namespace ); + $page = WikiPage::factory( $this->pageTitle3 ); + list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page, + "BackupDumperTestP3Text1", "BackupDumperTestP2Summary1" ); + list( $this->revId3_2, $this->textId3_2 ) = $this->addRevision( $page, + "BackupDumperTestP3Text2", "BackupDumperTestP2Summary2" ); + $this->pageId3 = $page->getId(); + $page->doDeleteArticle( "Testing ;)" ); + + $this->pageTitle4 = Title::newFromText( 'BackupDumperTestP1', $this->talk_namespace ); + $page = WikiPage::factory( $this->pageTitle4 ); + list( $this->revId4_1, $this->textId4_1 ) = $this->addRevision( $page, + "Talk about BackupDumperTestP1 Text1", + "Talk BackupDumperTestP1 Summary1" ); + $this->pageId4 = $page->getId(); + } catch ( Exception $e ) { + // We'd love to pass $e directly. However, ... see + // documentation of exceptionFromAddDBData in + // DumpTestCase + $this->exceptionFromAddDBData = $e; + } + } + + protected function setUp() { + parent::setUp(); + + // Since we will restrict dumping by page ranges (to allow + // working tests, even if the db gets prepopulated by a base + // class), we have to assert, that the page id are consecutively + // increasing + $this->assertEquals( + array( $this->pageId2, $this->pageId3, $this->pageId4 ), + array( $this->pageId1 + 1, $this->pageId2 + 1, $this->pageId3 + 1 ), + "Page ids increasing without holes" ); + } + + function testFullTextPlain() { + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + $dumper->startId = $this->pageId1; + $dumper->endId = $this->pageId4 + 1; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::FULL, WikiExporter::TEXT ); + + // Checking the dumped data + $this->assertDumpStart( $fname ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87", + "BackupDumperTestP1Text1" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1", + $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2", + "BackupDumperTestP2Text1" ); + $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", + $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95", + "BackupDumperTestP2Text2", $this->revId2_1 ); + $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3", + $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r", + "BackupDumperTestP2Text3", $this->revId2_2 ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", + "BackupDumperTestP2Text4 some additional Text", $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe", + "Talk about BackupDumperTestP1 Text1" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + function testFullStubPlain() { + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + $dumper->startId = $this->pageId1; + $dumper->endId = $this->pageId4 + 1; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::FULL, WikiExporter::STUB ); + + // Checking the dumped data + $this->assertDumpStart( $fname ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1", + $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2" ); + $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", + $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95", false, $this->revId2_1 ); + $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3", + $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r", false, $this->revId2_2 ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + function testCurrentStubPlain() { + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=file:" . $fname ) ); + $dumper->startId = $this->pageId1; + $dumper->endId = $this->pageId4 + 1; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::CURRENT, WikiExporter::STUB ); + + // Checking the dumped data + $this->assertDumpStart( $fname ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + function testCurrentStubGzip() { + $this->checkHasGzip(); + + // Preparing the dump + $fname = $this->getNewTempFile(); + $dumper = new BackupDumper( array( "--output=gzip:" . $fname ) ); + $dumper->startId = $this->pageId1; + $dumper->endId = $this->pageId4 + 1; + $dumper->reporting = false; + $dumper->setDb( $this->db ); + + // Performing the dump + $dumper->dump( WikiExporter::CURRENT, WikiExporter::STUB ); + + // Checking the dumped data + $this->gunzip( $fname ); + $this->assertDumpStart( $fname ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + } + + function testXmlDumpsBackupUseCase() { + // xmldumps-backup typically performs a single dump that that writes + // out three files + // * gzipped stubs of everything (meta-history) + // * gzipped stubs of latest revisions of all pages (meta-current) + // * gzipped stubs of latest revisions of all pages of namespage 0 + // (articles) + // + // We reproduce such a setup with our mini fixture, although we omit + // chunks, and all the other gimmicks of xmldumps-backup. + // + $this->checkHasGzip(); + + $fnameMetaHistory = $this->getNewTempFile(); + $fnameMetaCurrent = $this->getNewTempFile(); + $fnameArticles = $this->getNewTempFile(); + + $dumper = new BackupDumper( array( "--output=gzip:" . $fnameMetaHistory, + "--output=gzip:" . $fnameMetaCurrent, "--filter=latest", + "--output=gzip:" . $fnameArticles, "--filter=latest", + "--filter=notalk", "--filter=namespace:!NS_USER", + "--reporting=1000" ) ); + $dumper->startId = $this->pageId1; + $dumper->endId = $this->pageId4 + 1; + $dumper->setDb( $this->db ); + + // xmldumps-backup uses reporting. We will not check the exact reported + // message, as they are dependent on the processing power of the used + // computer. We only check that reporting does not crash the dumping + // and that something is reported + $dumper->stderr = fopen( 'php://output', 'a' ); + if ( $dumper->stderr === false ) { + $this->fail( "Could not open stream for stderr" ); + } + + // Performing the dump + $dumper->dump( WikiExporter::FULL, WikiExporter::STUB ); + + $this->assertTrue( fclose( $dumper->stderr ), "Closing stderr handle" ); + + // Checking meta-history ------------------------------------------------- + + $this->gunzip( $fnameMetaHistory ); + $this->assertDumpStart( $fnameMetaHistory ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_1, "BackupDumperTestP2Summary1", + $this->textId2_1, 23, "jprywrymfhysqllua29tj3sc7z39dl2" ); + $this->assertRevision( $this->revId2_2, "BackupDumperTestP2Summary2", + $this->textId2_2, 23, "b7vj5ks32po5m1z1t1br4o7scdwwy95", false, $this->revId2_1 ); + $this->assertRevision( $this->revId2_3, "BackupDumperTestP2Summary3", + $this->textId2_3, 23, "jfunqmh1ssfb8rs43r19w98k28gg56r", false, $this->revId2_2 ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + + // Checking meta-current ------------------------------------------------- + + $this->gunzip( $fnameMetaCurrent ); + $this->assertDumpStart( $fnameMetaCurrent ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + $this->assertPageStart( + $this->pageId4, + $this->talk_namespace, + $this->pageTitle4->getPrefixedText() + ); + $this->assertRevision( $this->revId4_1, "Talk BackupDumperTestP1 Summary1", + $this->textId4_1, 35, "nktofwzd0tl192k3zfepmlzxoax1lpe" ); + $this->assertPageEnd(); + + $this->assertDumpEnd(); + + // Checking articles ------------------------------------------------- + + $this->gunzip( $fnameArticles ); + $this->assertDumpStart( $fnameArticles ); + + // Page 1 + $this->assertPageStart( $this->pageId1, $this->namespace, $this->pageTitle1->getPrefixedText() ); + $this->assertRevision( $this->revId1_1, "BackupDumperTestP1Summary1", + $this->textId1_1, 23, "0bolhl6ol7i6x0e7yq91gxgaan39j87" ); + $this->assertPageEnd(); + + // Page 2 + $this->assertPageStart( $this->pageId2, $this->namespace, $this->pageTitle2->getPrefixedText() ); + $this->assertRevision( $this->revId2_4, "BackupDumperTestP2Summary4 extra", + $this->textId2_4, 44, "6o1ciaxa6pybnqprmungwofc4lv00wv", false, $this->revId2_3 ); + $this->assertPageEnd(); + + // Page 3 + // -> Page is marked deleted. Hence not visible + + // Page 4 + // -> Page is not in $this->namespace. Hence not visible + + $this->assertDumpEnd(); + + $this->expectETAOutput(); + } +} diff --git a/tests/phpunit/maintenance/fetchTextTest.php b/tests/phpunit/maintenance/fetchTextTest.php new file mode 100644 index 00000000..4e38418a --- /dev/null +++ b/tests/phpunit/maintenance/fetchTextTest.php @@ -0,0 +1,261 @@ + 0 ); + + /** + * Data for the fake stdin + * + * @param string $stdin The string to be used instead of stdin + */ + function mockStdin( $stdin ) { + $this->mockStdinText = $stdin; + $this->mockSetUp = true; + } + + /** + * Gets invocation counters for mocked methods. + * + * @return array An array, whose keys are function names. The corresponding values + * denote the number of times the function has been invoked. + */ + function mockGetInvocations() { + return $this->mockInvocations; + } + + // ----------------------------------------------------------------- + // Mocked functions from FetchText follow. + + function getStdin( $len = null ) { + $this->mockInvocations['getStdin']++; + if ( $len !== null ) { + throw new PHPUnit_Framework_ExpectationFailedException( + "Tried to get stdin with non null parameter" ); + } + + if ( !$this->mockSetUp ) { + throw new PHPUnit_Framework_ExpectationFailedException( + "Tried to get stdin before setting up rerouting" ); + } + + return fopen( 'data://text/plain,' . $this->mockStdinText, 'r' ); + } +} + +/** + * TestCase for FetchText + * + * @group Database + * @group Dump + * @covers FetchText + */ +class FetchTextTest extends MediaWikiTestCase { + + // We add 5 Revisions for this test. Their corresponding text id's + // are stored in the following 5 variables. + private $textId1; + private $textId2; + private $textId3; + private $textId4; + private $textId5; + + /** + * @var Exception|null As the current MediaWikiTestCase::run is not + * robust enough to recover from thrown exceptions directly, we cannot + * throw frow within addDBData, although it would be appropriate. Hence, + * we catch the exception and store it until we are in setUp and may + * finally rethrow the exception without crashing the test suite. + */ + private $exceptionFromAddDBData; + + /** + * @var FetchText The (mocked) FetchText that is to test + */ + private $fetchText; + + /** + * Adds a revision to a page, while returning the resuting text's id + * + * @param WikiPage $page The page to add the revision to + * @param string $text The revisions text + * @param string $summary The revisions summare + * @return int + * @throws MWException + */ + private function addRevision( $page, $text, $summary ) { + $status = $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + $summary + ); + + if ( $status->isGood() ) { + $value = $status->getValue(); + $revision = $value['revision']; + $id = $revision->getTextId(); + + if ( $id > 0 ) { + return $id; + } + } + + throw new MWException( "Could not determine text id" ); + } + + function addDBData() { + $this->tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'text'; + + $wikitextNamespace = $this->getDefaultWikitextNS(); + + try { + $title = Title::newFromText( 'FetchTextTestPage1', $wikitextNamespace ); + $page = WikiPage::factory( $title ); + $this->textId1 = $this->addRevision( + $page, + "FetchTextTestPage1Text1", + "FetchTextTestPage1Summary1" + ); + + $title = Title::newFromText( 'FetchTextTestPage2', $wikitextNamespace ); + $page = WikiPage::factory( $title ); + $this->textId2 = $this->addRevision( + $page, + "FetchTextTestPage2Text1", + "FetchTextTestPage2Summary1" + ); + $this->textId3 = $this->addRevision( + $page, + "FetchTextTestPage2Text2", + "FetchTextTestPage2Summary2" + ); + $this->textId4 = $this->addRevision( + $page, + "FetchTextTestPage2Text3", + "FetchTextTestPage2Summary3" + ); + $this->textId5 = $this->addRevision( + $page, + "FetchTextTestPage2Text4 some additional Text ", + "FetchTextTestPage2Summary4 extra " + ); + } catch ( Exception $e ) { + // We'd love to pass $e directly. However, ... see + // documentation of exceptionFromAddDBData + $this->exceptionFromAddDBData = $e; + } + } + + protected function setUp() { + parent::setUp(); + + // Check if any Exception is stored for rethrowing from addDBData + if ( $this->exceptionFromAddDBData !== null ) { + throw $this->exceptionFromAddDBData; + } + + $this->fetchText = new SemiMockedFetchText(); + } + + /** + * Helper to relate FetchText's input and output + * @param string $input + * @param string $expectedOutput + */ + private function assertFilter( $input, $expectedOutput ) { + $this->fetchText->mockStdin( $input ); + $this->fetchText->execute(); + $invocations = $this->fetchText->mockGetInvocations(); + $this->assertEquals( 1, $invocations['getStdin'], + "getStdin invocation counter" ); + $this->expectOutputString( $expectedOutput ); + } + + // Instead of the following functions, a data provider would be great. + // However, as data providers are evaluated /before/ addDBData, a data + // provider would not know the required ids. + + function testExistingSimple() { + $this->assertFilter( $this->textId2, + $this->textId2 . "\n23\nFetchTextTestPage2Text1" ); + } + + function testExistingSimpleWithNewline() { + $this->assertFilter( $this->textId2 . "\n", + $this->textId2 . "\n23\nFetchTextTestPage2Text1" ); + } + + function testExistingSeveral() { + $this->assertFilter( "$this->textId1\n$this->textId5\n" + . "$this->textId3\n$this->textId3", + implode( "", array( + $this->textId1 . "\n23\nFetchTextTestPage1Text1", + $this->textId5 . "\n44\nFetchTextTestPage2Text4 " + . "some additional Text", + $this->textId3 . "\n23\nFetchTextTestPage2Text2", + $this->textId3 . "\n23\nFetchTextTestPage2Text2" + ) ) ); + } + + function testEmpty() { + $this->assertFilter( "", null ); + } + + function testNonExisting() { + $this->assertFilter( $this->textId5 + 10, ( $this->textId5 + 10 ) . "\n-1\n" ); + } + + function testNegativeInteger() { + $this->assertFilter( "-42", "-42\n-1\n" ); + } + + function testFloatingPointNumberExisting() { + // float -> int -> revision + $this->assertFilter( $this->textId3 + 0.14159, + $this->textId3 . "\n23\nFetchTextTestPage2Text2" ); + } + + function testFloatingPointNumberNonExisting() { + $this->assertFilter( $this->textId5 + 3.14159, + ( $this->textId5 + 3 ) . "\n-1\n" ); + } + + function testCharacters() { + $this->assertFilter( "abc", "0\n-1\n" ); + } + + function testMix() { + $this->assertFilter( "ab\n" . $this->textId4 . ".5cd\n\nefg\n" . $this->textId2 + . "\n" . $this->textId3, + implode( "", array( + "0\n-1\n", + $this->textId4 . "\n23\nFetchTextTestPage2Text3", + "0\n-1\n", + "0\n-1\n", + $this->textId2 . "\n23\nFetchTextTestPage2Text1", + $this->textId3 . "\n23\nFetchTextTestPage2Text2" + ) ) ); + } +} diff --git a/tests/phpunit/mocks/filebackend/MockFSFile.php b/tests/phpunit/mocks/filebackend/MockFSFile.php new file mode 100644 index 00000000..e0463281 --- /dev/null +++ b/tests/phpunit/mocks/filebackend/MockFSFile.php @@ -0,0 +1,69 @@ + $this->exists(), + 'size' => $this->getSize(), + 'file-mime' => $this->getMimeType(), + 'sha1' => $this->getSha1Base36(), + ); + } + + public function getSha1Base36( $recache = false ) { + return '1234567890123456789012345678901'; + } +} diff --git a/tests/phpunit/mocks/filebackend/MockFileBackend.php b/tests/phpunit/mocks/filebackend/MockFileBackend.php new file mode 100644 index 00000000..de8590e3 --- /dev/null +++ b/tests/phpunit/mocks/filebackend/MockFileBackend.php @@ -0,0 +1,39 @@ + + */ + +/** + * Class simulating a backend store. + * + * @ingroup FileBackend + * @since 1.22 + */ +class MockFileBackend extends MemoryFileBackend { + protected function doGetLocalCopyMulti( array $params ) { + $tmpFiles = array(); // (path => MockFSFile) + foreach ( $params['srcs'] as $src ) { + $tmpFiles[$src] = new MockFSFile( wfTempDir() . '/' . wfRandomString( 32 ) ); + } + return $tmpFiles; + } +} diff --git a/tests/phpunit/mocks/media/MockBitmapHandler.php b/tests/phpunit/mocks/media/MockBitmapHandler.php new file mode 100644 index 00000000..38cacf9f --- /dev/null +++ b/tests/phpunit/mocks/media/MockBitmapHandler.php @@ -0,0 +1,32 @@ +getClientScalingThumbnailImage( $image, $scalerParams ); + } +} diff --git a/tests/phpunit/mocks/media/MockDjVuHandler.php b/tests/phpunit/mocks/media/MockDjVuHandler.php new file mode 100644 index 00000000..31cb13dc --- /dev/null +++ b/tests/phpunit/mocks/media/MockDjVuHandler.php @@ -0,0 +1,49 @@ +normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + $width = $params['width']; + $height = $params['height']; + $page = $params['page']; + if ( $page > $this->pageCount( $image ) ) { + return new MediaTransformError( + 'thumbnail_error', + $width, + $height, + wfMessage( 'djvu_page_error' )->text() + ); + } + + $params = array( + 'width' => $width, + 'height' => $height, + 'page' => $page + ); + + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } +} diff --git a/tests/phpunit/mocks/media/MockImageHandler.php b/tests/phpunit/mocks/media/MockImageHandler.php new file mode 100644 index 00000000..e0a72fd6 --- /dev/null +++ b/tests/phpunit/mocks/media/MockImageHandler.php @@ -0,0 +1,86 @@ +normaliseParams( $image, $params ); + + $scalerParams = array( + # The size to which the image will be resized + 'physicalWidth' => $params['physicalWidth'], + 'physicalHeight' => $params['physicalHeight'], + 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}", + # The size of the image on the page + 'clientWidth' => $params['width'], + 'clientHeight' => $params['height'], + # Comment as will be added to the EXIF of the thumbnail + 'comment' => isset( $params['descriptionUrl'] ) ? + "File source: {$params['descriptionUrl']}" : '', + # Properties of the original image + 'srcWidth' => $image->getWidth(), + 'srcHeight' => $image->getHeight(), + 'mimeType' => $image->getMimeType(), + 'dstPath' => $dstPath, + 'dstUrl' => $dstUrl, + ); + + # In some cases, we do not bother generating a thumbnail. + if ( !$image->mustRender() && + $scalerParams['physicalWidth'] == $scalerParams['srcWidth'] + && $scalerParams['physicalHeight'] == $scalerParams['srcHeight'] + ) { + wfDebug( __METHOD__ . ": returning unscaled image\n" ); + // getClientScalingThumbnailImage is protected + return $that->doClientImage( $image, $scalerParams ); + } + + return new ThumbnailImage( $image, $dstUrl, false, $params ); + } +} diff --git a/tests/phpunit/mocks/media/MockSvgHandler.php b/tests/phpunit/mocks/media/MockSvgHandler.php new file mode 100644 index 00000000..21520c44 --- /dev/null +++ b/tests/phpunit/mocks/media/MockSvgHandler.php @@ -0,0 +1,28 @@ + false, + 'file' => false, + 'use-filebackend' => false, + 'use-bagostuff' => false, + 'use-jobqueue' => false, + 'keep-uploads' => false, + 'use-normal-tables' => false, + 'reuse-db' => false, + 'wiki' => false, + ); + + public function __construct() { + parent::__construct(); + $this->addOption( + 'with-phpunitdir', + 'Directory to include PHPUnit from, for example when using a git ' + . 'fetchout from upstream. Path will be prepended to PHP `include_path`.', + false, # not required + true # need arg + ); + $this->addOption( + 'debug-tests', + 'Log testing activity to the PHPUnitCommand log channel.', + false, # not required + false # no arg needed + ); + $this->addOption( 'regex', 'Only run parser tests that match the given regex.', false, true ); + $this->addOption( 'file', 'File describing parser tests.', false, true ); + $this->addOption( 'use-filebackend', 'Use filebackend', false, true ); + $this->addOption( 'use-bagostuff', 'Use bagostuff', false, true ); + $this->addOption( 'use-jobqueue', 'Use jobqueue', false, true ); + $this->addOption( 'keep-uploads', 'Re-use the same upload directory for each test, don\'t delete it.', false, false ); + $this->addOption( 'use-normal-tables', 'Use normal DB tables.', false, false ); + $this->addOption( 'reuse-db', 'Init DB only if tables are missing and keep after finish.', false, false ); + } + + public function finalSetup() { + parent::finalSetup(); + + global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType; + global $wgLanguageConverterCacheType, $wgUseDatabaseMessages; + global $wgLocaltimezone, $wgLocalisationCacheConf; + global $wgDevelopmentWarnings; + + // Inject test autoloader + require_once __DIR__ . '/../TestsAutoLoader.php'; + + // wfWarn should cause tests to fail + $wgDevelopmentWarnings = true; + + $wgMainCacheType = CACHE_NONE; + $wgMessageCacheType = CACHE_NONE; + $wgParserCacheType = CACHE_NONE; + $wgLanguageConverterCacheType = CACHE_NONE; + + $wgUseDatabaseMessages = false; # Set for future resets + + // Assume UTC for testing purposes + $wgLocaltimezone = 'UTC'; + + $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull'; + + // Bug 44192 Do not attempt to send a real e-mail + Hooks::clear( 'AlternateUserMailer' ); + Hooks::register( + 'AlternateUserMailer', + function () { + return false; + } + ); + // xdebug's default of 100 is too low for MediaWiki + ini_set( 'xdebug.max_nesting_level', 1000 ); + } + + public function execute() { + global $IP; + + $this->forceFormatServerArgv(); + + # Make sure we have --configuration or PHPUnit might complain + if ( !in_array( '--configuration', $_SERVER['argv'] ) ) { + //Hack to eliminate the need to use the Makefile (which sucks ATM) + array_splice( $_SERVER['argv'], 1, 0, + array( '--configuration', $IP . '/tests/phpunit/suite.xml' ) ); + } + + # --with-phpunitdir let us override the default PHPUnit version + # Can use with either or phpunit.phar in the directory or the + # full PHPUnit code base. + if ( $this->hasOption( 'with-phpunitdir' ) ) { + $phpunitDir = $this->getOption( 'with-phpunitdir' ); + + # prepends provided PHPUnit directory or phar + $this->output( "Will attempt loading PHPUnit from `$phpunitDir`\n" ); + set_include_path( $phpunitDir . PATH_SEPARATOR . get_include_path() ); + + # Cleanup $args array so the option and its value do not + # pollute PHPUnit + $key = array_search( '--with-phpunitdir', $_SERVER['argv'] ); + unset( $_SERVER['argv'][$key] ); // the option + unset( $_SERVER['argv'][$key + 1] ); // its value + $_SERVER['argv'] = array_values( $_SERVER['argv'] ); + } + + if ( !wfIsWindows() ) { + # If we are not running on windows then we can enable phpunit colors + # Windows does not come anymore with ANSI.SYS loaded by default + # PHPUnit uses the suite.xml parameters to enable/disable colors + # which can be then forced to be enabled with --colors. + # The below code injects a parameter just like if the user called + # Probably fix bug 29226 + $key = array_search( '--colors', $_SERVER['argv'] ); + if ( $key === false ) { + array_splice( $_SERVER['argv'], 1, 0, '--colors' ); + } + } + + # Makes MediaWiki PHPUnit directory includable so the PHPUnit will + # be able to resolve relative files inclusion such as suites/* + # PHPUnit uses stream_resolve_include_path() internally + # See bug 32022 + $key = array_search( '--include-path', $_SERVER['argv'] ); + if ( $key === false ) { + array_splice( $_SERVER['argv'], 1, 0, + __DIR__ + . PATH_SEPARATOR + . get_include_path() + ); + array_splice( $_SERVER['argv'], 1, 0, '--include-path' ); + } + + $key = array_search( '--debug-tests', $_SERVER['argv'] ); + if ( $key !== false && array_search( '--printer', $_SERVER['argv'] ) === false ) { + unset( $_SERVER['argv'][$key] ); + array_splice( $_SERVER['argv'], 1, 0, 'MediaWikiPHPUnitTestListener' ); + array_splice( $_SERVER['argv'], 1, 0, '--printer' ); + } + + foreach ( self::$additionalOptions as $option => $default ) { + $key = array_search( '--' . $option, $_SERVER['argv'] ); + if ( $key !== false ) { + unset( $_SERVER['argv'][$key] ); + if ( $this->mParams[$option]['withArg'] ) { + self::$additionalOptions[$option] = $_SERVER['argv'][$key + 1]; + unset( $_SERVER['argv'][$key + 1] ); + } else { + self::$additionalOptions[$option] = true; + } + } + } + + } + + public function getDbType() { + return Maintenance::DB_ADMIN; + } + + /** + * Force the format of elements in $_SERVER['argv'] + * - Split args such as "wiki=enwiki" into two separate arg elements "wiki" and "enwiki" + */ + private function forceFormatServerArgv() { + $argv = array(); + foreach ( $_SERVER['argv'] as $key => $arg ) { + if ( $key === 0 ) { + $argv[0] = $arg; + } elseif ( strstr( $arg, '=' ) ) { + foreach ( explode( '=', $arg, 2 ) as $argPart ) { + $argv[] = $argPart; + } + } else { + $argv[] = $arg; + } + } + $_SERVER['argv'] = $argv; + } + +} + +$maintClass = 'PHPUnitMaintClass'; +require RUN_MAINTENANCE_IF_MAIN; + +// Prevent segfault when we have lots of unit tests (bug 62623) +if ( version_compare( PHP_VERSION, '5.4.0', '<' ) ) { + register_shutdown_function( function () { + gc_collect_cycles(); + gc_disable(); + } ); +} + + +$ok = false; + +foreach ( array( + stream_resolve_include_path( 'phpunit.phar' ), + 'PHPUnit/Runner/Version.php', + 'PHPUnit/Autoload.php' +) as $includePath ) { + @include_once $includePath; + if ( class_exists( 'PHPUnit_TextUI_Command' ) ) { + $ok = true; + break; + } +} + +if ( !$ok ) { + die( "Couldn't find a usable PHPUnit.\n" ); +} + +$puVersion = PHPUnit_Runner_Version::id(); +if ( $puVersion !== '@package_version@' && version_compare( $puVersion, '3.7.0', '<' ) ) { + die( "PHPUnit 3.7.0 or later required; you have {$puVersion}.\n" ); +} + +PHPUnit_TextUI_Command::main(); diff --git a/tests/phpunit/run-tests.bat b/tests/phpunit/run-tests.bat new file mode 100644 index 00000000..e6eb3e0c --- /dev/null +++ b/tests/phpunit/run-tests.bat @@ -0,0 +1 @@ +php phpunit.php --configuration suite.xml %* diff --git a/tests/phpunit/skins/SideBarTest.php b/tests/phpunit/skins/SideBarTest.php new file mode 100644 index 00000000..a3122b94 --- /dev/null +++ b/tests/phpunit/skins/SideBarTest.php @@ -0,0 +1,219 @@ +messages array */ + private function initMessagesHref() { + # List of default messages for the sidebar. The sidebar doesn't care at + # all whether they are full URLs, interwiki links or local titles. + $URL_messages = array( + 'mainpage', + 'portal-url', + 'currentevents-url', + 'recentchanges-url', + 'randompage-url', + 'helppage', + ); + + # We're assuming that isValidURI works as advertised: it's also + # tested separately, in tests/phpunit/includes/HttpTest.php. + foreach ( $URL_messages as $m ) { + $titleName = MessageCache::singleton()->get( $m ); + if ( Http::isValidURI( $titleName ) ) { + $this->messages[$m]['href'] = $titleName; + } else { + $title = Title::newFromText( $titleName ); + $this->messages[$m]['href'] = $title->getLocalURL(); + } + } + } + + protected function setUp() { + parent::setUp(); + $this->initMessagesHref(); + $this->skin = new SkinTemplate(); + $this->skin->getContext()->setLanguage( Language::factory( 'en' ) ); + } + + /** + * Internal helper to test the sidebar + * @param array $expected + * @param string $text + * @param string $message (Default: '') + * @todo this assert method to should be converted to a test using a dataprovider.. + */ + private function assertSideBar( $expected, $text, $message = '' ) { + $bar = array(); + $this->skin->addToSidebarPlain( $bar, $text ); + $this->assertEquals( $expected, $bar, $message ); + } + + /** + * @covers SkinTemplate::addToSidebarPlain + */ + public function testSidebarWithOnlyTwoTitles() { + $this->assertSideBar( + array( + 'Title1' => array(), + 'Title2' => array(), + ), + '* Title1 +* Title2 +' + ); + } + + /** + * @covers SkinTemplate::addToSidebarPlain + */ + public function testExpandMessages() { + $this->assertSidebar( + array( 'Title' => array( + array( + 'text' => 'Help', + 'href' => $this->messages['helppage']['href'], + 'id' => 'n-help', + 'active' => null + ) + ) ), + '* Title +** helppage|help +' + ); + } + + /** + * @covers SkinTemplate::addToSidebarPlain + */ + public function testExternalUrlsRequireADescription() { + $this->setMwGlobals( array( + 'wgNoFollowLinks' => true, + 'wgNoFollowDomainExceptions' => array(), + 'wgNoFollowNsExceptions' => array(), + ) ); + $this->assertSidebar( + array( 'Title' => array( + # ** http://www.mediawiki.org/| Home + array( + 'text' => 'Home', + 'href' => 'http://www.mediawiki.org/', + 'id' => 'n-Home', + 'active' => null, + 'rel' => 'nofollow', + ), + # ** http://valid.no.desc.org/ + # ... skipped since it is missing a pipe with a description + ) ), + '* Title +** http://www.mediawiki.org/| Home +** http://valid.no.desc.org/ +' + ); + } + + /** + * bug 33321 - Make sure there's a | after transforming. + * @group Database + * @covers SkinTemplate::addToSidebarPlain + */ + public function testTrickyPipe() { + $this->assertSidebar( + array( 'Title' => array( + # The first 2 are skipped + # Doesn't really test the url properly + # because it will vary with $wgArticlePath et al. + # ** Baz|Fred + array( + 'text' => 'Fred', + 'href' => Title::newFromText( 'Baz' )->getLocalURL(), + 'id' => 'n-Fred', + 'active' => null, + ), + array( + 'text' => 'title-to-display', + 'href' => Title::newFromText( 'page-to-go-to' )->getLocalURL(), + 'id' => 'n-title-to-display', + 'active' => null, + ), + ) ), + '* Title +** {{PAGENAME|Foo}} +** Bar +** Baz|Fred +** {{PLURAL:1|page-to-go-to{{int:pipe-separator/en}}title-to-display|branch not taken}} +' + ); + } + + #### Attributes for external links ########################## + private function getAttribs() { + # Sidebar text we will use everytime + $text = '* Title +** http://www.mediawiki.org/| Home'; + + $bar = array(); + $this->skin->addToSideBarPlain( $bar, $text ); + + return $bar['Title'][0]; + } + + /** + * Simple test to verify our helper assertAttribs() is functional + */ + public function testTestAttributesAssertionHelper() { + $this->setMwGlobals( array( + 'wgNoFollowLinks' => true, + 'wgNoFollowDomainExceptions' => array(), + 'wgNoFollowNsExceptions' => array(), + 'wgExternalLinkTarget' => false, + ) ); + $attribs = $this->getAttribs(); + + $this->assertArrayHasKey( 'rel', $attribs ); + $this->assertEquals( 'nofollow', $attribs['rel'] ); + + $this->assertArrayNotHasKey( 'target', $attribs ); + } + + /** + * Test $wgNoFollowLinks in sidebar + */ + public function testRespectWgnofollowlinks() { + $this->setMwGlobals( 'wgNoFollowLinks', false ); + + $attribs = $this->getAttribs(); + $this->assertArrayNotHasKey( 'rel', $attribs, + 'External URL in sidebar do not have rel=nofollow when $wgNoFollowLinks = false' + ); + } + + /** + * Test $wgExternaLinkTarget in sidebar + * @dataProvider dataRespectExternallinktarget + */ + public function testRespectExternallinktarget( $externalLinkTarget ) { + $this->setMwGlobals( 'wgExternalLinkTarget', $externalLinkTarget ); + + $attribs = $this->getAttribs(); + $this->assertArrayHasKey( 'target', $attribs ); + $this->assertEquals( $attribs['target'], $externalLinkTarget ); + } + + public static function dataRespectExternallinktarget() { + return array( + array( '_blank' ), + array( '_self' ), + ); + } +} diff --git a/tests/phpunit/structure/AutoLoaderTest.php b/tests/phpunit/structure/AutoLoaderTest.php new file mode 100644 index 00000000..2bdc9c9a --- /dev/null +++ b/tests/phpunit/structure/AutoLoaderTest.php @@ -0,0 +1,135 @@ +testLocalClasses = array( + 'TestAutoloadedLocalClass' => __DIR__ . '/../data/autoloader/TestAutoloadedLocalClass.php', + 'TestAutoloadedCamlClass' => __DIR__ . '/../data/autoloader/TestAutoloadedCamlClass.php', + 'TestAutoloadedSerializedClass' => + __DIR__ . '/../data/autoloader/TestAutoloadedSerializedClass.php', + ); + $this->setMwGlobals( + 'wgAutoloadLocalClasses', + $this->testLocalClasses + $wgAutoloadLocalClasses + ); + AutoLoader::resetAutoloadLocalClassesLower(); + + $this->testExtensionClasses = array( + 'TestAutoloadedClass' => __DIR__ . '/../data/autoloader/TestAutoloadedClass.php', + ); + $this->setMwGlobals( 'wgAutoloadClasses', $this->testExtensionClasses + $wgAutoloadClasses ); + } + + /** + * Assert that there were no classes loaded that are not registered with the AutoLoader. + * + * For example foo.php having class Foo and class Bar but only registering Foo. + * This is important because we should not be relying on Foo being used before Bar. + */ + public function testAutoLoadConfig() { + $results = self::checkAutoLoadConf(); + + $this->assertEquals( + $results['expected'], + $results['actual'] + ); + } + + protected static function checkAutoLoadConf() { + global $wgAutoloadLocalClasses, $wgAutoloadClasses, $IP; + + // wgAutoloadLocalClasses has precedence, just like in includes/AutoLoader.php + $expected = $wgAutoloadLocalClasses + $wgAutoloadClasses; + $actual = array(); + + $files = array_unique( $expected ); + + foreach ( $files as $file ) { + // Only prefix $IP if it doesn't have it already. + // Generally local classes don't have it, and those from extensions and test suites do. + if ( substr( $file, 0, 1 ) != '/' && substr( $file, 1, 1 ) != ':' ) { + $filePath = "$IP/$file"; + } else { + $filePath = $file; + } + + $contents = file_get_contents( $filePath ); + + // We could use token_get_all() here, but this is faster + $matches = array(); + preg_match_all( '/ + ^ [\t ]* (?: + (?:final\s+)? (?:abstract\s+)? (?:class|interface) \s+ + (?P [a-zA-Z0-9_]+) + | + class_alias \s* \( \s* + ([\'"]) (?P [^\'"]+) \g{-2} \s* , \s* + ([\'"]) (?P [^\'"]+ ) \g{-2} \s* + \) \s* ; + ) + /imx', $contents, $matches, PREG_SET_ORDER ); + + $namespaceMatch = array(); + preg_match( '/ + ^ [\t ]* + namespace \s+ + ([a-zA-Z0-9_]+(\\\\[a-zA-Z0-9_]+)*) + \s* ; + /imx', $contents, $namespaceMatch ); + $fileNamespace = $namespaceMatch ? $namespaceMatch[1] . '\\' : ''; + + $classesInFile = array(); + $aliasesInFile = array(); + + foreach ( $matches as $match ) { + if ( !empty( $match['class'] ) ) { + $class = $fileNamespace . $match['class']; + $actual[$class] = $file; + $classesInFile[$class] = true; + } else { + $aliasesInFile[$match['alias']] = $match['original']; + } + } + + // Only accept aliases for classes in the same file, because for correct + // behavior, all aliases for a class must be set up when the class is loaded + // (see ). + foreach ( $aliasesInFile as $alias => $class ) { + if ( isset( $classesInFile[$class] ) ) { + $actual[$alias] = $file; + } else { + $actual[$alias] = "[original class not in $file]"; + } + } + } + + return array( + 'expected' => $expected, + 'actual' => $actual, + ); + } + + function testCoreClass() { + $this->assertTrue( class_exists( 'TestAutoloadedLocalClass' ) ); + } + + function testExtensionClass() { + $this->assertTrue( class_exists( 'TestAutoloadedClass' ) ); + } + + function testWrongCaseClass() { + $this->assertTrue( class_exists( 'testautoLoadedcamlCLASS' ) ); + } + + function testWrongCaseSerializedClass() { + $dummyCereal = 'O:29:"testautoloadedserializedclass":0:{}'; + $uncerealized = unserialize( $dummyCereal ); + $this->assertFalse( $uncerealized instanceof __PHP_Incomplete_Class, + "unserialize() can load classes case-insensitively." ); + } +} diff --git a/tests/phpunit/structure/ResourcesTest.php b/tests/phpunit/structure/ResourcesTest.php new file mode 100644 index 00000000..2396ea29 --- /dev/null +++ b/tests/phpunit/structure/ResourcesTest.php @@ -0,0 +1,269 @@ +assertFileExists( $filename, + "File '$resource' referenced by '$module' must exist." + ); + } + + /** + * @dataProvider provideMediaStylesheets + */ + public function testStyleMedia( $moduleName, $media, $filename, $css ) { + $cssText = CSSMin::minify( $css->cssText ); + + $this->assertTrue( + strpos( $cssText, '@media' ) === false, + 'Stylesheets should not both specify "media" and contain @media' + ); + } + + /** + * Verify that nothing explicitly depends on the 'jquery' and 'mediawiki' modules. + * They are always loaded, depending on them is unsupported and leads to unexpected behaviour. + */ + public function testIllegalDependencies() { + $data = self::getAllModules(); + $illegalDeps = array( 'jquery', 'mediawiki' ); + + /** @var ResourceLoaderModule $module */ + foreach ( $data['modules'] as $moduleName => $module ) { + foreach ( $illegalDeps as $illegalDep ) { + $this->assertNotContains( + $illegalDep, + $module->getDependencies(), + "Module '$moduleName' must not depend on '$illegalDep'" + ); + } + } + } + + /** + * Verify that all modules specified as dependencies of other modules actually exist. + */ + public function testMissingDependencies() { + $data = self::getAllModules(); + $validDeps = array_keys( $data['modules'] ); + + /** @var ResourceLoaderModule $module */ + foreach ( $data['modules'] as $moduleName => $module ) { + foreach ( $module->getDependencies() as $dep ) { + $this->assertContains( + $dep, + $validDeps, + "The module '$dep' required by '$moduleName' must exist" + ); + } + } + } + + /** + * Verify that all dependencies of all modules are always satisfiable with the 'targets' defined + * for the involved modules. + * + * Example: A depends on B. A has targets: mobile, desktop. B has targets: desktop. Therefore the + * dependency is sometimes unsatisfiable: it's impossible to load module A on mobile. + */ + public function testUnsatisfiableDependencies() { + $data = self::getAllModules(); + $validDeps = array_keys( $data['modules'] ); + + /** @var ResourceLoaderModule $module */ + foreach ( $data['modules'] as $moduleName => $module ) { + $moduleTargets = $module->getTargets(); + foreach ( $module->getDependencies() as $dep ) { + $targets = $data['modules'][$dep]->getTargets(); + foreach ( $moduleTargets as $moduleTarget ) { + $this->assertContains( + $moduleTarget, + $targets, + "The module '$moduleName' must not have target '$moduleTarget' " + . "because its dependency '$dep' does not have it" + ); + } + } + } + } + + /** + * Get all registered modules from ResouceLoader. + * @return array + */ + protected static function getAllModules() { + global $wgEnableJavaScriptTest; + + // Test existance of test suite files as well + // (can't use setUp or setMwGlobals because providers are static) + $org_wgEnableJavaScriptTest = $wgEnableJavaScriptTest; + $wgEnableJavaScriptTest = true; + + // Initialize ResourceLoader + $rl = new ResourceLoader(); + + $modules = array(); + + foreach ( $rl->getModuleNames() as $moduleName ) { + $modules[$moduleName] = $rl->getModule( $moduleName ); + } + + // Restore settings + $wgEnableJavaScriptTest = $org_wgEnableJavaScriptTest; + + return array( + 'modules' => $modules, + 'resourceloader' => $rl, + 'context' => new ResourceLoaderContext( $rl, new FauxRequest() ) + ); + } + + /** + * Get all stylesheet files from modules that are an instance of + * ResourceLoaderFileModule (or one of its subclasses). + */ + public static function provideMediaStylesheets() { + $data = self::getAllModules(); + $cases = array(); + + foreach ( $data['modules'] as $moduleName => $module ) { + if ( !$module instanceof ResourceLoaderFileModule ) { + continue; + } + + $reflectedModule = new ReflectionObject( $module ); + + $getStyleFiles = $reflectedModule->getMethod( 'getStyleFiles' ); + $getStyleFiles->setAccessible( true ); + + $readStyleFile = $reflectedModule->getMethod( 'readStyleFile' ); + $readStyleFile->setAccessible( true ); + + $styleFiles = $getStyleFiles->invoke( $module, $data['context'] ); + + $flip = $module->getFlip( $data['context'] ); + + foreach ( $styleFiles as $media => $files ) { + if ( $media && $media !== 'all' ) { + foreach ( $files as $file ) { + $cases[] = array( + $moduleName, + $media, + $file, + // XXX: Wrapped in an object to keep it out of PHPUnit output + (object)array( 'cssText' => $readStyleFile->invoke( $module, $file, $flip ) ), + ); + } + } + } + } + + return $cases; + } + + /** + * Get all resource files from modules that are an instance of + * ResourceLoaderFileModule (or one of its subclasses). + * + * Since the raw data is stored in protected properties, we have to + * overrride this through ReflectionObject methods. + */ + public static function provideResourceFiles() { + $data = self::getAllModules(); + $cases = array(); + + // See also ResourceLoaderFileModule::__construct + $filePathProps = array( + // Lists of file paths + 'lists' => array( + 'scripts', + 'debugScripts', + 'loaderScripts', + 'styles', + ), + + // Collated lists of file paths + 'nested-lists' => array( + 'languageScripts', + 'skinScripts', + 'skinStyles', + ), + ); + + foreach ( $data['modules'] as $moduleName => $module ) { + if ( !$module instanceof ResourceLoaderFileModule ) { + continue; + } + + $reflectedModule = new ReflectionObject( $module ); + + $files = array(); + + foreach ( $filePathProps['lists'] as $propName ) { + $property = $reflectedModule->getProperty( $propName ); + $property->setAccessible( true ); + $list = $property->getValue( $module ); + foreach ( $list as $key => $value ) { + // 'scripts' are numeral arrays. + // 'styles' can be numeral or associative. + // In case of associative the key is the file path + // and the value is the 'media' attribute. + if ( is_int( $key ) ) { + $files[] = $value; + } else { + $files[] = $key; + } + } + } + + foreach ( $filePathProps['nested-lists'] as $propName ) { + $property = $reflectedModule->getProperty( $propName ); + $property->setAccessible( true ); + $lists = $property->getValue( $module ); + foreach ( $lists as $list ) { + foreach ( $list as $key => $value ) { + // We need the same filter as for 'lists', + // due to 'skinStyles'. + if ( is_int( $key ) ) { + $files[] = $value; + } else { + $files[] = $key; + } + } + } + } + + // Get method for resolving the paths to full paths + $method = $reflectedModule->getMethod( 'getLocalPath' ); + $method->setAccessible( true ); + + // Populate cases + foreach ( $files as $file ) { + $cases[] = array( + $method->invoke( $module, $file ), + $moduleName, + ( $file instanceof ResourceLoaderFilePath ? $file->getPath() : $file ), + ); + } + } + + return $cases; + } +} diff --git a/tests/phpunit/structure/StructureTest.php b/tests/phpunit/structure/StructureTest.php new file mode 100644 index 00000000..14461be6 --- /dev/null +++ b/tests/phpunit/structure/StructureTest.php @@ -0,0 +1,67 @@ +markTestSkipped( 'This test does not work on Windows' ); + } + $rootPath = escapeshellarg( __DIR__ . '/..' ); + $testClassRegex = implode( '|', array( + 'ApiFormatTestBase', + 'ApiTestCase', + 'ApiQueryTestBase', + 'ApiQueryContinueTestBase', + 'MediaWikiLangTestCase', + 'MediaWikiMediaTestCase', + 'MediaWikiTestCase', + 'ResourceLoaderTestCase', + 'PHPUnit_Framework_TestCase', + 'DumpTestCase', + ) ); + $testClassRegex = "^class .* extends ($testClassRegex)"; + $finder = "find $rootPath -name '*.php' '!' -name '*Test.php'" . + " | xargs grep -El '$testClassRegex|function suite\('"; + + $results = null; + $exitCode = null; + exec( $finder, $results, $exitCode ); + + $this->assertEquals( + 0, + $exitCode, + 'Verify find/grep command succeeds.' + ); + + $results = array_filter( + $results, + array( $this, 'filterSuites' ) + ); + $strip = strlen( $rootPath ) - 1; + foreach ( $results as $k => $v ) { + $results[$k] = substr( $v, $strip ); + } + $this->assertEquals( + array(), + $results, + "Unit test file in $rootPath must end with Test." + ); + } + + /** + * Filter to remove testUnitTestFileNamesEndWithTest false positives. + * @param string $filename + * @return bool + */ + public function filterSuites( $filename ) { + return strpos( $filename, __DIR__ . '/../suites/' ) !== 0; + } +} diff --git a/tests/phpunit/suite.xml b/tests/phpunit/suite.xml new file mode 100644 index 00000000..574c11e4 --- /dev/null +++ b/tests/phpunit/suite.xml @@ -0,0 +1,64 @@ + + + + + + + includes + + + languages + + + skins + + + + maintenance + + + structure + + + suites/UploadFromUrlTestSuite.php + + + suites/ExtensionsTestSuite.php + suites/ExtensionsParserTestSuite.php + suites/LessTestSuite.php + + + + + Utility + Broken + ParserFuzz + Stub + + + + + ../../includes + ../../languages + ../../maintenance + ../../resources + ../../skins + + + diff --git a/tests/phpunit/suites/ExtensionsParserTestSuite.php b/tests/phpunit/suites/ExtensionsParserTestSuite.php new file mode 100644 index 00000000..3d68b241 --- /dev/null +++ b/tests/phpunit/suites/ExtensionsParserTestSuite.php @@ -0,0 +1,8 @@ +getFilesAsArray( $path, $suffixes ); + $this->addTestFiles( $matchingFiles ); + } else { + // Add a single test case or suite class + $this->addTestFile( $path ); + } + } + if ( !count( $paths ) ) { + $this->addTest( new DummyExtensionsTest( 'testNothing' ) ); + } + } + + public static function suite() { + return new self; + } +} + +/** + * Needed to avoid warnings like 'No tests found in class "ExtensionsTestSuite".' + * when no extensions with tests are used. + */ +class DummyExtensionsTest extends MediaWikiTestCase { + public function testNothing() { + $this->assertTrue( true ); + } +} diff --git a/tests/phpunit/suites/LessTestSuite.php b/tests/phpunit/suites/LessTestSuite.php new file mode 100644 index 00000000..26a784ad --- /dev/null +++ b/tests/phpunit/suites/LessTestSuite.php @@ -0,0 +1,34 @@ + + */ +class LessTestSuite extends PHPUnit_Framework_TestSuite { + public function __construct() { + parent::__construct(); + + $resourceLoader = new ResourceLoader(); + + foreach ( $resourceLoader->getModuleNames() as $name ) { + $module = $resourceLoader->getModule( $name ); + if ( !$module || !$module instanceof ResourceLoaderFileModule ) { + continue; + } + + foreach ( $module->getAllStyleFiles() as $styleFile ) { + // TODO (phuedx, 2014-03-19) The + // ResourceLoaderFileModule class shouldn't + // know how to get a file's extension. + if ( $module->getStyleSheetLang( $styleFile ) !== 'less' ) { + continue; + } + + $this->addTest( new LessFileCompilationTest( $styleFile, $module ) ); + } + } + } + + public static function suite() { + return new static; + } +} diff --git a/tests/phpunit/suites/UploadFromUrlTestSuite.php b/tests/phpunit/suites/UploadFromUrlTestSuite.php new file mode 100644 index 00000000..d4a7bd36 --- /dev/null +++ b/tests/phpunit/suites/UploadFromUrlTestSuite.php @@ -0,0 +1,207 @@ + 'LocalRepo', + 'name' => 'local', + 'url' => 'http://example.com/images', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => new FSFileBackend( array( + 'name' => 'local-backend', + 'wikiId' => wfWikiId(), + 'containerPaths' => array( + 'local-public' => wfTempDir() . '/test-repo/public', + 'local-thumb' => wfTempDir() . '/test-repo/thumb', + 'local-temp' => wfTempDir() . '/test-repo/temp', + 'local-deleted' => wfTempDir() . '/test-repo/delete', + ) + ) ), + ); + foreach ( $tmpGlobals as $var => $val ) { + if ( array_key_exists( $var, $GLOBALS ) ) { + $this->savedGlobals[$var] = $GLOBALS[$var]; + } + $GLOBALS[$var] = $val; + } + + $wgNamespaceProtection[NS_MEDIAWIKI] = 'editinterface'; + $wgNamespaceAliases['Image'] = NS_FILE; + $wgNamespaceAliases['Image_talk'] = NS_FILE_TALK; + + $wgEnableParserCache = false; + DeferredUpdates::clearPendingUpdates(); + $wgMemc = wfGetMainCache(); + $messageMemc = wfGetMessageCacheStorage(); + $parserMemc = wfGetParserCacheStorage(); + + $wgUser = new User; + $context = new RequestContext; + $wgLang = $context->getLanguage(); + $wgOut = $context->getOutput(); + $wgParser = new StubObject( 'wgParser', $wgParserConf['class'], array( $wgParserConf ) ); + $wgRequest = $context->getRequest(); + + if ( $wgStyleDirectory === false ) { + $wgStyleDirectory = "$IP/skins"; + } + + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + } + + protected function tearDown() { + foreach ( $this->savedGlobals as $var => $val ) { + $GLOBALS[$var] = $val; + } + // Restore backends + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + + $this->teardownUploadDir( $this->uploadDir ); + + parent::tearDown(); + } + + private $uploadDir; + private $keepUploads; + + /** + * Remove the dummy uploads directory + * @param string $dir + */ + private function teardownUploadDir( $dir ) { + if ( $this->keepUploads ) { + return; + } + + // delete the files first, then the dirs. + self::deleteFiles( + array( + "$dir/3/3a/Foobar.jpg", + "$dir/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg", + "$dir/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg", + "$dir/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg", + "$dir/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg", + + "$dir/0/09/Bad.jpg", + ) + ); + + self::deleteDirs( + array( + "$dir/3/3a", + "$dir/3", + "$dir/thumb/6/65", + "$dir/thumb/6", + "$dir/thumb/3/3a/Foobar.jpg", + "$dir/thumb/3/3a", + "$dir/thumb/3", + + "$dir/0/09/", + "$dir/0/", + + "$dir/thumb", + "$dir", + ) + ); + } + + /** + * Delete the specified files, if they exist. + * + * @param array $files Full paths to files to delete. + */ + private static function deleteFiles( $files ) { + foreach ( $files as $file ) { + if ( file_exists( $file ) ) { + unlink( $file ); + } + } + } + + /** + * Delete the specified directories, if they exist. Must be empty. + * + * @param array $dirs Full paths to directories to delete. + */ + private static function deleteDirs( $dirs ) { + foreach ( $dirs as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); + } + } + } + + /** + * Create a dummy uploads directory which will contain a couple + * of files in order to pass existence tests. + * + * @return string The directory + */ + private function setupUploadDir() { + global $IP; + + if ( $this->keepUploads ) { + $dir = wfTempDir() . '/mwParser-images'; + + if ( is_dir( $dir ) ) { + return $dir; + } + } else { + $dir = wfTempDir() . "/mwParser-" . mt_rand() . "-images"; + } + + wfDebug( "Creating upload directory $dir\n" ); + + if ( file_exists( $dir ) ) { + wfDebug( "Already exists!\n" ); + + return $dir; + } + + wfMkdirParents( $dir . '/3/3a', null, __METHOD__ ); + copy( "$IP/tests/phpunit/data/upload/headbg.jpg", "$dir/3/3a/Foobar.jpg" ); + + wfMkdirParents( $dir . '/0/09', null, __METHOD__ ); + copy( "$IP/tests/phpunit/data/upload/headbg.jpg", "$dir/0/09/Bad.jpg" ); + + return $dir; + } + + public static function suite() { + // Hack to invoke the autoloader required to get phpunit to recognize + // the UploadFromUrlTest class + class_exists( 'UploadFromUrlTest' ); + $suite = new UploadFromUrlTestSuite( 'UploadFromUrlTest' ); + + return $suite; + } +} diff --git a/tests/phpunit/tests/MediaWikiTestCaseTest.php b/tests/phpunit/tests/MediaWikiTestCaseTest.php new file mode 100644 index 00000000..2846fde0 --- /dev/null +++ b/tests/phpunit/tests/MediaWikiTestCaseTest.php @@ -0,0 +1,77 @@ +setMwGlobals( self::GLOBAL_KEY_EXISTING, 'bar' ); + $this->assertEquals( + 'bar', + $GLOBALS[self::GLOBAL_KEY_EXISTING], + 'Global failed to correctly set' + ); + + $this->tearDown(); + + $this->assertEquals( + 'foo', + $GLOBALS[self::GLOBAL_KEY_EXISTING], + 'Global failed to be restored on tearDown' + ); + } + + /** + * @covers MediaWikiTestCase::stashMwGlobals + * @covers MediaWikiTestCase::tearDown + */ + public function testStashedGlobalsAreRestoredOnTearDown() { + $this->stashMwGlobals( self::GLOBAL_KEY_EXISTING ); + $GLOBALS[self::GLOBAL_KEY_EXISTING] = 'bar'; + $this->assertEquals( + 'bar', + $GLOBALS[self::GLOBAL_KEY_EXISTING], + 'Global failed to correctly set' + ); + + $this->tearDown(); + + $this->assertEquals( + 'foo', + $GLOBALS[self::GLOBAL_KEY_EXISTING], + 'Global failed to be restored on tearDown' + ); + } + + /** + * @covers MediaWikiTestCase::stashMwGlobals + */ + public function testExceptionThrownWhenStashingNonExistentGlobals() { + $this->setExpectedException( + 'Exception', + 'Global with key ' . self::GLOBAL_KEY_NONEXISTING . ' doesn\'t exist and cant be stashed' + ); + + $this->stashMwGlobals( self::GLOBAL_KEY_NONEXISTING ); + } + +} -- cgit v1.2.2