Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions tests/Unit/AvatarTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

declare(strict_types=1);

namespace SMF\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use SMF\Avatar;
use SMF\Config;

/**
* Covers how SMF\Avatar turns a stored avatar value into a URL.
*
* The constructor only asks the database who owns an attachment, so handing it
* an id_member keeps the whole thing on this side of the line. The avatars it
* looks for here are the ones the repository ships in avatars/.
*/
#[CoversClass(Avatar::class)]
class AvatarTest extends TestCase
{
/*********************
* Internal properties
*********************/

/**
* @var array The settings this class reads, as they were before the test.
*/
private array $backup = [];

/**
* @var string Config::$boardurl as it was before the test.
*/
private string $boardurl = '';

/****************
* Public methods
****************/

/**
* The control. Something with a scheme on it is an address to fetch from,
* and is left as one.
*/
public function testARemoteAvatarUrlIsLeftAlone(): void
{
$this->setUpForum('https://example.com/forum');

$avatar = new Avatar(url: 'https://example.org/pictures/me.png', id_member: 1);

$this->assertSame('https://example.org/pictures/me.png', (string) $avatar->url);
}

/**
* An avatar chosen from the gallery is stored as a path under the avatars
* directory, not as a URL, so there is no scheme on it and no host in it.
* Saying so up front means the file is looked for by name; reading it as a
* URL instead and working back to a file from that URL's path lands outside
* the avatar directories and finds nothing, and on a forum at the root of
* its domain that path is null and stripping the board URL off it throws.
*/
#[DataProvider('boardUrls')]
public function testAGalleryAvatarIsFoundUnderTheAvatarsDirectory(string $boardurl): void
{
$this->setUpForum($boardurl);

$avatar = new Avatar(url: 'Oxygen/beagle.png', id_member: 1);

$this->assertSame($boardurl . '/avatars/Oxygen/beagle.png', (string) $avatar->url);
$this->assertSame('Oxygen/beagle.png', $avatar->filename);
}

#[DataProvider('boardUrls')]
public function testAGalleryAvatarInTheRootOfTheGalleryIsFoundToo(string $boardurl): void
{
$this->setUpForum($boardurl);

$avatar = new Avatar(url: 'default.png', id_member: 1);

$this->assertSame($boardurl . '/avatars/default.png', (string) $avatar->url);
$this->assertSame('default.png', $avatar->filename);
}

/**
* A gallery file that is not there falls through to the default image
* rather than producing a URL pointing at nothing.
*/
public function testAGalleryAvatarThatIsNotThereFallsBackToTheDefault(): void
{
$this->setUpForum('https://example.com');

$avatar = new Avatar(url: 'Oxygen/no_such_avatar.png', id_member: 1);

$this->assertSame('https://example.com/avatars/default.png', (string) $avatar->url);
}

/***********************
* Public static methods
***********************/

/**
* @return array<string, array{string}>
*/
public static function boardUrls(): array
{
return [
'a forum at the root of its domain' => ['https://example.com'],
'a forum in a subdirectory' => ['https://example.com/forum'],
];
}

/******************
* Internal methods
******************/

protected function setUp(): void
{
$this->boardurl = Config::$boardurl ?? '';

foreach (['avatar_url', 'avatar_directory', 'gravatarEnabled'] as $key) {
if (isset(Config::$modSettings[$key])) {
$this->backup[$key] = Config::$modSettings[$key];
}
}
}

/**
* PHPUnit does not reset SMF's statics between tests, so a setting left
* behind here would leak into every test that follows.
*/
protected function tearDown(): void
{
Config::$boardurl = $this->boardurl;

foreach (['avatar_url', 'avatar_directory', 'gravatarEnabled'] as $key) {
unset(Config::$modSettings[$key]);

if (isset($this->backup[$key])) {
Config::$modSettings[$key] = $this->backup[$key];
}
}

$this->backup = [];
}

/**
* Points the avatar settings at the gallery the repository ships.
*/
private function setUpForum(string $boardurl): void
{
Config::$boardurl = $boardurl;
Config::$modSettings['avatar_url'] = $boardurl . '/avatars';
Config::$modSettings['avatar_directory'] = Config::$boarddir . '/avatars';
Config::$modSettings['gravatarEnabled'] = false;
}
}
104 changes: 104 additions & 0 deletions tests/Unit/MessageFormatterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace SMF\Tests\Unit;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use SMF\Localization\MessageFormatter;

/**
* Covers SMF\Localization\MessageFormatter::formatMessage().
*
* It reads one language string of its own, 'lang_locale', which comes off disk
* like any other, so the whole class is reachable without a forum behind it.
*/
#[CoversClass(MessageFormatter::class)]
class MessageFormatterTest extends TestCase
{
/****************
* Public methods
****************/

/**
* An object that can be a string is one of the things a caller may hand to
* Lang::getTxt(), and several of SMF's own value objects are exactly that:
* Url, IP, TimeInterval and PageIndex all implement \Stringable. The class
* skips any argument that is not already a string, and the intl formatter
* is handed only the scalar ones, so an object reached neither and its
* placeholder was printed to the member as it was written.
*/
public function testAStringableArgumentIsUsedForItsStringValue(): void
{
$this->assertSame(
'Hello Bob!',
MessageFormatter::formatMessage('Hello {name}!', ['name' => $this->stringable('Bob')]),
);
}

/**
* The braces and apostrophes in an argument are swapped for private use
* characters before the message is formatted and swapped back afterwards,
* so that a value cannot be read as MessageFormat syntax. A \Stringable is
* flattened early enough to go through that too.
*/
public function testMessageFormatSyntaxInAStringableValueIsNotInterpreted(): void
{
$this->assertSame(
"Hello it's {here}!",
MessageFormatter::formatMessage('Hello {name}!', ['name' => $this->stringable("it's {here}")]),
);
}

public function testTheSameStringableCanBeUsedTwiceInOneMessage(): void
{
$this->assertSame(
'Bob and Bob',
MessageFormatter::formatMessage('{name} and {name}', ['name' => $this->stringable('Bob')]),
);
}

public function testAPlainStringArgumentIsUnaffected(): void
{
$this->assertSame(
'Hello Ann!',
MessageFormatter::formatMessage('Hello {name}!', ['name' => 'Ann']),
);
}

public function testANumberArgumentIsStillFormattedAsANumber(): void
{
$this->assertSame(
'2 posts',
MessageFormatter::formatMessage('{count, plural, one {# post} other {# posts}}', ['count' => 2]),
);
}

public function testAMessageWithNoPlaceholdersComesBackUnchanged(): void
{
$this->assertSame(
'Nothing to substitute',
MessageFormatter::formatMessage('Nothing to substitute', ['name' => $this->stringable('Bob')]),
);
}

/******************
* Internal methods
******************/

/**
* The simplest thing that is a string without being one.
*/
private function stringable(string $value): \Stringable
{
return new class ($value) implements \Stringable {
public function __construct(private string $value) {}

public function __toString(): string
{
return $this->value;
}
};
}
}
Loading
Loading