Add unit tests for untested edge cases and code paths
Sync GitHub / sync (push) Successful in 9s

PayloadTest:
- decode() with invalid JSON, JSON array, null, boolean, number
- load() with all fields present but empty strings
- create() with invalid scope string (falls back to cookie)
- create() with missing json property (defaults to true)

DomainManagerTest:
- TLD table coverage: com.au, co.jp, com.br, co.nz, com.mx, co.in, br.com
- Single-label host matching behavior
- Empty string host
- validReturn with URL containing port, without path, different domain with port

MakeNonceTraitTest:
- Retry-then-succeed path (first attempt collides, second succeeds)
- Zero retries throws immediately on collision

StringTraitTest:
- Only invalid characters collapses to single underscore
- Exactly 128 characters boundary
- Multibyte characters (é, ö) replaced with underscore
- Emoji replaced with underscore

GetTotpTraitTest:
- HOTP URI triggers HttpException(500) via instanceof check

LoginManagerTest:
- ULID collision throws HttpException(500)
- Central auth cookie sets domain on matching host (uses auth cookie name)
- Central auth cookie uses null domain on non-matching host
- Empty return parameter falls back to request path

BackupCodeManagerTest:
- Empty string code returns false
- Code with value false (consumed) verified via cache state
- Generated codes are unique (50 codes)
- Code length equals TOTP digits + 2

MonitorCacheKeysTest:
- saveDeferred on __key_list and __chg_list throws OutOfBoundsException
- getKeys returns empty when key list is lost
- deleteItem/deleteItems return true assertions

