<?phpnamespace Tests;use App\Chat\ChatServer;use App\Chat\Frame;use App\Chat\MemorySession;use App\Chat\RoomStore;use App\Chat\TempId;use PHPUnit\Framework\TestCase;/** The chat's protocol, frame by frame, on a server held in memory (as the 2002 applet speaks it). */final class ChatServerTest extends TestCase{ private string $file; private float $now = 1000.0; private ChatServer $chat; protected function setUp(): void { $this->file = sys_get_temp_dir() . '/caramail-test-' . bin2hex(random_bytes(4)) . '/rooms.json'; $this->chat = new ChatServer(new RoomStore($this->file), 'secret', null, fn () => $this->now); } protected function tearDown(): void { @unlink($this->file); @rmdir(dirname($this->file)); } private function login(string $nickname, int $age = 25, int $sex = 0, string $city = 'Paris', string $key = ''): MemorySession { $s = new MemorySession($nickname); $this->chat->connect($s); $this->chat->receive($s, '0', ['0', TempId::make($nickname, $age, $sex, $city, $key)]); return $s; } public function testANewClientIsGreetedWithTheBanner(): void { $s = new MemorySession(); $this->chat->connect($s); self::assertSame(["100\t" . ChatServer::BANNER], $s->received); } public function testTheLoginAnswersTheIdTheNicknameAndTheConnectedList(): void { $s = $this->login('Alice', 24, 1, 'Lyon'); [$id, $code, $zero, $nickname] = explode("\t", $s->frames('0')[0]); self::assertMatchesRegularExpression('/^\d+:\d+:\d+:0$/', $id); self::assertSame(['197', '0', 'Alice'], [$code, $zero, $nickname]); self::assertSame(['-1'], $s->frames('330')); self::assertSame("-2:-2:-2\t$id\tAlice\t0\t24\t1\tLyon\t?\t2\t", $s->frames('56')[0]); } public function testABadTempidIsDropped(): void { $s = new MemorySession(); $this->chat->connect($s); $this->chat->receive($s, '0', ['0', '']); self::assertTrue($s->closed); self::assertSame([], $this->chat->users()); } public function testAFrameBeforeTheLoginIsDropped(): void { $s = new MemorySession(); $this->chat->connect($s); $this->chat->receive($s, '70', ['0100hello']); self::assertTrue($s->closed); } public function testTwoMembersCannotHaveTheSameNickname(): void { $this->login('Bob'); $second = $this->login('bob'); self::assertSame('bob_2', explode("\t", $second->frames('0')[0])[3]); } public function testEnteringARoomTellsItsMembersAndSendsItsLastSentences(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '20', ['Salon', '0']); $this->chat->receive($alice, '70', ['0100Bonjour']); $alice->take(); $this->chat->receive($bob, '20', ['Salon', '0']); $bobId = $this->idOf($bob); self::assertSame(["0\t$bobId\t0\tBob\t0\t25\t0\tParis\t"], $alice->frames('85')); self::assertSame(["0\tSalon\t?\tadmin\t"], $bob->frames('20')); self::assertSame([$this->idOf($alice) . "\t0\tAlice\t0100Bonjour\t"], $bob->frames('70')); } public function testASentenceGoesToEveryMemberOfTheRoomOnly(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $carol = $this->login('Carol'); $this->chat->receive($alice, '20', ['Musique', '0']); $this->chat->receive($bob, '20', ['Musique', '0']); $this->chat->receive($carol, '20', ['Sport', '0']); foreach ([$alice, $bob, $carol] as $s) { $s->take(); } $this->chat->receive($alice, '70', ['0302Salut :-)']); $line = $this->idOf($alice) . "\t0\tAlice\t0302Salut :-)\t"; self::assertSame([$line], $alice->frames('70')); self::assertSame([$line], $bob->frames('70')); self::assertSame([], $carol->frames('70')); } public function testLeavingARoomTellsTheOthersAndAnEmptyRoomDisappears(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '20', ['Ma salle', '0']); $this->chat->receive($bob, '20', ['Ma salle', '0']); $bob->take(); $this->chat->receive($alice, '20', ['Salon', '0']); self::assertSame(["1\t" . $this->idOf($alice) . "\t0\tAlice\t0\t25\t0\tParis\t"], $bob->frames('85')); $this->chat->disconnect($bob); self::assertNull($this->chat->room('Ma salle')); self::assertNotNull($this->chat->room('Salon')); } public function testAFullRoomOf32SendsTheNextOnesToNumberedRooms(): void { for ($i = 0; $i < 64; $i++) { $this->chat->receive($this->login("m$i"), '20', ['Foule', '0']); } self::assertSame(32, $this->chat->room('Foule')->count()); self::assertSame(32, $this->chat->room('Foule#1')->count()); $late = $this->login('Late'); $this->chat->receive($late, '20', ['Foule', '0']); self::assertSame(["0\tFoule#2\t?\tLate\t"], $late->frames('20')); } public function testThePermanentRoomsAreListedWithTheirMembers(): void { $alice = $this->login('Alice'); $this->chat->receive($alice, '20', ['Salon', '0']); $this->chat->receive($alice, '20', ['Salon', '0']); $this->chat->receive($alice, '94', []); self::assertStringStartsWith("Salon\t?\tadmin\t1\t10 - 15 ans\t?\tadmin\t0\t", $alice->frames('94')[0]); $this->chat->receive($alice, '20', ['Chez moi', '0']); $this->chat->receive($alice, '90', []); self::assertSame(["Chez moi\t?\tAlice\t1\t"], $alice->frames('90')); } public function testAPrivateMessageReachesItsReceiverAndEchoesToTheSender(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $alice->take(); $bob->take(); $this->chat->receive($alice, '72', [$this->idOf($bob), '0100Coucou']); self::assertSame([$this->idOf($bob) . "\t0\tBob\t0100Coucou\t"], $alice->frames('73')); self::assertSame([$this->idOf($alice) . "\t0\tAlice\t0100Coucou\t"], $bob->frames('72')); } private function idOf(MemorySession $s): string { foreach ($this->chat->users() as $user) { if ($user->session === $s) { return $user->id; } } self::fail('no user'); } public function testTheProfileOfAMemberIsGiven(): void { $alice = $this->login('Alice', 31, 1, 'Nantes'); $bob = $this->login('Bob'); $this->chat->receive($bob, '82', [$this->idOf($alice)]); self::assertSame([$this->idOf($alice) . "\t0\t31\t1\tNantes\t"], $bob->frames('82')); } public function testTheSearchFiltersByNicknameCitySexAndAge(): void { $this->login('Alice', 24, 1, 'Lyon'); $this->login('Albert', 52, 0, 'Lille'); $me = $this->login('Zoe', 30, 1, 'Paris'); $me->take(); $this->chat->receive($me, '56', ['al', '-2', '-2']); self::assertStringContainsString("\tAlice\t", $me->frames('56')[0]); self::assertStringContainsString("\tAlbert\t", $me->frames('56')[0]); $this->chat->receive($me, '56', ['al', '-2', '1']); self::assertStringStartsWith("-2:1:1\t", $me->frames('56')[1]); self::assertStringNotContainsString('Albert', $me->frames('56')[1]); $this->chat->receive($me, '58', ['li', '-2', '-2']); self::assertStringContainsString("\tAlbert\t", $me->frames('58')[0]); self::assertStringNotContainsString('Alice', $me->frames('58')[0]); $this->chat->receive($me, '56', ['', '10', '-2']); // 70 and more: nobody self::assertSame("10:-2\t", $me->frames('56')[2]); } public function testTheAgeBandsOfTheApplet(): void { self::assertSame([10, 15], ChatServer::ageRange(2)); self::assertSame([30, 40], ChatServer::ageRange(6)); self::assertSame([50, 60], ChatServer::ageRange(8)); self::assertSame([70, PHP_INT_MAX], ChatServer::ageRange(10)); } public function testTheNumberOfConnectedMembers(): void { $this->login('A'); $b = $this->login('B'); $this->chat->receive($b, '199', []); self::assertSame(['2'], $b->frames('199')); } public function testAModeratorEjectsSomebodyAndTheRoomIsTold(): void { $modo = $this->login('Modo', key: 'secret'); $bad = $this->login('Troll'); $this->chat->receive($modo, '20', ['Salon', '0']); $this->chat->receive($bad, '20', ['Salon', '0']); $modo->take(); $badId = $this->idOf($bad); $this->chat->receive($modo, '555', ['Troll:0']); self::assertSame([$this->idOf($modo) . "\tModo\t"], $bad->frames('555')); self::assertSame(["5\t" . $badId . "\tTroll\t" . $this->idOf($modo) . "\tModo\t"], $modo->frames('76')); self::assertTrue($bad->closed, 'the applet ends its session on 555'); self::assertCount(1, $this->chat->room('Salon')->members); } public function testAMemberCannotEjectSomebodyElse(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '555', ['Bob:0']); self::assertSame([], $bob->frames('555')); } public function testAModeratorUpdateReachesEverybody(): void { $modo = $this->login('Modo', key: 'secret'); $other = $this->login('Other'); $this->chat->receive($modo, '20', ['Salon', '0']); $this->chat->receive($modo, '70', ['0103!update Maintenance à 22h']); self::assertSame(['0503Maintenance à 22h'], $other->frames('77')); $this->chat->receive($other, '20', ['Salon', '0']); $this->chat->receive($other, '70', ['0100!update pirate']); self::assertCount(1, $other->frames('77')); } public function testQuietClientsArePingedAndSilentOnesDropped(): void { $s = $this->login('Sleepy'); $this->now += ChatServer::PING_EVERY + 1; $this->chat->tick(); self::assertCount(1, $s->frames('667')); $this->now += ChatServer::TIMEOUT; $this->chat->tick(); self::assertTrue($s->closed); self::assertSame([], $this->chat->users()); } public function testThePermanentRoomsAndTheirSentencesSurviveARestart(): void { $alice = $this->login('Alice'); $this->chat->receive($alice, '20', ['Salon', '0']); $this->chat->receive($alice, '70', ['0100Je reviens']); $this->chat->shutdown(); $again = new ChatServer(new RoomStore($this->file)); self::assertSame('0100Je reviens', $again->room('Salon')->history[0]['text']); self::assertSame(0, $again->room('Salon')->count()); } public function testAnEmptyCityIsSentAsAQuestionMarkForTheAppletsTokenizer(): void { $s = $this->login('Nobody', 30, 1, ''); self::assertStringContainsString("\tNobody\t0\t30\t1\t?\t", $s->frames('56')[0]); } public function testAMemberWhoRefusesPrivateTalksAnswers75(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($bob, '26', ['2']); self::assertSame(['2'], $bob->frames('26')); $this->chat->receive($alice, '72', [$this->idOf($bob), '0100Coucou']); self::assertSame([$this->idOf($bob) . "\tBob"], $alice->frames('75')); self::assertSame([], $bob->frames('72')); } public function testAPrivateMessageCanNameItsReceiverByNicknameAndOrigin(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '72', ['bob:0', '0100Psst']); self::assertCount(1, $bob->frames('72')); } public function testThePrivatePartnersHear74WhenSomebodyLeaves(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '72', [$this->idOf($bob), '0100Salut']); $aliceId = $this->idOf($alice); $this->chat->disconnect($alice); self::assertSame([$aliceId], $bob->frames('74')); } public function testTheIgnoreListIsKeptAndSentBack(): void { $alice = $this->login('Alice'); $this->chat->receive($alice, '18', ['0', 'Troll:0']); $this->chat->receive($alice, '18', ['0', 'Pest:0']); $this->chat->receive($alice, '18', ['1', 'Troll:0']); self::assertSame("-1\tPest:0", $alice->frames('18')[2]); } public function testCallingAModeratorReachesTheModerators(): void { $modo = $this->login('Modo', key: 'secret'); $alice = $this->login('Alice'); $this->chat->receive($alice, '20', ['Salon', '0']); $this->chat->receive($alice, '550', []); self::assertSame(["Salon\t1"], $modo->frames('550'), 'one member signals a problem in Salon'); self::assertSame(['1'], $alice->frames('550'), 'the caller: "Un modérateur a été prévenu"'); $this->chat->disconnect($modo); $this->chat->receive($alice, '550', []); self::assertCount(1, $alice->frames('551'), 'no moderator: "Aucun modérateur n\'est joignable"'); } public function testTheRoomTellsWhoComesAndGoesAsTheAppletsInformationLines(): void { $alice = $this->login('Alice'); $bob = $this->login('Bob'); $this->chat->receive($alice, '20', ['Salon', '0']); self::assertSame(["1\tSalon"], $alice->frames('76'), '"Bienvenue dans le salon"'); $this->chat->receive($bob, '20', ['Salon', '0']); $this->chat->receive($bob, '20', ['Musique', '0']); $this->chat->receive($bob, '20', ['Salon', '0']); $this->chat->disconnect($bob); self::assertSame(["1\tSalon", "2\tBob", "3\tBob", "2\tBob", "4\tBob"], $alice->frames('76')); } public function testFramesAreCutAtLineEndsAndKeepTheUnfinishedOne(): void { $buffer = "70\t0100a\n667\n\n20\tAccu"; self::assertSame([['70', ['0100a']], ['667', []]], Frame::decode($buffer)); self::assertSame("20\tAccu", $buffer); self::assertSame("667\t\n", Frame::encode('667')); self::assertSame("70\ta b\n", Frame::encode('70', ["a\nb"])); } /** The level the server gave somebody: its profile (82) as another member reads it. */ private function levelOf(MemorySession $who): string { $reader = $this->login('Reader' . random_int(100, 999)); $id = explode("\t", $who->frames('0')[0])[0]; $this->chat->receive($reader, '82', [$id]); return explode("\t", $reader->frames('82')[0])[1]; } private function loginFrom(string $ip, string $nickname, string $extra): MemorySession { $s = new MemorySession($nickname); $s->ip = $ip; $this->chat->connect($s); $this->chat->receive($s, '0', ['0', TempId::make($nickname, 30, 1, 'Paris') . $extra]); return $s; } public function testTheAdministratorKeyGivesTheSecondLevelAndTheModeratorKeyTheFirst(): void { $this->chat->setRoles('super', []); self::assertSame('2', $this->levelOf($this->login('Boss', key: 'super'))); self::assertSame('1', $this->levelOf($this->login('Modo', key: 'secret'))); self::assertSame('0', $this->levelOf($this->login('Bob', key: 'wrong'))); } public function testTheOwnersAddressChoosesItsRoleAndNobodyElseCan(): void { $this->chat->setRoles('', ['82.67.98.164']); self::assertSame('2', $this->levelOf($this->loginFrom('82.67.98.164', 'Owner', '&r=2'))); self::assertSame('1', $this->levelOf($this->loginFrom('82.67.98.164', 'OwnerModo', '&r=1'))); self::assertSame('0', $this->levelOf($this->loginFrom('82.67.98.164', 'OwnerPlain', '&r=0'))); self::assertSame('0', $this->levelOf($this->loginFrom('1.2.3.4', 'Stranger', '&r=2'))); } public function testTheLoginFormLearnsWhetherItIsTheOwnersAddress(): void { $this->chat->setRoles('', ['82.67.98.164']); foreach (['82.67.98.164' => '1', '1.2.3.4' => '0'] as $ip => $answer) { $s = new MemorySession(); $s->ip = $ip; $this->chat->connect($s); $this->chat->receive($s, '700', []); self::assertSame([$answer], $s->frames('700')); self::assertFalse($s->closed); } }}