diff --git a/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php b/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php new file mode 100644 index 0000000000000..2ba7d63be913c --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/DelegatingPsrLoggerTest.php @@ -0,0 +1,286 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log; + +use Joomla\CMS\Log\DelegatingPsrLogger; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; +use Psr\Log\LogLevel; + +/** + * Test class for DelegatingPsrLogger. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The delegating PSR-3 logger')] +class DelegatingPsrLoggerTest extends UnitTestCase +{ + /** + * Empties the Log singleton before each test. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function setUp(): void + { + parent::setUp(); + + Log::setInstance(null); + } + + /** + * Puts the singleton back. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function tearDown(): void + { + Log::setInstance(null); + + parent::tearDown(); + } + + /** + * Returns a Log which records the entries it is handed instead of dispatching them. + * + * @return Log + * + * @since __DEPLOY_VERSION__ + */ + private function createRecordingLog(): Log + { + return new class () extends Log { + /** + * @var LogEntry[] + */ + public array $entries = []; + + public function __construct() + { + } + + protected function addLogEntry(LogEntry $entry) + { + $this->entries[] = $entry; + } + }; + } + + /** + * The PSR-3 levels and the Joomla priorities they map to. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataLevels(): array + { + return [ + 'emergency' => [LogLevel::EMERGENCY, Log::EMERGENCY], + 'alert' => [LogLevel::ALERT, Log::ALERT], + 'critical' => [LogLevel::CRITICAL, Log::CRITICAL], + 'error' => [LogLevel::ERROR, Log::ERROR], + 'warning' => [LogLevel::WARNING, Log::WARNING], + 'notice' => [LogLevel::NOTICE, Log::NOTICE], + 'info' => [LogLevel::INFO, Log::INFO], + 'debug' => [LogLevel::DEBUG, Log::DEBUG], + ]; + } + + /** + * Tests that each PSR-3 level is mapped to the matching Joomla priority. + * + * @param string $level The PSR-3 level + * @param integer $priority The Joomla priority it must map to + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataLevels')] + #[TestDox('maps a PSR-3 level to the matching priority')] + public function testLevelIsMappedToPriority(string $level, int $priority): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + (new DelegatingPsrLogger($log))->log($level, 'a message'); + + $this->assertSame($priority, $log->entries[0]->priority); + } + + /** + * Tests that the convenience methods inherited from the PSR-3 abstract logger work. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('supports the PSR-3 convenience methods')] + public function testConvenienceMethodsWork(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + $psrLogger = new DelegatingPsrLogger($log); + + $psrLogger->warning('warned'); + $psrLogger->debug('debugged'); + + $this->assertSame(Log::WARNING, $log->entries[0]->priority); + $this->assertSame('warned', $log->entries[0]->message); + $this->assertSame(Log::DEBUG, $log->entries[1]->priority); + $this->assertSame('debugged', $log->entries[1]->message); + } + + /** + * Tests that an unknown level is refused. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('refuses an unknown level')] + public function testUnknownLevelIsRefused(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('An invalid log level has been given.'); + + (new DelegatingPsrLogger($log))->log('nonsense', 'a message'); + } + + /** + * Tests that a category in the context is lifted out into the entry. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('lifts the category out of the context')] + public function testCategoryIsLiftedOutOfTheContext(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + (new DelegatingPsrLogger($log))->info('a message', ['category' => 'MyCategory']); + + $this->assertSame('mycategory', $log->entries[0]->category); + } + + /** + * Tests that a date in the context is lifted out into the entry. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('lifts the date out of the context')] + public function testDateIsLiftedOutOfTheContext(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + (new DelegatingPsrLogger($log))->info('a message', ['date' => '2021-05-06 07:08:09']); + + $this->assertSame('2021-05-06 07:08:09', $log->entries[0]->date->format('Y-m-d H:i:s')); + } + + /** + * Tests that the whole context is still forwarded, including the keys which were lifted out. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('forwards the whole context as well')] + public function testWholeContextIsForwarded(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + $context = ['category' => 'MyCategory', 'date' => '2021-05-06 07:08:09', 'extra' => 'kept']; + + (new DelegatingPsrLogger($log))->info('a message', $context); + + $this->assertSame($context, $log->entries[0]->context); + } + + /** + * Tests that an object which can be cast to a string is accepted as the message. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('accepts a stringable message')] + public function testStringableMessageIsAccepted(): void + { + $log = $this->createRecordingLog(); + Log::setInstance($log); + + $message = new class () implements \Stringable { + public function __toString(): string + { + return 'from an object'; + } + }; + + (new DelegatingPsrLogger($log))->info($message); + + $this->assertSame('from an object', $log->entries[0]->message); + } + + /** + * Tests that the message goes to whichever Log is the singleton, not to the one the logger was + * built with. + * + * The delegation is written as $this->logger::add(...), a static call resolved through the + * instance, so the injected Log only decides which class is used and never receives anything + * itself. This pins the current behaviour rather than endorsing it. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @todo The logger should dispatch to the Log it was constructed with. + */ + #[TestDox('dispatches to the current singleton rather than the injected log (known defect)')] + public function testDispatchGoesToTheCurrentSingleton(): void + { + $injected = $this->createRecordingLog(); + $other = $this->createRecordingLog(); + + Log::setInstance($injected); + + $psrLogger = new DelegatingPsrLogger($injected); + $psrLogger->info('while the injected log is the singleton'); + + Log::setInstance($other); + $psrLogger->info('after the singleton was replaced'); + + $this->assertCount(1, $injected->entries); + $this->assertSame('while the injected log is the singleton', $injected->entries[0]->message); + + $this->assertCount(1, $other->entries); + $this->assertSame('after the singleton was replaced', $other->entries[0]->message); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/LogEntryTest.php b/tests/Unit/Libraries/Cms/Log/LogEntryTest.php new file mode 100644 index 0000000000000..01b6885b7d69a --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/LogEntryTest.php @@ -0,0 +1,234 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log; + +use Joomla\CMS\Date\Date; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for LogEntry. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('A log entry')] +class LogEntryTest extends UnitTestCase +{ + /** + * Test data for the testPriorityIsSanitised method. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataPriorities(): array + { + return [ + 'emergency' => [Log::EMERGENCY, Log::EMERGENCY], + 'alert' => [Log::ALERT, Log::ALERT], + 'critical' => [Log::CRITICAL, Log::CRITICAL], + 'error' => [Log::ERROR, Log::ERROR], + 'warning' => [Log::WARNING, Log::WARNING], + 'notice' => [Log::NOTICE, Log::NOTICE], + 'info' => [Log::INFO, Log::INFO], + 'debug' => [Log::DEBUG, Log::DEBUG], + 'unknown falls back to info' => [Log::INFO, 999], + 'zero falls back to info' => [Log::INFO, 0], + 'null falls back to info' => [Log::INFO, null], + 'numeric string is not a hit' => [Log::INFO, '64'], + ]; + } + + /** + * Tests that the priority is one of the known ones, or INFO. + * + * The check is a strict in_array(), so a priority given as a numeric string does not match and + * falls back to INFO like anything else unknown. + * + * @param integer $expected The priority the entry must end up with + * @param mixed $priority The priority handed to the constructor + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataPriorities')] + #[TestDox('keeps a known priority and falls back to INFO for anything else')] + public function testPriorityIsSanitised(int $expected, $priority): void + { + $this->assertSame($expected, (new LogEntry('A message', $priority))->priority); + } + + /** + * Test data for the testCategoryIsSanitised method. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataCategories(): array + { + return [ + 'spaces and punctuation go' => ['mycategory', 'My Category!'], + 'dots dashes underscores stay' => ['a.b-c_d', 'a.b-c_d'], + 'upper case is lowered' => ['allcaps', 'ALLCAPS'], + 'symbols are stripped' => ['symbols', 'sym#$%bols'], + ]; + } + + /** + * Tests that the category is reduced to the safe character set and lower cased. + * + * @param string $expected The category the entry must end up with + * @param string $category The category handed to the constructor + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataCategories')] + #[TestDox('reduces the category to a safe lower case token')] + public function testCategoryIsSanitised(string $expected, string $category): void + { + $this->assertSame($expected, (new LogEntry('A message', Log::INFO, $category))->category); + } + + /** + * Tests that an empty category is left as null rather than an empty string. + * + * The loggers test the category with empty(), so both behave the same, but the property really + * does stay at its declared default. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('leaves an empty category as null')] + public function testEmptyCategoryStaysNull(): void + { + $this->assertNull((new LogEntry('A message', Log::INFO, ''))->category); + } + + /** + * Tests that the site root is redacted out of the message. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('redacts the site root out of the message')] + public function testSiteRootIsRedacted(): void + { + $entry = new LogEntry(JPATH_ROOT . '/index.php failed'); + + $this->assertSame('[ROOT]/index.php failed', $entry->message); + } + + /** + * Tests that the temporary directory is redacted out of the message. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('redacts the temporary directory out of the message')] + public function testTemporaryDirectoryIsRedacted(): void + { + $entry = new LogEntry(sys_get_temp_dir() . '/upload.tmp failed'); + + $this->assertSame('[TMP]/upload.tmp failed', $entry->message); + } + + /** + * Tests that a message with nothing to redact is left alone, and that it is cast to a string. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('leaves a message with nothing to redact alone')] + public function testPlainMessageIsUnchanged(): void + { + $this->assertSame('nothing to redact', (new LogEntry('nothing to redact'))->message); + $this->assertSame('123', (new LogEntry(123))->message); + } + + /** + * Tests that an explicit date is used, and that it becomes a Date object. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('turns the given date into a Date object')] + public function testGivenDateIsUsed(): void + { + $entry = new LogEntry('A message', Log::INFO, '', '2020-01-02 03:04:05'); + + $this->assertInstanceOf(Date::class, $entry->date); + $this->assertSame('2020-01-02 03:04:05', $entry->date->format('Y-m-d H:i:s')); + } + + /** + * Tests that an omitted date becomes the current time. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('falls back to now when no date is given')] + public function testOmittedDateBecomesNow(): void + { + foreach ([null, ''] as $date) { + $entry = new LogEntry('A message', Log::INFO, '', $date); + + $this->assertInstanceOf(Date::class, $entry->date); + $this->assertEqualsWithDelta(time(), $entry->date->toUnix(), 10); + } + } + + /** + * Tests that the context is stored as it was given. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('stores the context as it was given')] + public function testContextIsStoredVerbatim(): void + { + $context = ['user' => 42, 'nested' => ['a' => 'b']]; + + $this->assertSame($context, (new LogEntry('A message', Log::INFO, '', null, $context))->context); + $this->assertSame([], (new LogEntry('A message'))->context); + } + + /** + * Tests that the call stack is captured. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('captures the call stack')] + public function testCallStackIsCaptured(): void + { + $entry = new LogEntry('A message'); + + $this->assertIsArray($entry->callStack); + $this->assertNotEmpty($entry->callStack); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/LogTest.php b/tests/Unit/Libraries/Cms/Log/LogTest.php new file mode 100644 index 0000000000000..9ec89e9add368 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/LogTest.php @@ -0,0 +1,388 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log; + +use Joomla\CMS\Log\DelegatingPsrLogger; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\CMS\Log\Logger\InMemoryLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for Log. + * + * Log is a swappable singleton: setInstance() accepts a Log or null, which is what makes it + * testable. Every test here puts it back to null afterwards. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The log singleton')] +class LogTest extends UnitTestCase +{ + /** + * Empties the singleton and the entries the in memory logger collects, both of which are static + * and shared with the rest of the suite. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function setUp(): void + { + parent::setUp(); + + $this->setStaticProperty(InMemoryLogger::class, 'logEntries', []); + + Log::setInstance(null); + } + + /** + * Puts the singleton back. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function tearDown(): void + { + Log::setInstance(null); + + parent::tearDown(); + } + + /** + * Returns a Log which exposes the protected registration and lookup internals. + * + * @return Log + * + * @since __DEPLOY_VERSION__ + */ + private function createInspectableLog(): Log + { + return new class () extends Log { + public function __construct() + { + } + + public function addLoggerFor(array $options, $priorities = Log::ALL, $categories = [], $exclude = false): void + { + $this->addLoggerInternal($options, $priorities, $categories, $exclude); + } + + public function findLoggersFor($priority, $category): array + { + return $this->findLoggers($priority, $category); + } + + public function getConfigurations(): array + { + return $this->configurations; + } + }; + } + + /** + * Tests that the singleton can be swapped and emptied. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('accepts a replacement instance and a null')] + public function testInstanceCanBeSwapped(): void + { + $log = $this->createInspectableLog(); + + Log::setInstance($log); + $this->assertSame($log, (new \ReflectionProperty(Log::class, 'instance'))->getValue()); + + Log::setInstance(null); + $this->assertNull((new \ReflectionProperty(Log::class, 'instance'))->getValue()); + } + + /** + * Tests that a message added through the static entry point reaches a registered logger. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('delivers a message to a registered logger')] + public function testMessageReachesTheRegisteredLogger(): void + { + Log::addLogger(['logger' => 'inmemory', 'group' => 'logtest'], Log::ALL); + + Log::add('a message', Log::WARNING, 'system'); + + $options = ['group' => 'logtest']; + $entries = (new InMemoryLogger($options))->getCollectedEntries(); + + $this->assertCount(1, $entries); + $this->assertSame('a message', $entries[0]->message); + $this->assertSame(Log::WARNING, $entries[0]->priority); + $this->assertSame('system', $entries[0]->category); + } + + /** + * Tests that a ready made entry is delivered, as a copy. + * + * Every logger is handed its own clone, so that one logger cannot change what another one + * sees. The entry which arrives is therefore equal to the one which was submitted but is not + * the same object. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('delivers a ready made entry as a copy')] + public function testReadyMadeEntryIsDeliveredAsACopy(): void + { + Log::addLogger(['logger' => 'inmemory', 'group' => 'logtest'], Log::ALL); + + $entry = new LogEntry('prepared', Log::ERROR); + Log::add($entry); + + $options = ['group' => 'logtest']; + $entries = (new InMemoryLogger($options))->getCollectedEntries(); + + $this->assertCount(1, $entries); + $this->assertNotSame($entry, $entries[0], 'Each logger is handed its own clone of the entry'); + $this->assertEquals($entry, $entries[0]); + $this->assertSame('prepared', $entries[0]->message); + $this->assertSame(Log::ERROR, $entries[0]->priority); + } + + /** + * Tests that two loggers each get their own copy of the entry. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('hands every logger its own copy of the entry')] + public function testEveryLoggerGetsItsOwnCopy(): void + { + Log::addLogger(['logger' => 'inmemory', 'group' => 'first'], Log::ALL); + Log::addLogger(['logger' => 'inmemory', 'group' => 'second'], Log::ALL); + + Log::add('shared message', Log::ERROR); + + $firstOptions = ['group' => 'first']; + $secondOptions = ['group' => 'second']; + + $first = (new InMemoryLogger($firstOptions))->getCollectedEntries(); + $second = (new InMemoryLogger($secondOptions))->getCollectedEntries(); + + $this->assertCount(1, $first); + $this->assertCount(1, $second); + $this->assertNotSame($first[0], $second[0]); + $this->assertSame('shared message', $first[0]->message); + $this->assertSame('shared message', $second[0]->message); + } + + /** + * Tests that the priority is treated as a bitmask. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('treats the priority as a bitmask')] + public function testPriorityIsABitmask(): void + { + $log = $this->createInspectableLog(); + $log->addLoggerFor(['logger' => 'inmemory'], Log::ERROR | Log::WARNING); + + $this->assertCount(1, $log->findLoggersFor(Log::ERROR, '')); + $this->assertCount(1, $log->findLoggersFor(Log::WARNING, '')); + $this->assertCount(0, $log->findLoggersFor(Log::INFO, '')); + $this->assertCount(0, $log->findLoggersFor(Log::DEBUG, '')); + } + + /** + * Tests that ALL covers every priority. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('covers every priority with ALL')] + public function testAllCoversEveryPriority(): void + { + $log = $this->createInspectableLog(); + $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL); + + foreach ([Log::EMERGENCY, Log::ALERT, Log::CRITICAL, Log::ERROR, Log::WARNING, Log::NOTICE, Log::INFO, Log::DEBUG] as $priority) { + $this->assertCount(1, $log->findLoggersFor($priority, ''), 'Priority ' . $priority . ' is not covered by ALL'); + } + } + + /** + * Tests that a logger with no categories takes everything. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('sends every category to a logger which named none')] + public function testNoCategoriesMeansEveryCategory(): void + { + $log = $this->createInspectableLog(); + $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL); + + $this->assertCount(1, $log->findLoggersFor(Log::INFO, '')); + $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'anything')); + } + + /** + * Tests that a logger which named categories only takes those, whatever their case. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('matches the named categories without regard to case')] + public function testCategoryMatchingIsCaseInsensitive(): void + { + $log = $this->createInspectableLog(); + $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL, ['MyCategory']); + + $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'mycategory')); + $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'MYCATEGORY')); + $this->assertCount(0, $log->findLoggersFor(Log::INFO, 'other')); + $this->assertCount(0, $log->findLoggersFor(Log::INFO, '')); + } + + /** + * Tests that excluding categories inverts the match, and that the empty category counts as one + * of the categories which are not excluded. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('inverts the match when the categories are excluded')] + public function testExcludedCategoriesInvertTheMatch(): void + { + $log = $this->createInspectableLog(); + $log->addLoggerFor(['logger' => 'inmemory'], Log::ALL, ['noisy'], true); + + $this->assertCount(0, $log->findLoggersFor(Log::INFO, 'noisy')); + $this->assertCount(1, $log->findLoggersFor(Log::INFO, 'quiet')); + $this->assertCount(1, $log->findLoggersFor(Log::INFO, '')); + } + + /** + * Tests that registering the same options twice keeps one configuration. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('keeps one configuration for identical options')] + public function testIdenticalOptionsAreRegisteredOnce(): void + { + $log = $this->createInspectableLog(); + + $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'same']); + $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'same']); + + $this->assertCount(1, $log->getConfigurations()); + + $log->addLoggerFor(['logger' => 'inmemory', 'group' => 'different']); + + $this->assertCount(2, $log->getConfigurations()); + } + + /** + * Tests that the logger name is lower cased and defaults to the text file logger. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('lower cases the logger name and defaults to formattedtext')] + public function testLoggerNameIsNormalised(): void + { + $log = $this->createInspectableLog(); + + $log->addLoggerFor([]); + $log->addLoggerFor(['logger' => 'INMEMORY']); + + $this->assertSame(['formattedtext', 'inmemory'], array_column($log->getConfigurations(), 'logger')); + } + + /** + * Tests that two different closures produce two configurations. + * + * Closures cannot be serialised, so the signature is built from the object hash instead. If + * that swap were dropped, registering a second callback logger would either fail outright or + * silently overwrite the first. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('tells two callback loggers apart by their closures')] + public function testDifferentClosuresProduceDifferentConfigurations(): void + { + $log = $this->createInspectableLog(); + + $log->addLoggerFor(['logger' => 'callback', 'callback' => function () { + }]); + $log->addLoggerFor(['logger' => 'callback', 'callback' => function () { + }]); + + $this->assertCount(2, $log->getConfigurations()); + } + + /** + * Tests that the closure is put back into the options after the signature was built. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('puts the closure back after building the signature')] + public function testClosureIsRestoredAfterHashing(): void + { + $log = $this->createInspectableLog(); + + $log->addLoggerFor(['logger' => 'callback', 'callback' => function () { + }]); + + foreach ($log->getConfigurations() as $configuration) { + $this->assertIsCallable($configuration['callback']); + } + } + + /** + * Tests that a delegated PSR-3 logger can be created, and that it builds the singleton when + * there is not one yet. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('creates a delegated PSR-3 logger')] + public function testDelegatedPsrLoggerIsCreated(): void + { + $this->assertNull((new \ReflectionProperty(Log::class, 'instance'))->getValue()); + + $psrLogger = Log::createDelegatedLogger(); + + $this->assertInstanceOf(DelegatingPsrLogger::class, $psrLogger); + $this->assertNotNull((new \ReflectionProperty(Log::class, 'instance'))->getValue()); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php new file mode 100644 index 0000000000000..3780cfe587a8b --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/Logger/CallbackLoggerTest.php @@ -0,0 +1,168 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger; + +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\CMS\Log\Logger\CallbackLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for CallbackLogger. + * + * Note that Logger::__construct() takes its options array by reference, so the options must always + * be held in a variable rather than passed as a literal. That is also why the data provider below + * hands over the callback rather than a ready made options array. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The callback logger')] +class CallbackLoggerTest extends UnitTestCase +{ + /** + * Tests that the entry is handed to the callback. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('hands the entry to the callback')] + public function testEntryIsHandedToTheCallback(): void + { + $received = []; + + $options = [ + 'callback' => function (LogEntry $entry) use (&$received) { + $received[] = $entry; + }, + ]; + + $logger = new CallbackLogger($options); + $entry = new LogEntry('a message', Log::WARNING, 'system'); + + $logger->addEntry($entry); + + $this->assertCount(1, $received); + $this->assertSame($entry, $received[0]); + } + + /** + * Tests that the callback is called once per entry. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('calls the callback once for every entry')] + public function testCallbackIsCalledOncePerEntry(): void + { + $calls = 0; + + $options = [ + 'callback' => function () use (&$calls) { + $calls++; + }, + ]; + + $logger = new CallbackLogger($options); + + $logger->addEntry(new LogEntry('one')); + $logger->addEntry(new LogEntry('two')); + + $this->assertSame(2, $calls); + } + + /** + * Tests that a callable given as a class method pair is accepted. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('accepts a callable given as an object and method pair')] + public function testObjectMethodPairIsAccepted(): void + { + $collector = new class () { + public array $seen = []; + + public function collect(LogEntry $entry): void + { + $this->seen[] = $entry->message; + } + }; + + $options = ['callback' => [$collector, 'collect']]; + $logger = new CallbackLogger($options); + + $logger->addEntry(new LogEntry('through a method')); + + $this->assertSame(['through a method'], $collector->seen); + } + + /** + * Tests that a callable given as a function name is accepted. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('accepts a callable given as a function name')] + public function testFunctionNameIsAccepted(): void + { + $options = ['callback' => 'trim']; + + $this->assertInstanceOf(CallbackLogger::class, new CallbackLogger($options)); + } + + /** + * Callbacks which are not usable. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataInvalidCallbacks(): array + { + return [ + 'missing' => [null], + 'null' => [null], + 'unknown function' => ['no_such_function_exists_here'], + 'empty array' => [[]], + 'plain string' => ['not a callable'], + 'integer' => [42], + ]; + } + + /** + * Tests that a logger without a usable callback refuses to be built. + * + * @param mixed $callback The unusable callback + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataInvalidCallbacks')] + #[TestDox('refuses to be built without a usable callback')] + public function testInvalidCallbackIsRefused($callback): void + { + $options = $callback === null ? [] : ['callback' => $callback]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('created without valid callback function'); + + new CallbackLogger($options); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php new file mode 100644 index 0000000000000..ec5cb411fab80 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/Logger/EchoLoggerTest.php @@ -0,0 +1,151 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger; + +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\CMS\Log\Logger\EchoLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for EchoLogger. + * + * Note that Logger::__construct() takes its options array by reference, so the options must always + * be held in a variable rather than passed as a literal. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The echo logger')] +class EchoLoggerTest extends UnitTestCase +{ + /** + * The priorities and the labels the logger writes for them. + * + * These come from the base Logger and are the long names, unlike the abbreviations the syslog + * logger uses. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataPriorities(): array + { + return [ + 'emergency' => ['EMERGENCY', Log::EMERGENCY], + 'alert' => ['ALERT', Log::ALERT], + 'critical' => ['CRITICAL', Log::CRITICAL], + 'error' => ['ERROR', Log::ERROR], + 'warning' => ['WARNING', Log::WARNING], + 'notice' => ['NOTICE', Log::NOTICE], + 'info' => ['INFO', Log::INFO], + 'debug' => ['DEBUG', Log::DEBUG], + ]; + } + + /** + * Tests that each priority is written with its label. + * + * @param string $label The label the logger must write + * @param integer $priority The priority of the entry + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataPriorities')] + #[TestDox('writes the label of the priority')] + public function testPriorityLabelIsWritten(string $label, int $priority): void + { + $options = []; + $logger = new EchoLogger($options); + + $this->expectOutputString($label . ": a message\n"); + + $logger->addEntry(new LogEntry('a message', $priority)); + } + + /** + * Tests that a category is written in brackets after the message. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('appends the category in brackets')] + public function testCategoryIsAppended(): void + { + $options = []; + $logger = new EchoLogger($options); + + $this->expectOutputString("WARNING: a message [system]\n"); + + $logger->addEntry(new LogEntry('a message', Log::WARNING, 'system')); + } + + /** + * Tests that no brackets are written when there is no category. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('writes no brackets when there is no category')] + public function testNoCategoryMeansNoBrackets(): void + { + $options = []; + $logger = new EchoLogger($options); + + $this->expectOutputString("INFO: a message\n"); + + $logger->addEntry(new LogEntry('a message', Log::INFO, '')); + } + + /** + * Tests that the line separator can be replaced. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('honours a line separator given in the options')] + public function testLineSeparatorIsHonoured(): void + { + $options = ['line_separator' => '
']; + $logger = new EchoLogger($options); + + $this->expectOutputString('INFO: a message
'); + + $logger->addEntry(new LogEntry('a message', Log::INFO)); + } + + /** + * Tests that several entries are written one after the other. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('writes one line per entry')] + public function testOneLinePerEntry(): void + { + $options = []; + $logger = new EchoLogger($options); + + $this->expectOutputString("WARNING: first [a]\nERROR: second\n"); + + $logger->addEntry(new LogEntry('first', Log::WARNING, 'a')); + $logger->addEntry(new LogEntry('second', Log::ERROR)); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php new file mode 100644 index 0000000000000..fbf5d7539eea5 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/Logger/InMemoryLoggerTest.php @@ -0,0 +1,156 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger; + +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\CMS\Log\Logger\InMemoryLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for InMemoryLogger. + * + * Note that Logger::__construct() takes its options array by reference, so the options must always + * be held in a variable rather than passed as a literal. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The in memory logger')] +class InMemoryLoggerTest extends UnitTestCase +{ + /** + * Empties the collected entries, which are held in a static property shared by every instance + * and are never cleared by the logger itself. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function setUp(): void + { + parent::setUp(); + + $this->setStaticProperty(InMemoryLogger::class, 'logEntries', []); + } + + /** + * Builds a logger for the given group. + * + * @param string|null $group The group, or null for the default one + * + * @return InMemoryLogger + * + * @since __DEPLOY_VERSION__ + */ + private function createLogger(?string $group = null): InMemoryLogger + { + $options = $group === null ? [] : ['group' => $group]; + + return new InMemoryLogger($options); + } + + /** + * Tests that entries come back in the order they were added. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('hands the entries back in the order they were added')] + public function testEntriesComeBackInOrder(): void + { + $logger = $this->createLogger(); + + $logger->addEntry(new LogEntry('first', Log::WARNING)); + $logger->addEntry(new LogEntry('second', Log::ERROR)); + + $entries = $logger->getCollectedEntries(); + + $this->assertCount(2, $entries); + $this->assertSame('first', $entries[0]->message); + $this->assertSame('second', $entries[1]->message); + } + + /** + * Tests that a logger with no entries hands back an empty array. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('hands back an empty array when nothing was logged')] + public function testEmptyGroupIsAnEmptyArray(): void + { + $this->assertSame([], $this->createLogger('nothing-here')->getCollectedEntries()); + } + + /** + * Tests that two groups do not see each other's entries. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('keeps the groups apart')] + public function testGroupsAreKeptApart(): void + { + $first = $this->createLogger('first'); + $second = $this->createLogger('second'); + + $first->addEntry(new LogEntry('for the first group')); + + $this->assertCount(1, $first->getCollectedEntries()); + $this->assertSame([], $second->getCollectedEntries()); + } + + /** + * Tests that a second logger on the same group sees what the first one collected. + * + * This is what makes the logger usable as a test double: one instance is registered with Log + * and another is built afterwards to read the entries back. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('shares its entries with another logger on the same group')] + public function testEntriesAreSharedWithinAGroup(): void + { + $writer = $this->createLogger('shared'); + $writer->addEntry(new LogEntry('written by the first instance')); + + $reader = $this->createLogger('shared'); + + $this->assertCount(1, $reader->getCollectedEntries()); + $this->assertSame('written by the first instance', $reader->getCollectedEntries()[0]->message); + } + + /** + * Tests that the group falls back to "default" and that an empty group option is ignored. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('falls back to the default group')] + public function testGroupFallsBackToDefault(): void + { + $withoutOption = $this->createLogger(); + $withoutOption->addEntry(new LogEntry('a message')); + + $emptyOption = $this->createLogger(''); + + $this->assertCount(1, $emptyOption->getCollectedEntries()); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php new file mode 100644 index 0000000000000..352ac27a09f4d --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/Logger/SyslogLoggerTest.php @@ -0,0 +1,170 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger; + +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\Logger\SyslogLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for SyslogLogger. + * + * addEntry() really does write to the syslog of the machine running the tests, so it is never + * called here. What is worth guarding is the priority map, which differs from the one every other + * logger uses, and the way the constructor normalises its options. + * + * Two options are deliberately never set. `sys_facility` reaches an `IS_WIN` constant which the + * unit bootstrap does not define, so a test which sets it dies with an undefined constant error. + * `sys_use_stderr` turns on LOG_PERROR, which would write the entries into the test output. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The syslog logger')] +class SyslogLoggerTest extends UnitTestCase +{ + /** + * The priorities and the syslog names the logger maps them to. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataPriorities(): array + { + return [ + 'emergency' => [Log::EMERGENCY, 'EMERG'], + 'alert' => [Log::ALERT, 'ALERT'], + 'critical' => [Log::CRITICAL, 'CRIT'], + 'error' => [Log::ERROR, 'ERR'], + 'warning' => [Log::WARNING, 'WARNING'], + 'notice' => [Log::NOTICE, 'NOTICE'], + 'info' => [Log::INFO, 'INFO'], + 'debug' => [Log::DEBUG, 'DEBUG'], + ]; + } + + /** + * Reads the priority map without building a logger, so that no syslog connection is opened. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private static function priorityMap(): array + { + $instance = (new \ReflectionClass(SyslogLogger::class))->newInstanceWithoutConstructor(); + + return (new \ReflectionProperty(SyslogLogger::class, 'priorities'))->getValue($instance); + } + + /** + * Tests that each priority maps to the expected syslog name. + * + * These are the abbreviated syslog names, not the long ones the other loggers write, and that + * difference is the whole reason the class overrides the map. + * + * @param integer $priority The Log priority + * @param string $name The syslog name it must map to + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataPriorities')] + #[TestDox('maps a priority to its syslog name')] + public function testPriorityMapsToSyslogName(int $priority, string $name): void + { + $this->assertSame($name, self::priorityMap()[$priority]); + } + + /** + * Tests that every name in the map has a matching LOG_* constant. + * + * addEntry() builds the syslog priority with constant('LOG_' . $name), so a name without a + * constant would be a fatal error the moment anything was logged. + * + * @param integer $priority The Log priority + * @param string $name The syslog name it maps to + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataPriorities')] + #[TestDox('maps to a name which has a matching LOG_ constant')] + public function testEveryNameHasALogConstant(int $priority, string $name): void + { + $this->assertTrue(\defined('LOG_' . $name), 'LOG_' . $name . ' is not defined'); + } + + /** + * Tests that the map covers every priority and nothing else. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('covers every priority')] + public function testMapCoversEveryPriority(): void + { + $this->assertSame( + [Log::EMERGENCY, Log::ALERT, Log::CRITICAL, Log::ERROR, Log::WARNING, Log::NOTICE, Log::INFO, Log::DEBUG], + array_keys(self::priorityMap()) + ); + } + + /** + * Tests the identity and process id options the constructor fills in. + * + * The options array is held by reference, so the normalised values can be read straight back + * off the array which was handed to the constructor. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('fills in the identity and process id options')] + public function testDefaultOptionsAreFilledIn(): void + { + $options = []; + $logger = new SyslogLogger($options); + + $this->assertSame('Joomla Platform', $options['sys_ident']); + $this->assertTrue($options['sys_add_pid']); + $this->assertFalse($options['sys_use_stderr']); + + // Close the syslog connection deterministically rather than at the end of the test run + unset($logger); + } + + /** + * Tests that the options which were given are kept, and cast to a boolean where that applies. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('keeps the options it was given and casts the flags')] + public function testGivenOptionsAreKept(): void + { + $options = ['sys_ident' => 'My Application', 'sys_add_pid' => '0']; + $logger = new SyslogLogger($options); + + $this->assertSame('My Application', $options['sys_ident']); + $this->assertFalse($options['sys_add_pid']); + + unset($logger); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php b/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php new file mode 100644 index 0000000000000..c175e0ac8df90 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/Logger/W3cLoggerTest.php @@ -0,0 +1,170 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log\Logger; + +use Joomla\CMS\Log\Logger\FormattedtextLogger; +use Joomla\CMS\Log\Logger\W3cLogger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for W3cLogger. + * + * The logger extends FormattedtextLogger, which writes to disk in addEntry() and falls back to the + * application configuration for its path, so only the constructor is exercised here and it is + * always given an explicit path. addEntry() is never called, and the deferred mode is never turned + * on because the destructor flushes it to disk. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The W3C logger')] +class W3cLoggerTest extends UnitTestCase +{ + /** + * Builds a logger against the temporary directory. + * + * @param array $options Extra options for the logger + * + * @return array The logger and the options array it was built with + * + * @since __DEPLOY_VERSION__ + */ + private function createLogger(array $options = []): array + { + $options = array_merge(['text_file_path' => sys_get_temp_dir()], $options); + $logger = new W3cLogger($options); + + return [$logger, $options]; + } + + /** + * Tests that the log file is named after the logger when nothing else is asked for. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('names its file error.w3c.php by default')] + public function testDefaultFileName(): void + { + [, $options] = $this->createLogger(); + + $this->assertSame('error.w3c.php', $options['text_file']); + } + + /** + * Tests that a file name which was asked for is kept. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('keeps a file name it was given')] + public function testGivenFileNameIsKept(): void + { + [, $options] = $this->createLogger(['text_file' => 'custom.php']); + + $this->assertSame('custom.php', $options['text_file']); + } + + /** + * Tests the W3C entry format, which is the reason the class exists. + * + * It is tab separated and splits the timestamp into a date and a time, where the parent writes + * a single DATETIME field. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('uses the tab separated W3C entry format')] + public function testEntryFormat(): void + { + [$logger] = $this->createLogger(); + + $format = (new \ReflectionProperty(W3cLogger::class, 'format'))->getValue($logger); + + $this->assertSame("{DATE}\t{TIME}\t{PRIORITY}\t{CLIENTIP}\t{CATEGORY}\t{MESSAGE}", $format); + } + + /** + * Tests that the fields are parsed out of the format. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('parses the fields out of its format')] + public function testFieldsAreParsed(): void + { + [$logger] = $this->createLogger(); + + $fields = (new \ReflectionProperty(FormattedtextLogger::class, 'fields'))->getValue($logger); + + $this->assertSame(['DATE', 'TIME', 'PRIORITY', 'CLIENTIP', 'CATEGORY', 'MESSAGE'], $fields); + } + + /** + * Tests that the path is built from the directory and the file name. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('builds its path from the directory and the file name')] + public function testPathIsBuilt(): void + { + [$logger, $options] = $this->createLogger(['text_file' => 'custom.php']); + + $path = (new \ReflectionProperty(FormattedtextLogger::class, 'path'))->getValue($logger); + + $this->assertSame($options['text_file_path'] . '/custom.php', $path); + } + + /** + * Tests that building the logger does not touch the filesystem. + * + * The file is only created when the first entry is written, which is what makes it safe to + * build the logger in a test. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('does not touch the filesystem until something is logged')] + public function testConstructorDoesNotTouchTheFilesystem(): void + { + $file = sys_get_temp_dir() . '/w3c-unittest-' . uniqid() . '.php'; + + $this->createLogger(['text_file' => basename($file)]); + + $this->assertFileDoesNotExist($file); + } + + /** + * Tests that a custom entry format given in the options wins. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('lets the options replace its entry format')] + public function testEntryFormatCanBeReplaced(): void + { + [$logger] = $this->createLogger(['text_entry_format' => '{DATETIME} {MESSAGE}']); + + $fields = (new \ReflectionProperty(FormattedtextLogger::class, 'fields'))->getValue($logger); + + $this->assertSame(['DATETIME', 'MESSAGE'], $fields); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php b/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php new file mode 100644 index 0000000000000..59dcd6dae1ecb --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/LoggerRegistryTest.php @@ -0,0 +1,193 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log; + +use Joomla\CMS\Log\Logger\CallbackLogger; +use Joomla\CMS\Log\Logger\DatabaseLogger; +use Joomla\CMS\Log\Logger\EchoLogger; +use Joomla\CMS\Log\Logger\FormattedtextLogger; +use Joomla\CMS\Log\Logger\InMemoryLogger; +use Joomla\CMS\Log\Logger\MessagequeueLogger; +use Joomla\CMS\Log\Logger\SyslogLogger; +use Joomla\CMS\Log\Logger\W3cLogger; +use Joomla\CMS\Log\LoggerRegistry; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for LoggerRegistry. + * + * The map is a private instance property, so every test gets a registry of its own and there is no + * static state to reset. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The logger registry')] +class LoggerRegistryTest extends UnitTestCase +{ + /** + * The keys the registry ships with, and the classes they resolve to. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function dataDefaultLoggers(): array + { + return [ + 'callback' => ['callback', CallbackLogger::class], + 'database' => ['database', DatabaseLogger::class], + 'echo' => ['echo', EchoLogger::class], + 'formattedtext' => ['formattedtext', FormattedtextLogger::class], + 'messagequeue' => ['messagequeue', MessagequeueLogger::class], + 'syslog' => ['syslog', SyslogLogger::class], + 'w3c' => ['w3c', W3cLogger::class], + 'inmemory' => ['inmemory', InMemoryLogger::class], + ]; + } + + /** + * Tests that every key the registry ships with resolves to its logger. + * + * @param string $key The registry key + * @param string $class The class it must resolve to + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('dataDefaultLoggers')] + #[TestDox('resolves a key it ships with')] + public function testDefaultLoggerIsRegistered(string $key, string $class): void + { + $registry = new LoggerRegistry(); + + $this->assertTrue($registry->hasLogger($key)); + $this->assertSame($class, $registry->getLoggerClass($key)); + } + + /** + * Tests that an unknown key is reported as missing rather than resolved. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('reports an unknown key as missing')] + public function testUnknownKeyIsMissing(): void + { + $this->assertFalse((new LoggerRegistry())->hasLogger('nosuchlogger')); + } + + /** + * Tests that asking for an unknown key throws. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('throws when an unknown key is asked for')] + public function testUnknownKeyThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("The 'nosuchlogger' key is not registered."); + + (new LoggerRegistry())->getLoggerClass('nosuchlogger'); + } + + /** + * Tests that a new key can be registered. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('registers a new key')] + public function testNewKeyCanBeRegistered(): void + { + $registry = new LoggerRegistry(); + $registry->register('custom', InMemoryLogger::class); + + $this->assertTrue($registry->hasLogger('custom')); + $this->assertSame(InMemoryLogger::class, $registry->getLoggerClass('custom')); + } + + /** + * Tests that registering over an existing key is refused unless it is asked for. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('refuses to overwrite an existing key')] + public function testExistingKeyIsNotOverwritten(): void + { + $registry = new LoggerRegistry(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("The 'echo' key is already registered."); + + $registry->register('echo', InMemoryLogger::class); + } + + /** + * Tests that an existing key is replaced when that is asked for. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('overwrites an existing key when asked to')] + public function testExistingKeyIsOverwrittenWhenRequested(): void + { + $registry = new LoggerRegistry(); + $registry->register('echo', InMemoryLogger::class, true); + + $this->assertSame(InMemoryLogger::class, $registry->getLoggerClass('echo')); + } + + /** + * Tests that a class which does not exist is refused. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('refuses a class which does not exist')] + public function testMissingClassIsRefused(): void + { + $registry = new LoggerRegistry(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("The 'No\\Such\\Logger' class for key 'custom' does not exist."); + + $registry->register('custom', 'No\\Such\\Logger'); + } + + /** + * Tests that two registries do not share their maps. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('keeps its map to itself')] + public function testRegistriesDoNotShareTheirMaps(): void + { + $first = new LoggerRegistry(); + $first->register('custom', InMemoryLogger::class); + + $this->assertFalse((new LoggerRegistry())->hasLogger('custom')); + } +} diff --git a/tests/Unit/Libraries/Cms/Log/LoggerTest.php b/tests/Unit/Libraries/Cms/Log/LoggerTest.php new file mode 100644 index 0000000000000..991eaa81c79b0 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Log/LoggerTest.php @@ -0,0 +1,135 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Log; + +use Joomla\CMS\Log\Log; +use Joomla\CMS\Log\LogEntry; +use Joomla\CMS\Log\Logger; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\TestDox; + +/** + * Test class for the abstract Logger. + * + * The class is abstract, so it is exercised through a small anonymous subclass which records the + * entries it is given. + * + * @package Joomla.UnitTest + * @subpackage Log + * @since __DEPLOY_VERSION__ + */ +#[TestDox('The abstract logger')] +class LoggerTest extends UnitTestCase +{ + /** + * Returns a logger which records what it is given, built with the options handed in. + * + * @param array $options The options array, taken by reference by the constructor + * + * @return Logger + * + * @since __DEPLOY_VERSION__ + */ + private function createLogger(array &$options): Logger + { + return new class ($options) extends Logger { + /** + * @var LogEntry[] + */ + public array $entries = []; + + public function addEntry(LogEntry $entry) + { + $this->entries[] = $entry; + } + + public function readOptions(): array + { + return $this->options; + } + + public function readPriorities(): array + { + return $this->priorities; + } + }; + } + + /** + * Tests that the options array is held by reference rather than copied. + * + * This is the trap the whole package shares: the constructor signature is + * `__construct(array &$options)`, so an options array cannot be passed as a literal, and a + * subclass which fills in its defaults writes them back into the caller's array. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('holds the options by reference')] + public function testOptionsAreHeldByReference(): void + { + $options = ['group' => 'first']; + $logger = $this->createLogger($options); + + $this->assertSame(['group' => 'first'], $logger->readOptions()); + + // Changing the caller's array must be visible inside the logger + $options['group'] = 'second'; + + $this->assertSame(['group' => 'second'], $logger->readOptions()); + } + + /** + * Tests that the priority labels cover every priority. + * + * These are the long names. The syslog logger deliberately overrides them with the abbreviated + * syslog names. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('labels every priority with its long name')] + public function testPriorityLabels(): void + { + $options = []; + + $this->assertSame( + [ + Log::EMERGENCY => 'EMERGENCY', + Log::ALERT => 'ALERT', + Log::CRITICAL => 'CRITICAL', + Log::ERROR => 'ERROR', + Log::WARNING => 'WARNING', + Log::NOTICE => 'NOTICE', + Log::INFO => 'INFO', + Log::DEBUG => 'DEBUG', + ], + $this->createLogger($options)->readPriorities() + ); + } + + /** + * Tests that an empty options array is accepted. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[TestDox('accepts an empty options array')] + public function testEmptyOptionsAreAccepted(): void + { + $options = []; + + $this->assertSame([], $this->createLogger($options)->readOptions()); + } +} diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php index 3650d440c51c8..a9dbcfd033a54 100644 --- a/tests/Unit/UnitTestCase.php +++ b/tests/Unit/UnitTestCase.php @@ -10,9 +10,16 @@ namespace Joomla\Tests\Unit; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Language; use Joomla\Database\DatabaseInterface; use Joomla\Database\DatabaseQuery; use Joomla\Database\QueryInterface; +use Joomla\DI\Container; +use Joomla\Registry\Registry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\MockObject\Stub; /** * Base Unit Test case for common behaviour across unit tests @@ -21,6 +28,248 @@ */ abstract class UnitTestCase extends \PHPUnit\Framework\TestCase { + /** + * The Factory static properties which are snapshotted before, and restored after, every test. + * + * @var string[] + * @since __DEPLOY_VERSION__ + */ + private const FACTORY_STATE_PROPERTIES = [ + 'application', + 'cache', + 'config', + 'container', + 'database', + 'dates', + 'document', + 'language', + 'mailer', + 'session', + ]; + + /** + * The values of the Factory static properties as they were before the current test started, + * or null when no snapshot has been taken. + * + * @var array|null + * @since __DEPLOY_VERSION__ + */ + private $factoryStateSnapshot; + + /** + * The static properties changed through setStaticProperty(), and the values they held before. + * + * Each entry is [class, property, previous value], in the order the changes were made. + * + * @var array + * @since __DEPLOY_VERSION__ + */ + private $staticPropertySnapshots = []; + + /** + * Records the global Factory state before the test runs. + * + * This is deliberately a #[Before] hook rather than setUp(), so that it also runs for the test + * cases which override setUp() without calling parent::setUp(). + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[Before] + protected function snapshotFactoryState(): void + { + $snapshot = []; + + foreach (self::FACTORY_STATE_PROPERTIES as $property) { + $snapshot[$property] = Factory::$$property; + } + + $this->factoryStateSnapshot = $snapshot; + } + + /** + * Puts the global Factory state back the way the test found it. + * + * This is deliberately a #[After] hook rather than tearDown(), so that it also runs for the test + * cases which override tearDown() without calling parent::tearDown(). It is a no-op when no + * snapshot was taken. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[After] + protected function restoreFactoryState(): void + { + if ($this->factoryStateSnapshot === null) { + return; + } + + foreach ($this->factoryStateSnapshot as $property => $value) { + Factory::$$property = $value; + } + + $this->factoryStateSnapshot = null; + } + + /** + * Puts back the static properties which were changed through setStaticProperty(). + * + * The changes are undone in reverse order, so that repeated changes to the same property end up + * back at the value it held before the first one. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + #[After] + protected function restoreStaticProperties(): void + { + foreach (array_reverse($this->staticPropertySnapshots) as [$class, $property, $value]) { + $reflectionProperty = new \ReflectionProperty($class, $property); + $reflectionProperty->setValue(null, $value); + } + + $this->staticPropertySnapshots = []; + } + + /** + * Sets a static property of a class, remembering the previous value so that it can be put back + * after the test. + * + * Use it for the static caches which the CMS does not expose a reset method for, for example + * ComponentHelper::$components or HTMLHelper::$registry. + * + * @param string $class The class holding the property + * @param string $property The name of the static property + * @param mixed $value The value to set + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function setStaticProperty(string $class, string $property, $value): void + { + $reflectionProperty = new \ReflectionProperty($class, $property); + + $this->staticPropertySnapshots[] = [$class, $property, $reflectionProperty->getValue()]; + + $reflectionProperty->setValue(null, $value); + } + + /** + * Returns a minimal dependency injection container, pre-populated with the services which CMS + * classes most commonly resolve through Factory::getContainer(). + * + * The container is not installed into Factory::$container, call setUpContainer() for that. + * + * @param array $services Additional services, keyed by container key. They are merged over + * the defaults, so passing an existing key replaces that default. + * + * @return Container + * + * @since __DEPLOY_VERSION__ + */ + protected function createContainer(array $services = []): Container + { + $services = array_merge( + [ + 'config' => new Registry(), + DatabaseInterface::class => $this->createStub(DatabaseInterface::class), + Language::class => $this->createLanguageStub(), + ], + $services + ); + + $container = new Container(); + + foreach ($services as $key => $service) { + // Share, so that the container hands back the very same instance rather than a clone of it + $container->share($key, $service); + } + + return $container; + } + + /** + * Creates a minimal dependency injection container and installs it as the global container. + * + * The previous container is restored after the test by restoreFactoryState(). + * + * @param array $services Additional services, see createContainer() + * + * @return Container The container which was installed + * + * @since __DEPLOY_VERSION__ + */ + protected function setUpContainer(array $services = []): Container + { + Factory::$container = $this->createContainer($services); + + return Factory::$container; + } + + /** + * Returns a Language stub whose translation methods hand the language key back unchanged. + * + * Assertions can therefore be written against the untranslated key, which keeps them independent + * of the contents of the language ini files. + * + * @param array $strings Optional map of language key to translation, for the keys which the + * test does care about. The keys are upper cased, as Language does. + * + * @return Language&Stub + * + * @since __DEPLOY_VERSION__ + */ + protected function createLanguageStub(array $strings = []): Language + { + $strings = array_change_key_case($strings, CASE_UPPER); + + $language = $this->createStub(Language::class); + + $language->method('_') + ->willReturnCallback( + static function ($string) use ($strings) { + return $strings[strtoupper($string)] ?? $string; + } + ); + + $language->method('hasKey') + ->willReturnCallback( + static function ($string) use ($strings) { + return isset($strings[strtoupper($string)]); + } + ); + + $language->method('getTag')->willReturn('en-GB'); + $language->method('getName')->willReturn('English (en-GB)'); + + return $language; + } + + /** + * Installs a Language stub, so that the static calls to Text::_(), Text::sprintf() and friends + * return something assertable. + * + * Text resolves the language through Factory::getLanguage(), which hands back Factory::$language + * when it is set, so setting that property is enough. It is restored after the test by + * restoreFactoryState(). + * + * @param array $strings Optional map of language key to translation, see createLanguageStub() + * + * @return Language&Stub The language which was installed + * + * @since __DEPLOY_VERSION__ + */ + protected function setUpLanguage(array $strings = []): Language + { + Factory::$language = $this->createLanguageStub($strings); + + return Factory::$language; + } + /** * Returns a database query instance. *