PersistCacheTest:
- Mixed updates and removals in same persist cycle
- Multiple boot-modify-persist cycles with interleaved modifications
This commit is contained in:
2026-08-06 06:10:31 -04:00
parent 6c5a7c98e8
commit 12ba6cde7b
9 changed files with 555 additions and 0 deletions
+43
View File
@@ -36,6 +36,28 @@ final class PayloadTest extends TestCase {
self::assertNull(Payload::decode(self::b64u('"just a string"')));
}
public function testDecodeInvalidJsonReturnsNull(): void {
// valid base64url but invalid JSON
self::assertNull(Payload::decode(self::b64u('{invalid json')));
}
public function testDecodeJsonArrayReturnsNull(): void {
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
}
public function testDecodeJsonNullReturnsNull(): void {
self::assertNull(Payload::decode(self::b64u('null')));
}
public function testDecodeJsonBooleanReturnsNull(): void {
self::assertNull(Payload::decode(self::b64u('true')));
self::assertNull(Payload::decode(self::b64u('false')));
}
public function testDecodeJsonNumberReturnsNull(): void {
self::assertNull(Payload::decode(self::b64u('42')));
}
public function testDecodeEmptyStringReturnsNull(): void {
self::assertNull(Payload::decode(''));
}
@@ -69,6 +91,12 @@ final class PayloadTest extends TestCase {
self::assertNull(Payload::load($input));
}
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void {
// has() returns true for all, but create() rejects empty values
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
self::assertNull(Payload::load($input));
}
public function testCreateWithValidData(): void {
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
@@ -90,6 +118,21 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithInvalidScopeFallsBackToCookie(): void {
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'scope' => 'admin',
];
$payload = Payload::create($data);
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithMissingJsonDefaultsToTrue(): void {
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertTrue($payload->json);
}
public function testCreateWithNoneScopeSetsJsonFalse(): void {
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
+61
View File
@@ -209,4 +209,65 @@ final class MonitorCacheKeysTest extends TestCase {
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItems(['__chg_list']);
}
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void {
$monitor = $this->wrap();
$item = $monitor->getItem('safe');
$item->set('value');
// getItem returns the real item, but saveDeferred calls update() which
// validates the key — so we need to get the __key_list item and try to save it
$keyListItem = $monitor->getItem('__key_list');
$this->expectException(OutOfBoundsException::class);
$monitor->saveDeferred($keyListItem);
}
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void {
$monitor = $this->wrap();
$changeListItem = $monitor->getItem('__chg_list');
$this->expectException(OutOfBoundsException::class);
$monitor->saveDeferred($changeListItem);
}
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void {
// If the underlying pool loses its key list, getKeys should return []
$pool = new ArrayAdapter();
$monitor = new MonitorCacheKeys($pool);
$item = $monitor->getItem('alpha');
$item->set('value');
$monitor->save($item);
// delete the key list directly from the underlying pool
$pool->deleteItem('__key_list');
$monitor2 = new MonitorCacheKeys($pool);
// the constructor will re-initialize since __key_list is missing
// but getKeys on the new monitor should be empty
self::assertSame([], $monitor2->getKeys());
}
public function testDeleteItemReturnsTrueForExistingKey(): void {
$monitor = $this->wrap();
$item = $monitor->getItem('to-delete');
$item->set('value');
$monitor->save($item);
self::assertTrue($monitor->deleteItem('to-delete'));
self::assertNotContains('to-delete', $monitor->getKeys());
}
public function testDeleteItemsReturnsTrue(): void {
$monitor = $this->wrap();
foreach (['a', 'b', 'c'] as $key) {
$item = $monitor->getItem($key);
$item->set('value');
$monitor->save($item);
}
self::assertTrue($monitor->deleteItems(['a', 'b', 'c']));
self::assertSame([], $monitor->getKeys());
}
}
+67
View File
@@ -150,4 +150,71 @@ final class PersistCacheTest extends TestCase {
self::assertContains('cookie_cycle', $monitor->getKeys());
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
}
public function testPersistHandlesMixedUpdatesAndRemovals(): void {
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
// seed storage with two items
$storageMonitor = new MonitorCacheKeys($sessionStorage);
$item1 = $storageMonitor->getItem('cookie_keep');
$item1->set('user-keep');
$storageMonitor->save($item1);
$item2 = $storageMonitor->getItem('cookie_remove');
$item2->set('user-remove');
$storageMonitor->save($item2);
$storageMonitor->markClean();
$persist = new PersistCache($sessionCache, $sessionStorage);
$persist->boot();
// update one item and delete the other in the same cycle
$cacheMonitor = new MonitorCacheKeys($sessionCache);
$item1 = $cacheMonitor->getItem('cookie_keep');
$item1->set('user-updated');
$cacheMonitor->save($item1);
$cacheMonitor->deleteItem('cookie_remove');
$persist->persist();
// storage should reflect both changes
$storageMonitor = new MonitorCacheKeys($sessionStorage);
self::assertContains('cookie_keep', $storageMonitor->getKeys());
self::assertSame('user-updated', $storageMonitor->getItem('cookie_keep')->get());
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
}
public function testMultipleBootModifyPersistCycles(): void {
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
// cycle 1: add item A
$persist = new PersistCache($sessionCache, $sessionStorage);
$persist->boot();
$cacheMonitor = new MonitorCacheKeys($sessionCache);
$item = $cacheMonitor->getItem('cookie_a');
$item->set('user-a');
$cacheMonitor->save($item);
$persist->persist();
// cycle 2: fresh cache, add item B, keep A from storage
$newCache = new ArrayAdapter();
$persist2 = new PersistCache($newCache, $sessionStorage);
$persist2->boot();
$cacheMonitor2 = new MonitorCacheKeys($newCache);
$item = $cacheMonitor2->getItem('cookie_b');
$item->set('user-b');
$cacheMonitor2->save($item);
$persist2->persist();
// cycle 3: fresh cache, both A and B should be loaded from storage
$newCache2 = new ArrayAdapter();
$persist3 = new PersistCache($newCache2, $sessionStorage);
$persist3->boot();
$monitor = new MonitorCacheKeys($newCache2);
self::assertContains('cookie_a', $monitor->getKeys());
self::assertSame('user-a', $monitor->getItem('cookie_a')->get());
self::assertContains('cookie_b', $monitor->getKeys());
self::assertSame('user-b', $monitor->getItem('cookie_b')->get());
}
}
@@ -168,4 +168,48 @@ final class BackupCodeManagerTest extends TestCase {
self::assertFalse($pool->hasItem('backup_' . strtolower($code)));
}
}
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void {
$manager = $this->makeManager();
// empty string after preg_replace becomes 'backup_' with nothing after it
self::assertFalse($manager->verifyAndConsume(''));
}
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void {
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(1);
$code = $codes[0];
// first use succeeds
self::assertTrue($manager->verifyAndConsume($code));
// the code is now marked as false (used); isHit is true but get() is false
$key = 'backup_' . strtolower($code);
$item = $pool->getItem($key);
self::assertTrue($item->isHit());
self::assertFalse($item->get());
// second use should fail because get() returns false
self::assertFalse($manager->verifyAndConsume($code));
}
public function testGenerateProducesUniqueCodes(): void {
$manager = $this->makeManager();
$codes = $manager->generate(50);
self::assertCount(50, $codes);
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
}
public function testGenerateCodeLengthIsDigitsPlusTwo(): void {
$manager = $this->makeManager();
$codes = $manager->generate(1);
// default TOTP digits is 6, so code length should be 6 + 2 = 8
self::assertSame(8, strlen($codes[0]));
}
}
+96
View File
@@ -142,4 +142,100 @@ final class DomainManagerTest extends TestCase {
self::assertFalse($manager->matchesAuth('local'));
self::assertFalse($manager->matchesAuth('app.local'));
}
/* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void {
// com.au is NOT in the TLD table (table has au? no, it doesn't),
// so it's treated as a standard 2-part TLD: base = com.au
$manager = $this->createManager(true, 'auth.example.com.au');
self::assertSame('com.au', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.au'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void {
// co.jp is NOT in the TLD table (table has jpn under com, not jp under co)
// so base = co.jp
$manager = $this->createManager(true, 'auth.example.co.jp');
self::assertSame('co.jp', $manager->authBase());
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void {
// com.br: TLD table has com => [br], meaning *.br.com is multi-part
// but com.br has last=br, TLD['br'] doesn't exist, so base = com.br
$manager = $this->createManager(true, 'auth.example.com.br');
self::assertSame('com.br', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.br'));
}
public function testMatchesAuthWithCoNzTld(): void {
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
$manager = $this->createManager(true, 'auth.example.co.nz');
self::assertSame('example.co.nz', $manager->authBase());
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
}
public function testMatchesAuthWithComMxTld(): void {
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
$manager = $this->createManager(true, 'auth.example.com.mx');
self::assertSame('example.com.mx', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
}
public function testMatchesAuthWithCoInTld(): void {
// co.in: in => [co,...], so *.co.in IS multi-part
$manager = $this->createManager(true, 'auth.example.co.in');
self::assertSame('example.co.in', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.co.in'));
}
public function testMatchesAuthWithBrComTld(): void {
// br.com: TLD table has com => [br], so *.br.com IS multi-part
$manager = $this->createManager(true, 'auth.example.br.com');
self::assertSame('example.br.com', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.br.com'));
}
public function testSimpleTldNotTreatedAsMultiPart(): void {
// example.com is a standard 2-part domain, not multi-part
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
// auth.example.org should NOT match example.com
self::assertFalse($manager->matchesAuth('app.example.org'));
}
/* ── baseDomain edge cases ───────────────────────────────────────────── */
public function testMatchesAuthWithSingleLabelHost(): void {
// a single-label domain (not localhost, not IP) has baseLength 1
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
// they won't match unless the auth subdomain itself is single-label
$manager = $this->createManager(true, 'auth.local');
// auth.local base is 'auth.local', 'local' base is 'local' -> no match
self::assertFalse($manager->matchesAuth('local'));
// but a subdomain of auth.local does match
self::assertTrue($manager->matchesAuth('app.auth.local'));
}
public function testMatchesAuthWithEmptyStringHost(): void {
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth(''));
}
public function testValidReturnAcceptsUrlWithPort(): void {
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
}
public function testValidReturnAcceptsUrlWithoutPath(): void {
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com'));
}
public function testValidReturnRejectsDifferentDomainWithPort(): void {
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
}
}
+128
View File
@@ -11,6 +11,8 @@ use App\Trait\StringTrait;
use App\Service\LoginManager;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
@@ -293,4 +295,130 @@ final class LoginManagerTest extends TestCase {
$nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123'));
self::assertFalse($nonceItem->get());
}
public function testUlidCollisionThrowsHttpException(): void {
// Use a stub pool where every cookie_ key is already a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn('existing');
// The nonce cache needs to work, so we return the stub item for
// cookie_ keys but a real working item for nonce keys.
$pool->method('getItem')->willReturnCallback(function (string $key) use ($item) {
if (str_starts_with($key, 'cookie_')) {
return $item; // collision
}
// For nonce keys, return a real item from an ArrayAdapter
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
return $realPool->getItem($key);
});
$pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) {
if (str_starts_with($key, 'cookie_')) {
return true;
}
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
return $realPool->hasItem($key);
});
$pool->method('save')->willReturn(true);
$pool->method('saveDeferred')->willReturn(true);
$pool->method('commit')->willReturn(true);
$pool->method('getItems')->willReturnCallback(function (array $keys) {
static $realPool = null;
$realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter();
return $realPool->getItems($keys);
});
$pool->method('clear')->willReturn(true);
$pool->method('deleteItem')->willReturn(true);
$pool->method('deleteItems')->willReturn(true);
$this->domainManager = new DomainManager(false, '');
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
$manager->setConfig($this->makeConfig());
$manager->setLogger(new NullLogger());
$manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
$payload = new Payload();
$payload->id = 'collide-user';
$payload->token = $this->validTotpCode();
$payload->nonce = 'test-nonce-123';
$payload->json = true;
$payload->scope = Scope::Cookie;
// inject the nonce
$this->insertNonce($manager, 'test-nonce-123');
$request = Request::create('/', 'GET');
$this->expectException(HttpException::class);
$manager->checkToken($payload, $request);
}
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void {
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
);
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::Cookie);
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
// host matches the auth base domain
$request = Request::create('https://auth.example.com/', 'GET');
$response = $manager->checkToken($payload, $request);
self::assertNotNull($response);
$cookies = $response->headers->getCookies();
self::assertCount(1, $cookies);
// when using central auth and host matches, the cookie domain is set
self::assertSame('example.com', $cookies[0]->getDomain());
// the auth cookie name is used instead of the host-prefixed name
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void {
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
);
$payload = $this->makePayloadWithNonce($manager, id: 'bob', scope: Scope::Cookie);
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
// host does NOT match the auth base domain
$request = Request::create('https://other.com/', 'GET');
$response = $manager->checkToken($payload, $request);
self::assertNotNull($response);
$cookies = $response->headers->getCookies();
self::assertCount(1, $cookies);
// domain is null when host does not match
self::assertNull($cookies[0]->getDomain());
// still uses auth cookie name since authBase is set
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void {
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
// return parameter is present but empty string
$request = Request::create('/?return=', 'GET');
$response = $manager->checkToken($payload, $request);
self::assertNotNull($response);
$location = $response->headers->get('Location');
self::assertNotNull($location);
// should fall back to path since empty string is not a valid URL
self::assertStringStartsWith('/', $location);
}
}
+18
View File
@@ -69,4 +69,22 @@ final class GetTotpTraitTest extends TestCase {
$this->expectException(\Throwable::class);
$obj->publicGetTotp();
}
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void {
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
// so the instanceof check in getTotp() should throw an HttpException(500)
$obj = $this->makeObject();
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', 0, false,
'Error', 'Teapot', 'Too Many'
);
$obj->setConfig($config);
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Internal Server Exception');
$obj->publicGetTotp();
}
}
+69
View File
@@ -104,4 +104,73 @@ final class MakeNonceTraitTest extends TestCase {
$obj->publicMakeNonce();
}
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void {
// Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for
// subsequent calls so the retry succeeds.
$realPool = new ArrayAdapter();
$collisionCount = 0;
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0;
public function __construct(
private CacheItemPoolInterface $inner,
private int &$hitCounter,
) {}
public function getItem(string $key): CacheItemInterface {
$item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision)
if ($this->hits === 0) {
$this->hits++;
$this->hitCounter++;
return new class($key) implements CacheItemInterface {
public function __construct(private string $key) {}
public function getKey(): string { return $this->key; }
public function get(): mixed { return true; }
public function isHit(): bool { return true; }
public function set(mixed $value): static { return $this; }
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
public function expiresAfter(int|\DateInterval|null $time): static { return $this; }
};
}
return $item;
}
public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); }
public function hasItem(string $key): bool { return $this->inner->hasItem($key); }
public function clear(): bool { return $this->inner->clear(); }
public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); }
public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); }
public function save(CacheItemInterface $item): bool { return $this->inner->save($item); }
public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); }
public function commit(): bool { return $this->inner->commit(); }
};
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($spyPool);
// should retry and succeed on the second attempt
$nonce = $obj->publicMakeNonce();
self::assertIsString($nonce);
self::assertSame(20, strlen($nonce));
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
}
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void {
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
$item->method('get')->willReturn(true);
$pool->method('getItem')->willReturn($item);
$pool->method('save')->willReturn(true);
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache($pool);
$this->expectException(HttpException::class);
$obj->publicMakeNonce(0);
}
}
+29
View File
@@ -28,4 +28,33 @@ final class StringTraitTest extends TestCase {
public function testMakeCacheKeyEmptyString(): void {
self::assertSame('', $this->makeCacheKey(''));
}
public function testMakeCacheKeyWithOnlyInvalidChars(): void {
// preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!'));
self::assertSame('_', $this->makeCacheKey(' '));
self::assertSame('_', $this->makeCacheKey('!@#'));
self::assertSame('_', $this->makeCacheKey('!@ #'));
}
public function testMakeCacheKeyTruncatesToExactly128(): void {
$input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
self::assertSame($input, $this->makeCacheKey($input));
$input129 = str_repeat('a', 129);
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
}
public function testMakeCacheKeyWithMultibyteChars(): void {
// multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld');
// é and ö are not in [A-Za-z0-9_.] so they become _
self::assertSame('h_llo_w_rld', $result);
}
public function testMakeCacheKeyWithEmoji(): void {
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
}
}