*/ protected static array $identifierToToken; /** @var array */ protected static array $identifierToId; /** @var array */ protected static array $tokenToIdentifier; /** @var array */ protected static array $sessionIdToUser; /** @var array */ protected static array $sessionNameToActorId; /** @var array */ protected static array $userToSessionId; /** @var array */ protected static array $userToAttendeeId; /** @var array */ protected static array $textToMessageId; /** @var array */ protected static array $titleToThreadId; /** @var array */ protected static array $messageIdToText; /** @var array */ protected static array $threadIdToTitle; /** @var array */ protected static array $aiTaskIds; /** @var array */ protected static array $remoteToInviteId; /** @var array */ protected static array $inviteIdToRemote; /** @var array */ protected static array $remoteAuth; /** @var array */ protected static array $questionToPollId; /** @var array[] */ protected static array $lastNotifications; /** @var array */ protected static array $botIdToName; /** @var array */ protected static array $botNameToId; /** @var array */ protected static array $botNameToHash; /** @var array */ protected static array $phoneNumberToActorId; /** @var array|null */ protected static ?array $nextChatRequestParameters = null; /** @var array */ protected static array $modifiedSince; protected static array $createdTeams = []; protected static array $renamedTeams = []; /** @var array */ protected static array $userToBanId; protected static ?string $queryLogFile = null; protected static ?string $currentScenario = null; /** @var array */ protected static array $identifierToObjectId = []; protected static array $permissionsMap = [ 'D' => 0, // PERMISSIONS_DEFAULT 'C' => 1, // PERMISSIONS_CUSTOM 'S' => 2, // PERMISSIONS_CALL_START 'J' => 4, // PERMISSIONS_CALL_JOIN 'L' => 8, // PERMISSIONS_LOBBY_IGNORE 'A' => 16, // PERMISSIONS_PUBLISH_AUDIO 'V' => 32, // PERMISSIONS_PUBLISH_VIDEO 'P' => 64, // PERMISSIONS_PUBLISH_SCREEN 'M' => 128, // PERMISSIONS_CHAT ]; protected ?string $currentUser = null; private ?ResponseInterface $response; /** @var CookieJar[] */ private array $cookieJars; protected string $localServerUrl; protected string $remoteServerUrl; protected string $baseUrl; protected string $currentServer; /** @var string[] */ protected array $createdUsers = []; /** @var string[] */ protected array $createdGroups = []; /** @var string[] */ protected array $createdGuestAccountUsers = []; protected array $changedConfigs = []; protected bool $changedBruteforceSetting = false; private ?SharingContext $sharingContext; private array $guestsAppWasEnabled = []; private array $testingAppWasEnabled = []; private array $taskProcessingProviderPreference = []; private array $guestsOldWhitelist = []; use CommandLineTrait; use RecordingTrait; public static function getTokenForIdentifier(string $identifier): string { return self::$identifierToToken[$identifier]; } public static function getTeamIdForLabel(string $server, string $label): string { return self::$createdTeams[$server][$label] ?? self::$renamedTeams[$server][$label] ?? throw new \RuntimeException('Unknown team: ' . $label); } public static function getRoomLocationForToken(string $identifier): string { return getenv('TEST_SERVER_URL') . '/call/' . self::$identifierToToken[$identifier] ?? throw new \RuntimeException('Unknown token: ' . $identifier); } public static function getMessageIdForText(string $text): int { return self::$textToMessageId[$text]; } public static function getActorIdForPhoneNumber(string $phoneNumber): string { return self::$phoneNumberToActorId[$phoneNumber]; } public static function getAttendeeIdForPhoneNumber(string $identifier, string $phoneNumber): int { return self::$userToAttendeeId[$identifier]['phones'][self::$phoneNumberToActorId[$phoneNumber]]; } public static function getSessionIdForUser(string $user): string { return self::$userToSessionId[$user]; } public function getAttendeeId(string $type, string $id, string $room, ?string $user = null): int { if ($type === 'federated_users') { if (!str_contains($id, '@')) { $id .= '@' . $this->remoteServerUrl; } else { $id = str_replace( ['LOCAL', 'REMOTE'], [$this->localServerUrl, $this->remoteServerUrl], $id ); } $id = rtrim($id, '/'); } if (!isset(self::$userToAttendeeId[$room][$type][$id])) { if ($user !== null) { $this->userLoadsAttendeeIdsInRoom($user, $room, 'v4'); } else { throw new \Exception('Attendee id unknown, please call userLoadsAttendeeIdsInRoom with a user that has access before'); } } if (!isset(self::$userToAttendeeId[$room][$type][$id])) { throw new \Exception('Attendee id unknown, please call userLoadsAttendeeIdsInRoom with a user that has access before'); } return self::$userToAttendeeId[$room][$type][$id]; } /** * FeatureContext constructor. */ public function __construct() { $this->cookieJars = []; $this->localServerUrl = getenv('TEST_SERVER_URL'); $this->remoteServerUrl = getenv('TEST_REMOTE_URL'); foreach (['LOCAL', 'REMOTE'] as $server) { $this->changedConfigs[$server] = []; $this->guestsAppWasEnabled[$server] = null; $this->testingAppWasEnabled[$server] = null; $this->guestsOldWhitelist[$server] = ''; } } #[BeforeScenario] public function setUp(BeforeScenarioScope $scope): void { self::$currentScenario = $scope->getFeature()->getTitle() . ':' . $scope->getScenario()->getLine() . ' - ' . $scope->getScenario()->getTitle(); self::$identifierToToken = []; self::$identifierToId = []; self::$botNameToId = []; self::$tokenToIdentifier = []; self::$sessionNameToActorId = []; self::$sessionIdToUser = [ 'cli' => 'cli', 'system' => 'system', 'failed-to-get-session' => 'failed-to-get-session', ]; self::$userToSessionId = []; self::$userToAttendeeId = []; self::$userToBanId = []; self::$textToMessageId = []; self::$messageIdToText = []; self::$titleToThreadId = []; self::$threadIdToTitle = []; self::$questionToPollId = []; self::$lastNotifications = []; self::$phoneNumberToActorId = []; self::$modifiedSince = []; foreach (['LOCAL', 'REMOTE'] as $server) { $this->createdUsers[$server] = []; $this->createdGroups[$server] = []; self::$createdTeams[$server] = []; self::$renamedTeams[$server] = []; $this->createdGuestAccountUsers[$server] = []; } // Force getting sibling contexts to ensure that sharingContext is set // before using it. $this->getOtherRequiredSiblingContexts($scope); $this->usingServer('LOCAL'); } #[BeforeScenario] public function getOtherRequiredSiblingContexts(BeforeScenarioScope $scope): void { $environment = $scope->getEnvironment(); $this->sharingContext = $environment->getContext('SharingContext'); } #[AfterScenario] public function tearDown(): void { foreach (['LOCAL', 'REMOTE'] as $server) { $this->usingServer($server); foreach (self::$createdTeams[$server] as $team => $id) { $this->deleteTeam((string)$team); } foreach ($this->createdUsers[$server] as $user) { $this->deleteUser($user); } foreach ($this->createdGroups[$server] as $group) { $this->deleteGroup($group); } foreach ($this->createdGuestAccountUsers[$server] as $user) { $this->deleteGuestUser($user); } } } #[Given('/^using server "(LOCAL|REMOTE)"$/')] public function usingServer(string $server): void { if ($server === 'LOCAL') { $this->baseUrl = $this->localServerUrl; } else { $this->baseUrl = $this->remoteServerUrl; } $this->currentServer = $server; $this->sharingContext->setCurrentServer($this->currentServer, $this->localServerUrl); } #[Then('/^user "([^"]*)" cannot find any listed rooms \((v4)\)$/')] public function userCannotFindAnyListedRooms(string $user, string $apiVersion): void { $this->userCanFindListedRoomsWithTerm($user, '', $apiVersion, null); } #[Then('/^user "([^"]*)" cannot find any listed rooms with (\d+) \((v4)\)$/')] public function userCannotFindAnyListedRoomsWithStatus(string $user, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/listed-room'); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" cannot find any listed rooms with term "([^"]*)" \((v4)\)$/')] public function userCannotFindAnyListedRoomsWithTerm(string $user, string $term, string $apiVersion): void { $this->userCanFindListedRoomsWithTerm($user, $term, $apiVersion); } #[Then('/^user "([^"]*)" can find listed rooms \((v4)\)$/')] public function userCanFindListedRooms(string $user, string $apiVersion, ?TableNode $formData = null): void { $this->userCanFindListedRoomsWithTerm($user, '', $apiVersion, $formData); } #[Then('/^user "([^"]*)" can find listed rooms with term "([^"]*)" \((v4)\)$/')] public function userCanFindListedRoomsWithTerm(string $user, string $term, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $suffix = ''; if ($term !== '') { $suffix = '?searchTerm=' . \rawurlencode($term); } $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/listed-room' . $suffix); $this->assertStatusCode($this->response, 200); $rooms = $this->getDataFromResponse($this->response); if ($formData === null) { Assert::assertEmpty($rooms); return; } $this->assertRooms($rooms, $formData); } #[Then('/^user "([^"]*)" is participant of the following (unordered )?(note-to-self )?(modified-since )?rooms \((v4)\)$/')] public function userIsParticipantOfRooms(string $user, string $shouldOrder, string $shouldFilter, string $modifiedSince, string $apiVersion, ?TableNode $formData = null): void { $parameters = ''; if ($modifiedSince !== '') { if (!isset(self::$modifiedSince[$user])) { throw new \RuntimeException('Must run once without "modified-since" before'); } $parameters .= '?modifiedSince=' . self::$modifiedSince[$user]; } $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room' . $parameters); $this->assertStatusCode($this->response, 200); self::$modifiedSince[$user] = time(); $rooms = $this->getDataFromResponse($this->response); if ($shouldFilter === '') { $rooms = array_filter($rooms, static function (array $room) { // Filter out "Talk updates" and "Note to self" conversations return $room['type'] !== 4 && $room['type'] !== 6 && $room['objectType'] !== 'sample'; }); } elseif ($shouldFilter === 'note-to-self ') { $rooms = array_filter($rooms, static function (array $room) { // Filter out "Talk updates" conversations return $room['type'] !== 4 && $room['objectType'] !== 'sample'; }); } if ($formData === null) { Assert::assertEmpty($rooms); return; } $this->assertRooms($rooms, $formData, $shouldOrder !== ''); } #[Then('/^user "([^"]*)" sees the following breakout rooms for room "([^"]*)" with (\d+) \((v4)\)$/')] public function userListsBreakoutRooms(string $user, string $identifier, int $status, string $apiVersion, ?TableNode $formData = null): void { $token = self::$identifierToToken[$identifier]; $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . $token . '/breakout-rooms'); $this->assertStatusCode($this->response, $status); if ($status !== 200) { return; } $rooms = $this->getDataFromResponse($this->response); $rooms = array_filter($rooms, static function (array $room) { // Filter out "Talk updates" and "Note to self" conversations return $room['type'] !== 4 && $room['type'] !== 6 && $room['objectType'] !== 'sample'; }); if ($formData === null) { Assert::assertEmpty($rooms); return; } $this->assertRooms($rooms, $formData, true); } private function assertRooms(array $rooms, TableNode $formData, bool $shouldOrder = false): void { Assert::assertCount(count($formData->getHash()), $rooms, 'Room count does not match'); $expected = $formData->getHash(); $count = count($expected); for ($i = 0; $i < $count; $i++) { if (isset($expected[$i]['objectId']) && preg_match('/OBJECT_ID\(([^)]+)\)/', $expected[$i]['objectId'], $matches)) { $expected[$i]['objectId'] = self::$identifierToObjectId[$matches[1]]; } } if ($shouldOrder) { $sorter = static function (array $roomA, array $roomB): int { if (str_starts_with($roomA['name'], '/')) { return 1; } if (str_starts_with($roomB['name'], '/')) { return -1; } $idA = $roomA['id'] ?? self::$identifierToId[$roomA['name']]; $idB = $roomB['id'] ?? self::$identifierToId[$roomB['name']]; if (isset(self::$identifierToId[$idA])) { $idA = self::$identifierToId[$idA]; } else { self::$identifierToId[$roomA['name']] = $idA; } if (isset(self::$identifierToId[$idB])) { $idB = self::$identifierToId[$idB]; } else { self::$identifierToId[$roomB['name']] = $idB; } if ($idA === $idB) { if (isset($roomA['remoteServer'], $roomB['remoteServer'])) { return $roomA['remoteServer'] < $roomB['remoteServer'] ? -1 : 1; } if (isset($roomA['remoteServer'])) { return 1; } if (isset($roomB['remoteServer'])) { return -1; } } return $idA < $idB ? -1 : 1; }; usort($rooms, $sorter); usort($expected, $sorter); } Assert::assertEquals($expected, array_map(function (array $room, array $expectedRoom): array { if (!isset(self::$identifierToToken[$room['name']])) { self::$identifierToToken[$room['name']] = $room['token']; } if (!isset(self::$tokenToIdentifier[$room['token']])) { self::$tokenToIdentifier[$room['token']] = $room['name']; } $data = []; if (isset($expectedRoom['id'])) { $data['id'] = self::$tokenToIdentifier[$room['token']]; } if (isset($expectedRoom['name'])) { $data['name'] = $room['name']; // Breakout room regex if (str_starts_with($expectedRoom['name'], '/') && preg_match($expectedRoom['name'], $room['name'])) { $data['name'] = $expectedRoom['name']; } } if (isset($expectedRoom['description'])) { $data['description'] = $room['description']; } if (isset($expectedRoom['type'])) { $data['type'] = (string)$room['type']; } if (isset($expectedRoom['remoteServer'])) { $data['remoteServer'] = isset($room['remoteServer']) ? self::translateRemoteServer($room['remoteServer']) : ''; } if (isset($expectedRoom['remoteToken'])) { if (isset($room['remoteToken'])) { $data['remoteToken'] = self::$tokenToIdentifier[$room['remoteToken']] ?? 'unknown-token'; } else { $data['remoteToken'] = ''; } } if (isset($expectedRoom['hasPassword'])) { $data['hasPassword'] = (string)$room['hasPassword']; } if (isset($expectedRoom['readOnly'])) { $data['readOnly'] = (string)$room['readOnly']; } if (isset($expectedRoom['listable'])) { $data['listable'] = (string)$room['listable']; } if (isset($expectedRoom['isArchived'])) { $data['isArchived'] = (int)$room['isArchived']; } if (isset($expectedRoom['isSensitive'])) { $data['isSensitive'] = (int)$room['isSensitive']; } if (isset($expectedRoom['participantType'])) { $data['participantType'] = (string)$room['participantType']; } if (isset($expectedRoom['sipEnabled'])) { $data['sipEnabled'] = (string)$room['sipEnabled']; } if (isset($expectedRoom['callFlag'])) { $data['callFlag'] = (int)$room['callFlag']; } if (isset($expectedRoom['lobbyState'])) { $data['lobbyState'] = (int)$room['lobbyState']; } if (!empty($expectedRoom['lobbyTimer'])) { $data['lobbyTimer'] = (int)$room['lobbyTimer']; } if (isset($expectedRoom['hiddenPinnedId'])) { if ($room['hiddenPinnedId'] === 0) { $data['hiddenPinnedId'] = 'EMPTY'; } else { $data['hiddenPinnedId'] = self::$messageIdToText[(int)$room['hiddenPinnedId']] ?? 'UNKNOWN_MESSAGE'; } } if (isset($expectedRoom['lastPinnedId'])) { if ($room['lastPinnedId'] === 0) { $data['lastPinnedId'] = 'EMPTY'; } else { $data['lastPinnedId'] = self::$messageIdToText[(int)$room['lastPinnedId']] ?? 'UNKNOWN_MESSAGE'; } } if (isset($expectedRoom['lobbyTimer'])) { $data['lobbyTimer'] = (int)$room['lobbyTimer']; if ($expectedRoom['lobbyTimer'] === 'GREATER_THAN_ZERO' && $room['lobbyTimer'] > 0) { $data['lobbyTimer'] = 'GREATER_THAN_ZERO'; } } if (isset($expectedRoom['breakoutRoomMode'])) { $data['breakoutRoomMode'] = (int)$room['breakoutRoomMode']; } if (isset($expectedRoom['breakoutRoomStatus'])) { $data['breakoutRoomStatus'] = (int)$room['breakoutRoomStatus']; } if (isset($expectedRoom['attendeePin'])) { $data['attendeePin'] = $room['attendeePin'] ? '**PIN**' : ''; } if (isset($expectedRoom['lastMessage'])) { if (isset($room['lastMessage'])) { $data['lastMessage'] = $room['lastMessage'] ? $room['lastMessage']['message'] : ''; } else { $data['lastMessage'] = 'UNSET'; } } if (isset($expectedRoom['lastMessageActorType'])) { $data['lastMessageActorType'] = $room['lastMessage'] ? $room['lastMessage']['actorType'] : ''; } if (isset($expectedRoom['lastMessageActorId'])) { $data['lastMessageActorId'] = $room['lastMessage'] ? $room['lastMessage']['actorId'] : ''; $data['lastMessageActorId'] = str_replace(rtrim($this->localServerUrl, '/'), '{$LOCAL_URL}', $data['lastMessageActorId']); $data['lastMessageActorId'] = str_replace(rtrim($this->remoteServerUrl, '/'), '{$REMOTE_URL}', $data['lastMessageActorId']); } if (isset($expectedRoom['lastReadMessage'])) { $data['lastReadMessage'] = self::$messageIdToText[(int)$room['lastReadMessage']] ?? ($room['lastReadMessage'] === -2 ? 'FIRST_MESSAGE_UNREAD': 'UNKNOWN_MESSAGE'); } if (isset($expectedRoom['unreadMessages'])) { $data['unreadMessages'] = (int)$room['unreadMessages']; } if (isset($expectedRoom['unreadMention'])) { $data['unreadMention'] = (int)$room['unreadMention']; } if (isset($expectedRoom['unreadMentionDirect'])) { $data['unreadMentionDirect'] = (int)$room['unreadMentionDirect']; } if (isset($expectedRoom['messageExpiration'])) { $data['messageExpiration'] = (int)$room['messageExpiration']; } if (isset($expectedRoom['callRecording'])) { $data['callRecording'] = (int)$room['callRecording']; } if (isset($expectedRoom['recordingConsent'])) { $data['recordingConsent'] = (int)$room['recordingConsent']; } if (isset($expectedRoom['permissions'])) { $data['permissions'] = $this->mapPermissionsAPIOutput($room['permissions']); } if (isset($expectedRoom['attendeePermissions'])) { $data['attendeePermissions'] = $this->mapPermissionsAPIOutput($room['attendeePermissions']); } if (isset($expectedRoom['callPermissions'])) { $data['callPermissions'] = $this->mapPermissionsAPIOutput($room['callPermissions']); } if (isset($expectedRoom['defaultPermissions'])) { $data['defaultPermissions'] = $this->mapPermissionsAPIOutput($room['defaultPermissions']); } if (isset($expectedRoom['mentionPermissions'])) { $data['mentionPermissions'] = (int)$room['mentionPermissions']; } if (isset($expectedRoom['participants'])) { throw new \Exception('participants key needs to be checked via participants endpoint'); } if (isset($expectedRoom['objectId'])) { $data['objectId'] = $room['objectId']; } if (isset($expectedRoom['objectType'])) { $data['objectType'] = $room['objectType']; } return $data; }, $rooms, $expected)); } #[Then('/^user "([^"]*)" has the following invitations \((v1)\)$/')] public function userHasInvites(string $user, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/federation/invitation'); $this->assertStatusCode($this->response, 200); $invites = $this->getDataFromResponse($this->response); if ($formData === null) { Assert::assertEmpty($invites, json_encode($invites, JSON_PRETTY_PRINT)); return; } $this->assertInvites($invites, $formData); foreach ($invites as $data) { self::$remoteToInviteId[$this->translateRemoteServer($data['remoteServerUrl']) . '::' . self::$tokenToIdentifier[$data['remoteToken']]] = $data['id']; self::$inviteIdToRemote[$data['id']] = $this->translateRemoteServer($data['remoteServerUrl']) . '::' . self::$tokenToIdentifier[$data['remoteToken']]; self::$identifierToToken['LOCAL::' . $data['roomName']] = $data['localToken']; } } #[Then('/^user "([^"]*)" (accepts|declines) invite to room "([^"]*)" of server "([^"]*)" with (\d+) \((v1)\)$/')] public function userAcceptsDeclinesRemoteInvite(string $user, string $acceptsDeclines, string $identifier, string $server, int $status, string $apiVersion, ?TableNode $formData = null): void { $inviteId = self::$remoteToInviteId[$server . '::' . $identifier]; $verb = $acceptsDeclines === 'accepts' ? 'POST' : 'DELETE'; $this->setCurrentUser($user); $this->sendRequest($verb, '/apps/spreed/api/' . $apiVersion . '/federation/invitation/' . $inviteId); $this->assertStatusCode($this->response, $status); $response = $this->getDataFromResponse($this->response); if ($formData) { if ($status === 200) { if (!isset(self::$tokenToIdentifier[$response['token']])) { self::$tokenToIdentifier[$response['token']] = $server . '::' . $identifier; } $this->assertRooms([$response], $formData); } else { Assert::assertSame($formData->getRowsHash(), $response); } } else { Assert::assertEmpty($response); } } private function assertInvites(array $invites, TableNode $formData): void { Assert::assertCount(count($formData->getHash()), $invites, 'Invite count does not match'); $expectedInvites = array_map(static function ($expectedInvite): array { if (isset($expectedInvite['state'])) { $expectedInvite['state'] = (int)$expectedInvite['state']; } return $expectedInvite; }, $formData->getHash()); Assert::assertEquals($expectedInvites, array_map(function ($invite, $expectedInvite): array { $data = []; if (isset($expectedInvite['id'])) { $data['id'] = self::$tokenToIdentifier[$invite['token']]; } if (isset($expectedInvite['inviterCloudId'])) { $data['inviterCloudId'] = $this->translateRemoteServer($invite['inviterCloudId']); } if (isset($expectedInvite['inviterDisplayName'])) { $data['inviterDisplayName'] = $invite['inviterDisplayName']; } if (isset($expectedInvite['remoteToken'])) { $data['remoteToken'] = self::$tokenToIdentifier[$invite['remoteToken']] ?? 'unknown-token'; } if (isset($expectedInvite['remoteServerUrl'])) { $data['remoteServerUrl'] = $this->translateRemoteServer($invite['remoteServerUrl']); } if (isset($expectedInvite['state'])) { $data['state'] = $invite['state']; } if (isset($expectedInvite['localCloudId'])) { $data['localCloudId'] = $this->translateRemoteServer($invite['localCloudId']); } return $data; }, $invites, $expectedInvites)); } protected function translateRemoteServer(string $server): string { $server = str_replace([ 'http://localhost:8080', 'http://localhost:8280', ], [ 'LOCAL', 'REMOTE', ], $server); if (str_contains($server, 'http://')) { return 'unknown-server'; } return $server; } #[Then('/^user "([^"]*)" (is|is not) participant of room "([^"]*)" \((v4)\)$/')] public function userIsParticipantOfRoom(string $user, string $isOrNotParticipant, string $identifier, string $apiVersion, ?TableNode $formData = null): void { if (strpos($user, 'guest') === 0) { $this->guestIsParticipantOfRoom($user, $isOrNotParticipant, $identifier, $apiVersion, $formData); return; } $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room'); $this->assertStatusCode($this->response, 200); $isParticipant = $isOrNotParticipant === 'is'; $rooms = $this->getDataFromResponse($this->response); $rooms = array_filter($rooms, function ($room) { // Filter out "Talk updates" and "Note to self" conversations return $room['type'] !== 4 && $room['type'] !== 6 && $room['objectType'] !== 'sample'; }); if ($isParticipant) { Assert::assertNotEmpty($rooms); } foreach ($rooms as $room) { if (self::$tokenToIdentifier[$room['token']] === $identifier) { Assert::assertEquals($isParticipant, true, 'Room ' . $identifier . ' found in user´s room list'); if ($formData) { $this->assertRooms([$room], $formData); } return; } } Assert::assertEquals($isParticipant, false, 'Room ' . $identifier . ' not found in user´s room list'); } #[Then('/^user "([^"]*)" sees the following attendees( with status)? in room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSeesAttendeesInRoom(string $user, string $withStatus, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants?includeStatus=' . ($withStatus === ' with status' ? '1' : '0')); $this->assertStatusCode($this->response, $statusCode); if ($formData instanceof TableNode) { $attendees = $this->getDataFromResponse($this->response); } else { $attendees = []; } $this->assertAttendeeList($identifier, $formData, $attendees); } #[Then('/^user "([^"]*)" sees the following attendees in breakout rooms for room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSeesAttendeesInBreakoutRoomsForRoom(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/breakout-rooms/participants'); $this->assertStatusCode($this->response, $statusCode); if ($formData instanceof TableNode) { $attendees = $this->getDataFromResponse($this->response); } else { $attendees = []; } $this->assertAttendeeList($identifier, $formData, $attendees); } protected function assertAttendeeList(string $identifier, ?TableNode $formData, array $attendees): void { if ($formData instanceof TableNode) { $expectedKeys = array_flip($formData->getRows()[0]); $result = []; foreach ($attendees as $attendee) { $data = []; if (isset($expectedKeys['roomToken'])) { $data['roomToken'] = self::$tokenToIdentifier[$attendee['roomToken']]; } if (isset($expectedKeys['actorType'])) { $data['actorType'] = $attendee['actorType']; } if (isset($expectedKeys['actorId'])) { $data['actorId'] = $attendee['actorId']; } if (isset($expectedKeys['participantType'])) { $data['participantType'] = (string)$attendee['participantType']; } if (isset($expectedKeys['inCall'])) { $data['inCall'] = (string)$attendee['inCall']; } if (isset($expectedKeys['attendeePin'])) { $data['attendeePin'] = $attendee['attendeePin'] ? '**PIN**' : ''; } if (isset($expectedKeys['permissions'])) { $data['permissions'] = (string)$attendee['permissions']; } if (isset($expectedKeys['attendeePermissions'])) { $data['attendeePermissions'] = (string)$attendee['attendeePermissions']; } if (isset($expectedKeys['displayName'])) { $data['displayName'] = (string)$attendee['displayName']; } if (isset($expectedKeys['phoneNumber'])) { $data['phoneNumber'] = (string)$attendee['phoneNumber']; } if (isset($expectedKeys['callId'])) { $data['callId'] = (string)$attendee['callId']; } if (isset($expectedKeys['invitedActorId'], $attendee['invitedActorId'])) { $data['invitedActorId'] = (string)$attendee['invitedActorId']; } if (isset($expectedKeys['status'], $attendee['status'])) { $data['status'] = (string)$attendee['status']; } if (isset($expectedKeys['sessionIds'])) { $sessionIds = '['; foreach ($attendee['sessionIds'] as $sessionId) { if (str_contains($sessionId, '#')) { $sessionIds .= 'SESSION' . substr($sessionId, strpos($sessionId, '#')) . ','; } else { $sessionIds .= 'SESSION,'; } } $sessionIds .= ']'; $data['sessionIds'] = $sessionIds; } if (!isset(self::$userToAttendeeId[$identifier][$attendee['actorType']])) { self::$userToAttendeeId[$identifier][$attendee['actorType']] = []; } self::$userToAttendeeId[$identifier][$attendee['actorType']][$attendee['actorId']] = $attendee['attendeeId']; if (!empty($attendee['phoneNumber'])) { self::$phoneNumberToActorId[$attendee['phoneNumber']] = $attendee['actorId']; } $result[] = $data; } usort($result, [self::class, 'sortAttendees']); $expected = array_map(function ($attendee, $actual) { if (isset($attendee['actorId']) && substr($attendee['actorId'], 0, strlen('"guest')) === '"guest') { $attendee['actorId'] = sha1(self::$userToSessionId[trim($attendee['actorId'], '"')]); } if (isset($attendee['actorId']) && str_ends_with($attendee['actorId'], '@{$LOCAL_URL}')) { $attendee['actorId'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $attendee['actorId']); } if (isset($attendee['actorId']) && str_ends_with($attendee['actorId'], '@{$REMOTE_URL}')) { $attendee['actorId'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $attendee['actorId']); } if (isset($attendee['actorId']) && preg_match('/^SHA256\(([a-z0-9@.+\-]+)\)$/', $attendee['actorId'], $match)) { $attendee['actorId'] = hash('sha256', $match[1]); } if (isset($attendee['actorId'], $attendee['actorType']) && $attendee['actorType'] === 'federated_users' && !str_contains($attendee['actorId'], '@')) { $attendee['actorId'] .= '@' . rtrim($this->remoteServerUrl, '/'); } if (isset($attendee['actorId']) && preg_match('/TEAM_ID\(([^)]+)\)/', $attendee['actorId'], $matches)) { $attendee['actorId'] = self::getTeamIdForLabel($this->currentServer, $matches[1]); } if (isset($attendee['sessionIds']) && str_contains($attendee['sessionIds'], '@{$LOCAL_URL}')) { $attendee['sessionIds'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $attendee['sessionIds']); } if (isset($attendee['sessionIds']) && str_contains($attendee['sessionIds'], '@{$REMOTE_URL}')) { $attendee['sessionIds'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $attendee['sessionIds']); } if (isset($attendee['actorId'], $attendee['actorType'], $attendee['phoneNumber']) && $attendee['actorType'] === 'phones' && str_starts_with($attendee['actorId'], 'PHONE(')) { $matched = preg_match('/PHONE\((\+\d+)\)/', $attendee['actorId'], $matches); if ($matched) { $attendee['actorId'] = self::$phoneNumberToActorId[$matches[1]]; } } // Breakout room regex if (isset($attendee['actorId']) && strpos($attendee['actorId'], '/') === 0 && preg_match($attendee['actorId'], $actual['actorId'])) { $attendee['actorId'] = $actual['actorId']; } if (isset($attendee['participantType'])) { $attendee['participantType'] = (string)$this->mapParticipantTypeTestInput($attendee['participantType']); } if (isset($attendee['actorType']) && $attendee['actorType'] === 'phones') { $attendee['participantType'] = (string)$this->mapParticipantTypeTestInput($attendee['participantType']); } if (isset($attendee['invitedActorId']) && $attendee['invitedActorId'] === 'ABSENT') { unset($attendee['invitedActorId']); } if (isset($attendee['status']) && $attendee['status'] === 'ABSENT') { unset($attendee['status']); } return $attendee; }, $formData->getHash(), $result); $expected = array_filter($expected); $result = array_map(function ($attendee) { if (isset($attendee['permissions'])) { $attendee['permissions'] = $this->mapPermissionsAPIOutput($attendee['permissions']); } if (isset($attendee['attendeePermissions'])) { $attendee['attendeePermissions'] = $this->mapPermissionsAPIOutput($attendee['attendeePermissions']); } return $attendee; }, $result); usort($expected, [self::class, 'sortAttendees']); Assert::assertEquals($expected, $result, print_r([ 'original' => $formData->getHash(), 'expected' => $expected, 'actual' => $attendees, 'result' => $result, ], true)); } else { Assert::assertNull($formData); } } #[Then('/^user "([^"]*)" loads attendees attendee ids in room "([^"]*)" \((v4)\)$/')] public function userLoadsAttendeeIdsInRoom(string $user, string $identifier, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants'); $this->assertStatusCode($this->response, 200); $attendees = $this->getDataFromResponse($this->response); foreach ($attendees as $attendee) { if (!isset(self::$userToAttendeeId[$identifier][$attendee['actorType']])) { self::$userToAttendeeId[$identifier][$attendee['actorType']] = []; } self::$userToAttendeeId[$identifier][$attendee['actorType']][$attendee['actorId']] = $attendee['attendeeId']; } } protected static function sortAttendees(array $a1, array $a2): int { if (array_key_exists('roomToken', $a1) && array_key_exists('roomToken', $a2) && $a1['roomToken'] !== $a2['roomToken']) { return $a1['roomToken'] <=> $a2['roomToken']; } if (array_key_exists('participantType', $a1) && array_key_exists('participantType', $a2) && $a1['participantType'] !== $a2['participantType']) { return $a1['participantType'] <=> $a2['participantType']; } if ($a1['actorType'] !== $a2['actorType']) { return $a1['actorType'] <=> $a2['actorType']; } return $a1['actorId'] <=> $a2['actorId']; } private function mapParticipantTypeTestInput(string|int $participantType): int { if (is_numeric($participantType)) { return (int)$participantType; } switch ($participantType) { case 'OWNER': return 1; case 'MODERATOR': return 2; case 'USER': return 3; case 'GUEST': return 4; case 'USER_SELF_JOINED': return 5; case 'GUEST_MODERATOR': return 6; } Assert::fail('Invalid test input value for participant type'); } private function mapPermissionsTestInput(string|int $permissions): int { if (is_numeric($permissions)) { return (int)$permissions; } $numericPermissions = 0; foreach (self::$permissionsMap as $char => $int) { if (strpos($permissions, $char) !== false) { $numericPermissions += $int; $permissions = str_replace($char, '', $permissions); } } if (trim($permissions) !== '') { Assert::fail('Invalid test input value for permissions'); } return $numericPermissions; } private function mapPermissionsAPIOutput(string|int $permissions): string { $permissions = (int)$permissions; $permissionsString = !$permissions ? 'D' : ''; foreach (self::$permissionsMap as $char => $int) { if ($permissions & $int) { $permissionsString .= $char; $permissions &= ~ $int; } } if ($permissions !== 0) { Assert::fail('Invalid API output value for permissions'); } return $permissionsString; } private function guestIsParticipantOfRoom(string $guest, string $isOrNotParticipant, string $identifier, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($guest); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier]); $response = $this->getDataFromResponse($this->response); $isParticipant = $isOrNotParticipant === 'is'; if ($formData) { $rooms = [$response]; $this->assertRooms($rooms, $formData); } if ($isParticipant) { $this->assertStatusCode($this->response, 200); Assert::assertEquals(self::$userToSessionId[$guest], $response['sessionId']); return; } if ($this->response->getStatusCode() === 200) { // Public rooms can always be got, but if the guest is not a // participant the sessionId will be 0. Assert::assertEquals(0, $response['sessionId']); return; } $this->assertStatusCode($this->response, 404); } #[Then('/^user "([^"]*)" creates room "([^"]*)" \((v4)\)$/')] public function userCreatesRoom(string $user, string $identifier, string $apiVersion, ?TableNode $formData = null): void { $this->userCreatesRoomWith($user, $identifier, 201, $apiVersion, $formData); } #[Then('/^user "([^"]*)" creates ([0-9]+) rooms \((v4)\)$/')] public function userCreatesManyRoom(string $user, int $amount, string $apiVersion, ?TableNode $formData = null): void { for ($i = 1; $i <= $amount; $i++) { $identifier = 'room' . $i; $this->userCreatesRoomWith($user, $identifier, 201, $apiVersion, $formData); } } #[Then('/^user "([^"]*)" creates note-to-self \((v4)\)$/')] public function userCreatesNoteToSelf(string $user, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/note-to-self'); $this->assertStatusCode($this->response, 200); $response = $this->getDataFromResponse($this->response); self::$identifierToToken[$user . '-note-to-self'] = $response['token']; self::$identifierToId[$user . '-note-to-self'] = $response['id']; self::$tokenToIdentifier[$response['token']] = $user . '-note-to-self'; } #[Then('/^user "([^"]*)" reset note-to-self preference$/')] public function userResetNoteToSelfPreference(string $user): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/provisioning_api/api/v1/config/users/spreed/note_to_self'); $this->assertStatusCode($this->response, 200); } #[Then('/^user "([^"]*)" creates room "([^"]*)" with (\d+) \((v4)\)$/')] public function userCreatesRoomWith(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $body = $formData->getRowsHash(); if (isset($body['roomName']) && $body['roomName'] === 'IDENTIFIER') { $body['roomName'] = $identifier; } if (isset($body['objectType'], $body['objectId']) && in_array($body['objectType'], ['room', 'extended_conversation'], true)) { $result = preg_match('/ROOM\(([^)]+)\)/', $body['objectId'], $matches); if ($result && isset(self::$identifierToToken[$matches[1]])) { $body['objectId'] = self::$identifierToToken[$matches[1]]; } elseif ($result) { throw new \InvalidArgumentException('Could not find parent room'); } } if (isset($body['objectType']) && $body['objectType'] === 'event') { [$start, $end] = explode('#', $body['objectId']); $body['objectId'] = (time() + (int)$start) . '#' . (time() + (int)$end); } if (isset($body['permissions'])) { $body['permissions'] = $this->mapPermissionsTestInput($body['permissions']); } if (isset($body['lobbyTimer'])) { if (preg_match('/^OFFSET\((\d+)\)$/', $body['lobbyTimer'], $matches)) { $body['lobbyTimer'] = $matches[1] + time(); } } $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $body); $this->assertStatusCode($this->response, $statusCode); $response = $this->getDataFromResponse($this->response); if ($statusCode === 201) { self::$identifierToToken[$identifier] = $response['token']; self::$identifierToId[$identifier] = $response['id']; self::$tokenToIdentifier[$response['token']] = $identifier; } } #[Then('/^user "([^"]*)" tries to create room with (\d+) \((v4)\)$/')] public function userTriesToCreateRoom(string $user, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $formData); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" gets the room for path "([^"]*)" with (\d+) \((v1)\)$/')] public function userGetsTheRoomForPath(string $user, string $path, int $statusCode, string $apiVersion): void { $fileId = $this->getFileIdForPath($user, $path); $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/file/' . $fileId); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $response = $this->getDataFromResponse($this->response); $identifier = 'file ' . $path . ' room'; self::$identifierToToken[$identifier] = $response['token']; self::$tokenToIdentifier[$response['token']] = $identifier; } #[Then('/^user "([^"]*)" propfinds path "([^"]*)"$/')] public function getFileIdForPath(string $user, string $path): int { $this->setCurrentUser($user); $url = "/$user/$path"; $headers = []; $headers['Depth'] = 0; $body = '' . ' ' . ' ' . ' ' . ''; $this->sendingToDav('PROPFIND', $url, $headers, $body); $this->assertStatusCode($this->response, 207); $xmlResponse = simplexml_load_string($this->response->getBody()->getContents()); $xmlResponse->registerXPathNamespace('oc', 'http://owncloud.org/ns'); return (int)$xmlResponse->xpath('//oc:fileid')[0]; } private function sendingToDav(string $verb, string $url, ?array $headers = null, ?string $body = null): void { $fullUrl = $this->baseUrl . 'remote.php/dav/files' . $url; $client = new Client(); $options = []; if ($this->currentUser === 'admin') { $options['auth'] = 'admin'; } elseif ($this->currentUser !== null) { $options['auth'] = [$this->currentUser, self::TEST_PASSWORD]; } $options['headers'] = [ 'OCS_APIREQUEST' => 'true' ]; if ($headers !== null) { $options['headers'] = array_merge($options['headers'], $headers); } if ($body !== null) { $options['body'] = $body; } try { $this->response = $client->{$verb}($fullUrl, $options); } catch (GuzzleHttp\Exception\ClientException $ex) { $this->response = $ex->getResponse(); } } #[Then('/^user "([^"]*)" gets the room for last share with (\d+) \((v1)\)$/')] public function userGetsTheRoomForLastShare(string $user, int $statusCode, string $apiVersion): void { $shareToken = $this->sharingContext->getLastShareToken(); $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/publicshare/' . $shareToken); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $response = $this->getDataFromResponse($this->response); $identifier = 'file last share room'; self::$identifierToToken[$identifier] = $response['token']; self::$tokenToIdentifier[$response['token']] = $identifier; } #[Then('/^user "([^"]*)" creates the password request room for last share with (\d+) \((v1)\)$/')] public function userCreatesThePasswordRequestRoomForLastShare(string $user, int $statusCode, string $apiVersion): void { $shareToken = $this->sharingContext->getLastShareToken(); $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/publicshareauth', ['shareToken' => $shareToken]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 201) { return; } $response = $this->getDataFromResponse($this->response); $identifier = 'password request for last share room'; self::$identifierToToken[$identifier] = $response['token']; self::$tokenToIdentifier[$response['token']] = $identifier; } #[Then('/^user "([^"]*)" joins room "([^"]*)" with (\d+) \((v4)\)$/')] public function userJoinsRoom(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { $this->userJoinsRoomWithNamedSession($user, $identifier, $statusCode, $apiVersion, '', $formData); } #[Then('/^user "([^"]*)" joins room "([^"]*)" with (\d+) \((v4)\) session name "([^"]*)"$/')] public function userJoinsRoomWithNamedSession(string $user, string $identifier, int $statusCode, string $apiVersion, string $sessionName, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants/active', $formData ); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $response = $this->getDataFromResponse($this->response); if (array_key_exists('sessionId', $response)) { // In the chat guest users are identified by their sessionId. The // sessionId is larger than the size of the actorId column in the // database, though, so the ID stored in the database and returned // in chat messages is a hashed version instead. self::$sessionIdToUser[sha1($response['sessionId'])] = $user; self::$userToSessionId[$user] = $response['sessionId']; if ($sessionName) { self::$userToSessionId[$user . '#' . $sessionName] = $response['sessionId']; self::$sessionNameToActorId[$sessionName] = $response['actorId']; } if (!isset(self::$userToAttendeeId[$identifier][$response['actorType']])) { self::$userToAttendeeId[$identifier][$response['actorType']] = []; } self::$userToAttendeeId[$identifier][$response['actorType']][$response['actorId']] = $response['attendeeId']; } } #[Then('/^user "([^"]*)" resends invite for room "([^"]*)" with (\d+) \((v4)\)$/')] public function userResendsInvite(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); /** @var ?array $body */ $body = null; if ($formData instanceof TableNode) { $attendee = $formData?->getRowsHash()['attendeeId'] ?? ''; $actorId = hash('sha256', $attendee); if (isset(self::$userToAttendeeId[$identifier]['emails'][$actorId])) { $body = [ 'attendeeId' => self::$userToAttendeeId[$identifier]['emails'][$actorId], ]; } elseif (str_starts_with($attendee, 'not-found')) { $body = [ 'attendeeId' => max(self::$userToAttendeeId[$identifier]['emails']) + 1000, ]; } else { throw new \InvalidArgumentException('Unknown attendee, did you pull participants?'); } } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants/resend-invitations', $body ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sets session state to (\d) in room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSessionState(string $user, int $state, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants/state', ['state' => $state] ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" views call-URL of room "([^"]*)" with (\d+)$/')] public function userViewsCallURL(string $user, string $identifier, int $statusCode, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendFrontpageRequest( 'GET', '/call/' . (self::$identifierToToken[$identifier] ?? $identifier) ); $this->assertStatusCode($this->response, $statusCode); if ($formData instanceof TableNode) { $content = $this->response->getBody()->getContents(); foreach ($formData->getRows() as $line) { Assert::assertStringContainsString($line[0], $content); } } } #[Then('/^user "([^"]*)" views URL "([^"]*)" with query parameters and status code (\d+)$/')] public function userViewsURLWithQuery(string $user, string $page, int $statusCode, ?TableNode $formData = null): void { $parameters = []; if ($formData instanceof TableNode) { foreach ($formData->getRowsHash() as $key => $value) { $parameters[$key] = $key === 'token' ? (self::$identifierToToken[$value] ?? $value) : $value; } } $this->setCurrentUser($user); $this->sendFrontpageRequest( 'GET', '/' . $page . '?' . http_build_query($parameters) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sets notifications to (default|disabled|mention|all) for room "([^"]*)" \((v4)\)$/')] public function userSetsNotificationLevelForRoom(string $user, string $level, string $identifier, string $apiVersion): void { $this->setCurrentUser($user); $intLevel = 0; // default if ($level === 'disabled') { $intLevel = 3; } elseif ($level === 'mention') { $intLevel = 2; } elseif ($level === 'all') { $intLevel = 1; } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/notify', new TableNode([ ['level', $intLevel], ]) ); $this->assertStatusCode($this->response, 200); } #[Then('/^user "([^"]*)" sets call notifications to (enabled|disabled) for room "([^"]*)" \((v4)\)$/')] public function userSetsCallNotificationLevelForRoom(string $user, string $level, string $identifier, string $apiVersion): void { $this->setCurrentUser($user); $intLevel = 1; // default if ($level === 'disabled') { $intLevel = 0; } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/notify-calls', new TableNode([ ['level', $intLevel], ]) ); $this->assertStatusCode($this->response, 200); } #[Then('/^user "([^"]*)" leaves room "([^"]*)" with (\d+) \((v4)\)$/')] public function userExitsRoom(string $user, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants/active'); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" removes themselves from room "([^"]*)" with (\d+) \((v4)\)$/')] public function userLeavesRoom(string $user, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants/self'); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" removes "([^"]*)" from room "([^"]*)" with (\d+) \((v4)\)$/')] public function userRemovesUserFromRoom(string $user, string $toRemove, string $identifier, int $statusCode, string $apiVersion): void { if ($toRemove === 'stranger') { $attendeeId = 123456789; } else { $attendeeId = $this->getAttendeeId('users', $toRemove, $identifier, $statusCode === 200 ? $user : null); } $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/attendees', new TableNode([['attendeeId', $attendeeId]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" removes (user|group|email|remote) "([^"]*)" from room "([^"]*)" with (\d+) \((v4)\)$/')] public function userRemovesAttendeeFromRoom(string $user, string $actorType, string $actorId, string $identifier, int $statusCode, string $apiVersion): void { if ($actorId === 'stranger') { $attendeeId = 123456789; } else { if ($actorType === 'remote') { $actorId .= '@' . rtrim($this->remoteServerUrl, '/'); $actorType = 'federated_user'; } $attendeeId = $this->getAttendeeId($actorType . 's', $actorId, $identifier, $statusCode === 200 ? $user : null); } $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/attendees', new TableNode([['attendeeId', $attendeeId]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" bans ([^ ]*) "([^"]*)" from room "([^"]*)" with (\d+) \((v1)\)$/')] public function userBansUserFromRoom(string $user, string $actorType, string $actorId, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $internalNote = null): void { if ($actorType === 'guest') { $actorId = self::$sessionNameToActorId[$actorId]; } elseif ($actorId === 'stranger') { $actorId = '123456789'; } else { if ($actorType === 'remote') { $actorId .= '@' . rtrim($this->remoteServerUrl, '/'); $actorType = 'federated_user'; } } if ($actorType !== 'ip') { $actorType .= 's'; } $this->setCurrentUser($user); $body = [ 'actorType' => $actorType, 'actorId' => $actorId, ]; // Add the internal note if it exists if ($internalNote !== null) { $internalNoteData = $internalNote->getRowsHash(); if (isset($internalNoteData['internalNote'])) { $body['internalNote'] = $internalNoteData['internalNote']; } } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/ban/' . self::$identifierToToken[$identifier], $body ); $data = $this->getDataFromResponse($this->response); $this->assertStatusCode($this->response, $statusCode, print_r($data, true)); if ($statusCode === 200) { self::$userToBanId[self::$identifierToToken[$identifier]] ??= []; self::$userToBanId[self::$identifierToToken[$identifier]][$actorType] ??= []; self::$userToBanId[self::$identifierToToken[$identifier]][$actorType][$actorId] = $data['id']; } elseif ($internalNote !== null) { $internalNoteData = $internalNote->getRowsHash(); if (isset($internalNoteData['error'])) { Assert::assertSame($internalNoteData['error'], $data['error']); } } } #[Then('/^user "([^"]*)" sees the following bans in room "([^"]*)" with (\d+) \((v1)\)$/')] public function userLoadsBanIdsInRoom(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $tableNode): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/ban/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } if ($tableNode instanceof TableNode) { $expected = array_map(static function (array $ban): array { if (preg_match('/^SESSION\(([a-z0-9]+)\)$/', $ban['bannedActorId'], $match)) { $ban['bannedActorId'] = self::$sessionNameToActorId[$match[1]]; } if (preg_match('/^SESSION\(([a-z0-9]+)\)$/', $ban['bannedDisplayName'], $match)) { $ban['bannedDisplayName'] = self::$sessionNameToActorId[$match[1]]; } return $ban; }, $tableNode->getColumnsHash()); } else { $expected = []; } $bans = array_map(static function (array $ban, array $expectedBan): array { if ($expectedBan['bannedActorId'] === 'LOCAL_IP') { if ($ban['bannedActorId'] === '127.0.0.1' || $ban['bannedActorId'] === '::1') { $ban['bannedActorId'] = 'LOCAL_IP'; } } if ($expectedBan['bannedDisplayName'] === 'LOCAL_IP') { if ($ban['bannedDisplayName'] === '127.0.0.1' || $ban['bannedDisplayName'] === '::1') { $ban['bannedDisplayName'] = 'LOCAL_IP'; } } unset($ban['id'], $ban['roomId'], $ban['bannedTime']); return $ban; }, $this->getDataFromResponse($this->response), $expected); Assert::assertEquals($expected, $bans); } #[When('/^user "([^"]*)" unbans (user|group|email|remote) "([^"]*)" from room "([^"]*)" with (\d+) \((v1)\)$/')] public function userUnbansUserFromRoom(string $user, string $actorType, string $actorId, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { if ($actorId === 'stranger') { $actorId = '123456789'; } else { if ($actorType === 'remote') { $actorId .= '@' . rtrim($this->remoteServerUrl, '/'); $actorType = 'federated_user'; } } $actorType .= 's'; $banId = self::$userToBanId[self::$identifierToToken[$identifier]][$actorType][$actorId]; unset(self::$userToBanId[self::$identifierToToken[$identifier]][$actorType][$actorId]); $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/ban/' . self::$identifierToToken[$identifier] . '/' . $banId ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" deletes room "([^"]*)" with (\d+) \((v4)\)$/')] public function userDeletesRoom(string $user, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" gets room "([^"]*)" with (\d+) \((v4)\)$/')] public function userGetsRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v4', ?TableNode $formData = null): void { $this->setCurrentUser($user, $identifier); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $statusCode); if ($formData instanceof TableNode) { $xpectedAttributes = $formData->getRowsHash(); $actual = $this->getDataFromResponse($this->response); foreach ($xpectedAttributes as $attribute => $expectedValue) { if ($expectedValue === 'NOT_EMPTY') { Assert::assertNotEmpty($actual[$attribute]); continue; } Assert::assertEquals($expectedValue, $actual[$attribute]); } } } #[Then('/^user "([^"]*)" renames room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userRenamesRoom(string $user, string $identifier, string $newName, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier], new TableNode([['roomName', $newName]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets description for room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsDescriptionForRoomTo(string $user, string $identifier, string $description, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/description', new TableNode([['description', $description]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets password "([^"]*)" for room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsTheRoomPassword(string $user, string $password, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/password', new TableNode([['password', $password]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets lobby state for room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsLobbyStateForRoomTo(string $user, string $identifier, string $lobbyStateString, int $statusCode, string $apiVersion): void { if ($lobbyStateString === 'no lobby') { $lobbyState = 0; } elseif ($lobbyStateString === 'non moderators') { $lobbyState = 1; } else { Assert::fail('Invalid lobby state'); } $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/webinar/lobby', new TableNode([['state', $lobbyState]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets lobby state for room "([^"]*)" to "([^"]*)" for (\d+) seconds with (\d+) \((v4)\)$/')] public function userSetsLobbyStateAndTimerForRoom(string $user, string $identifier, string $lobbyStateString, int $lobbyTimer, int $statusCode, string $apiVersion): void { if ($lobbyStateString === 'no lobby') { $lobbyState = 0; } elseif ($lobbyStateString === 'non moderators') { $lobbyState = 1; } else { Assert::fail('Invalid lobby state'); } $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/webinar/lobby', new TableNode([['state', $lobbyState], ['timer', time() + $lobbyTimer]]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets SIP state for room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsSIPStateForRoomTo(string $user, string $identifier, string $SIPStateString, int $statusCode, string $apiVersion): void { if ($SIPStateString === 'disabled') { $SIPState = 0; } elseif ($SIPStateString === 'enabled') { $SIPState = 1; } elseif ($SIPStateString === 'no pin') { $SIPState = 2; } else { Assert::fail('Invalid SIP state'); } $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/webinar/sip', new TableNode([['state', $SIPState]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" makes room "([^"]*)" (public|private) with (\d+) \((v4)\)$/')] public function userChangesTypeOfTheRoom(string $user, string $identifier, string $newType, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( $newType === 'public' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/public' ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" (locks|unlocks) room "([^"]*)" with (\d+) \((v4)\)$/')] public function userChangesReadOnlyStateOfTheRoom(string $user, string $newState, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/read-only', new TableNode([['state', $newState === 'unlocks' ? 0 : 1]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" allows listing room "([^"]*)" for "(none|users|all|\d+)" with (\d+) \((v4)\)$/')] public function userChangesListableScopeOfTheRoom(string $user, string $identifier, string|int $newState, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); if ($newState === 'none') { $newStateValue = 0; // Room::LISTABLE_NONE } elseif ($newState === 'users') { $newStateValue = 1; // Room::LISTABLE_USERS } elseif ($newState === 'all') { $newStateValue = 2; // Room::LISTABLE_ALL } elseif (is_numeric($newState)) { $newStateValue = (int)$newState; } else { Assert::fail('Invalid listable scope value'); } $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/listable', new TableNode([['scope', $newStateValue]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" adds (user|group|email|federated_user|phone|team) "([^"]*)" to room "([^"]*)" with (\d+) \((v4)\)$/')] public function userAddAttendeeToRoom(string $user, string $newType, string $newId, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); if ($newType === 'federated_user') { if (!str_contains($newId, '@')) { $newId .= '@' . $this->remoteServerUrl; } else { $newId = str_replace('REMOTE', $this->remoteServerUrl, $newId); } } if ($newType === 'team') { $newId = self::getTeamIdForLabel($this->currentServer, $newId); } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/participants', new TableNode([ ['source', $newType . 's'], ['newParticipant', $newId], ]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" (promotes|demotes) "([^"]*)" in room "([^"]*)" with (\d+) \((v4)\)$/')] public function userPromoteDemoteInRoom(string $user, string $isPromotion, string $participant, string $identifier, int $statusCode, string $apiVersion): void { if ($participant === 'stranger') { $attendeeId = 123456789; } elseif (strpos($participant, 'guest') === 0) { $sessionId = self::$userToSessionId[$participant]; $attendeeId = $this->getAttendeeId('guests', sha1($sessionId), $identifier, $statusCode === 200 ? $user : null); } else { $attendeeId = $this->getAttendeeId('users', $participant, $identifier, $statusCode === 200 ? $user : null); } $requestParameters = [['attendeeId', $attendeeId]]; $this->setCurrentUser($user); $this->sendRequest( $isPromotion === 'promotes' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/moderators', new TableNode($requestParameters) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets permissions for "([^"]*)" in room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsPermissionsForInRoomTo(string $user, string $participant, string $identifier, string $permissionsString, int $statusCode, string $apiVersion): void { if ($participant === 'stranger') { $attendeeId = 123456789; } elseif (strpos($participant, 'guest') === 0) { $sessionId = self::$userToSessionId[$participant]; $attendeeId = $this->getAttendeeId('guests', sha1($sessionId), $identifier, $statusCode === 200 ? $user : null); } elseif (str_ends_with($participant, '@{$REMOTE_URL}')) { $participant = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $participant); $attendeeId = $this->getAttendeeId('federated_users', $participant, $identifier, $statusCode === 200 ? $user : null); } else { $attendeeId = $this->getAttendeeId('users', $participant, $identifier, $statusCode === 200 ? $user : null); } $permissions = $this->mapPermissionsTestInput($permissionsString); $requestParameters = [ ['attendeeId', $attendeeId], ['permissions', $permissions], ['method', 'set'], ]; $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/attendees/permissions', new TableNode($requestParameters) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets (call|default) permissions for room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsPermissionsForRoomTo(string $user, string $mode, string $identifier, string $permissionsString, int $statusCode, string $apiVersion): void { $permissions = $this->mapPermissionsTestInput($permissionsString); $requestParameters = [ ['permissions', $permissions], ]; $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/permissions/' . $mode, new TableNode($requestParameters) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" joins call "([^"]*)" with (\d+) \((v4)\)$/')] public function userJoinsCall(string $user, string $identifier, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier], $formData ); $this->assertStatusCode($this->response, $statusCode); $response = $this->getDataFromResponse($this->response); if (is_array($response) && array_key_exists('sessionId', $response)) { // In the chat guest users are identified by their sessionId. The // sessionId is larger than the size of the actorId column in the // database, though, so the ID stored in the database and returned // in chat messages is a hashed version instead. self::$sessionIdToUser[sha1($response['sessionId'])] = $user; self::$userToSessionId[$user] = $response['sessionId']; } } #[Then('/^user "([^"]*)" checks call notification for "([^"]*)" with (\d+) \((v4)\)$/')] public function userChecksCallNotification(string $user, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier] . '/notification-state'); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" updates call flags in room "([^"]*)" to "([^"]*)" with (\d+) \((v4)\)$/')] public function userUpdatesCallFlagsInRoomTo(string $user, string $identifier, string $flags, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier], new TableNode([['flags', $flags]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" pings (federated_user|user|guest) "([^"]*)"( attendeeIdPlusOne)? to join call "([^"]*)" with (\d+) \((v4)\)$/')] public function userPingsAttendeeInRoomTo(string $user, string $actorType, string $actorId, ?string $offset, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $attendeeId = $this->getAttendeeId($actorType . 's', $actorId, $identifier, $user); if ($offset) { $attendeeId++; } $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier] . '/ring/' . $attendeeId); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" dials out to "([^"]*)" from call in room "([^"]*)" with (\d+) \((v4)\)$/')] public function userDialsOut(string $user, string $phoneNumber, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier] . '/dialout/' . self::$userToAttendeeId[$identifier]['phones'][self::$phoneNumberToActorId[$phoneNumber]] ); $this->assertStatusCode($this->response, $statusCode); $response = $this->getDataFromResponse($this->response); if (is_array($response) && array_key_exists('sessionId', $response)) { // In the chat guest users are identified by their sessionId. The // sessionId is larger than the size of the actorId column in the // database, though, so the ID stored in the database and returned // in chat messages is a hashed version instead. self::$sessionIdToUser[sha1($response['sessionId'])] = $user; self::$userToSessionId[$user] = $response['sessionId']; } } #[Then('/^user "([^"]*)" leaves call "([^"]*)" with (\d+) \((v4)\)$/')] public function userLeavesCall(string $user, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" ends call "([^"]*)" with (\d+) \((v4)\)$/')] public function userEndsCall(string $user, string $identifier, int $statusCode, string $apiVersion): void { $requestParameters = [ ['all', true], ]; $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier], new TableNode($requestParameters) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sees (\d+) peers in call "([^"]*)" with (\d+) \((v4)\)$/')] public function userSeesPeersInCall(string $user, int $numPeers, string $identifier, int $statusCode, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode === 200) { $response = $this->getDataFromResponse($this->response); Assert::assertCount((int)$numPeers, $response); } else { Assert::assertEquals((int)$numPeers, 0); } } #[Then('/^user "([^"]*)" downloads call participants from "([^"]*)" as "(csv)" with (\d+) \((v4)\)$/')] public function userDownloadsPeersInCall(string $user, string $identifier, string $format, int $statusCode, string $apiVersion, ?TableNode $tableNode = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/call/' . self::$identifierToToken[$identifier] . '/download', [ 'format' => $format, ]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $expected = []; foreach ($tableNode->getRows() as $row) { if ($row[2] === 'guests') { $row[3] = self::$sessionNameToActorId[$row[3]]; } $expected[] = implode(',', $row); } Assert::assertEquals(implode("\n", $expected) . "\n", $this->response->getBody()->getContents()); } #[When('/^user "([^"]*)" schedules a message to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSchedulesMessageToRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $row = $formData->getRowsHash(); $row['sendAt'] = (int)$row['sendAt']; if (isset($row['replyTo'])) { $row['replyTo'] = self::$textToMessageId[$row['replyTo']]; } if (isset($row['threadId']) && $row['threadId'] !== '0' && $row['threadId'] !== '-1') { $row['threadId'] = self::$titleToThreadId[$row['threadId']]; } $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule', $row ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId[$row['message']] = $response['id']; self::$messageIdToText[$response['id']] = $row['message']; } } #[When('/^user "([^"]*)" updates scheduled message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userUpdatesScheduledMessageInRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $row = $formData->getRowsHash(); $id = self::$textToMessageId[$message]; $row['sendAt'] = (int)$row['sendAt']; $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule/' . $id, $row ); $this->assertStatusCode($this->response, $statusCode); if ($this->response->getStatusCode() !== 202) { return; } sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); self::$textToMessageId[$row['message']] = $response['id']; self::$messageIdToText[$response['id']] = $row['message']; Assert::assertEquals($row['message'], $response['message']); Assert::assertEquals($row['sendAt'], $response['sendAt']); Assert::assertArrayHasKey('metaData', $response); $metaData = $response['metaData']; Assert::assertArrayHasKey('silent', $metaData); Assert::assertArrayHasKey('threadTitle', $metaData); Assert::assertArrayHasKey('threadId', $metaData); Assert::assertArrayHasKey('lastEditedTime', $metaData); if (isset($row['silent'])) { Assert::assertEquals($metaData['silent'], (bool)$row['silent']); } if (isset($row['threadTitle'])) { Assert::assertEquals($metaData['threadTitle'], (bool)$row['threadTitle']); } } #[When('/^user "([^"]*)" deletes scheduled message "([^"]*)" from room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userDeletesScheduledMessageFromRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule/' . self::$textToMessageId[$message], ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sees the following scheduled messages in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingScheduledMessagesInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest( 'GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/schedule', ); $this->assertStatusCode($this->response, $statusCode); if ($statusCode === 304) { return; } $data = $this->getDataFromResponse($this->response); foreach ($data as &$message) { Assert::assertArrayHasKey('createdAt', $message); Assert::assertIsInt($message['createdAt']); unset($message['createdAt']); $metaData = $message['metaData']; if (isset($metaData['lastEditedTime'])) { $metaData['lastEditedTime'] = 0; } if (isset($message['parent'])) { $parent = $message['parent']; Assert::assertArrayHasKey('message', $parent); Assert::assertArrayHasKey('actorId', $parent); $message['parent'] = self::$messageIdToText[$parent['id']]; } $message['metaData'] = $metaData; } $expected = $formData->getColumnsHash(); foreach ($expected as &$row) { $row['id'] = self::$textToMessageId[$row['message']]; $row['sendAt'] = (int)$row['sendAt']; $row['metaData'] = json_decode($row['metaData'], true); $row['roomId'] = self::$identifierToId[$row['roomId']]; $row['parentId'] = ($row['parentId'] === 'null' ? null : self::$textToMessageId[$row['parentId']]); if (isset($row['parent'])) { $parent = []; } if ($row['threadId'] === '-1') { $row['threadId'] = -1; $row['threadExists'] = false; $row['threadTitle'] = $row['metaData']['threadTitle']; } elseif ($row['threadId'] !== '0') { $row['threadId'] = self::$titleToThreadId[$row['threadId']]; $row['threadTitle'] = self::$threadIdToTitle[$row['threadId']]; $row['threadExists'] = true; $row['metaData']['threadId'] = $row['threadId']; } else { $row['threadId'] = (int)$row['threadId']; } } Assert::assertEquals($expected, $data); } #[Then('/^user "([^"]*)" (silent sends|sends) message ("[^"]*"|\'[^\']*\') to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSendsMessageToRoom(string $user, string $sendingMode, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->userPostThreadToRoom($user, $sendingMode, '', $message, $identifier, $statusCode, $apiVersion); } #[Then('/^user "([^"]*)" (silent sends|sends) thread "([^"]*)" with message ("[^"]*"|\'[^\']*\') to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userPostThreadToRoom(string $user, string $sendingMode, string $title, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $message = substr($message, 1, -1); $message = str_replace('\n', "\n", $message); $message = str_replace('{$LOCAL_URL}', $this->localServerUrl, $message); $message = str_replace('{$REMOTE_URL}', $this->remoteServerUrl, $message); if (str_contains($message, '@"TEAM_ID(')) { $result = preg_match('/TEAM_ID\(([^)]+)\)/', $message, $matches); if ($result) { $message = str_replace($matches[0], 'team/' . self::getTeamIdForLabel($this->currentServer, $matches[1]), $message); } } if ($message === '413 Payload Too Large') { $message .= "\n" . str_repeat('1', 32000); } $data = [['message', $message]]; if ($sendingMode === 'silent sends') { $data[] = ['silent', true]; } if ($title !== '') { $data[] = ['threadTitle', $title]; } $body = new TableNode($data); $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier], $body ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId[$message] = $response['id']; self::$messageIdToText[$response['id']] = $message; if ($title !== '') { self::$titleToThreadId[$title] = $response['id']; self::$threadIdToTitle[$response['id']] = $title; } } } #[Then('/^user "([^"]*)" renames thread "([^"]*)" to "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userRenamesThreadInRoom(string $user, string $oldTitle, string $newTitle, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $threadId = self::$titleToThreadId[$oldTitle]; $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/threads/' . $threadId, new TableNode([['threadTitle', $newTitle]]) ); $this->assertStatusCode($this->response, $statusCode); self::$titleToThreadId[$newTitle] = $threadId; self::$threadIdToTitle[$threadId] = $newTitle; } #[Then('/^user "([^"]*)" edits message ("[^"]*"|\'[^\']*\') in room "([^"]*)" to ("[^"]*"|\'[^\']*\') with (\d+)(?: \((v1)\))?$/')] public function userEditsMessageToRoom(string $user, string $oldMessage, string $identifier, string $newMessage, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $oldMessage = substr($oldMessage, 1, -1); $oldMessage = str_replace('\n', "\n", $oldMessage); $messageId = self::$textToMessageId[$oldMessage]; $newMessage = substr($newMessage, 1, -1); $newMessage = str_replace('\n', "\n", $newMessage); $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . $messageId, new TableNode([['message', $newMessage]]) ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages if ($statusCode === 200 || $statusCode === 202) { self::$textToMessageId[$newMessage] = $messageId; self::$messageIdToText[$messageId] = $newMessage; } elseif ($formData instanceof TableNode) { Assert::assertEquals( $formData->getRowsHash(), $this->getDataFromResponse($this->response), ); } } #[Then('/^user "([^"]*)" sets reminder for message ("[^"]*"|\'[^\']*\') in room "([^"]*)" for time (\d+) with (\d+)(?: \((v1)\))?$/')] public function userSetsReminder(string $user, string $message, string $identifier, int $timestamp, int $statusCode, string $apiVersion = 'v1'): void { $message = substr($message, 1, -1); $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$textToMessageId[$message] . '/reminder', new TableNode([['timestamp', $timestamp]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" deletes reminder for message ("[^"]*"|\'[^\']*\') in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userDeletesReminder(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $message = substr($message, 1, -1); $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$textToMessageId[$message] . '/reminder' ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" (unpins|pins|hides pinned) message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userPinActionMessage(string $user, string $action, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->userPinActionWithTimeMessage($user, $action, $message, 0, $identifier, $statusCode, $apiVersion); } #[Then('/^user "([^"]*)" (unpins|pins|hides pinned) message "([^"]*)" for (\d+) seconds in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userPinActionWithTimeMessage(string $user, string $action, string $message, int $duration, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $body = []; if ($action === 'pins' && $duration !== 0) { $body['pinUntil'] = time() + $duration; } $routeSuffix = $action === 'hides pinned' ? '/self' : ''; $this->sendRequest( $action === 'pins' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$textToMessageId[$message] . '/pin' . $routeSuffix, $body ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" gets upcoming reminders \((v1)\)$/')] public function userGetsUpcomingReminders(string $user, string $apiVersion, ?TableNode $table = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/upcoming-reminders'); $this->assertStatusCode($this->response, 200); $actual = $this->getDataFromResponse($this->response); var_dump($actual); if ($table === null) { Assert::assertEmpty($actual); return; } Assert::assertEquals(array_map(function (array $expected): array { $expected['messageId'] = self::$textToMessageId[$expected['messageId']]; $expected['roomToken'] = self::$identifierToToken[$expected['roomToken']]; $expected['messageParameters'] = json_decode($expected['messageParameters']); return $expected; }, $table->getHash()), $actual); } #[Then('/^user "([^"]*)" shares rich-object "([^"]*)" "([^"]*)" \'([^\']*)\' to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSharesRichObjectToRoom(string $user, string $type, string $id, string $metaData, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/share', new TableNode([ ['objectType', $type], ['objectId', $id], ['metaData', $metaData], ]) ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId['shared::' . $type . '::' . $id] = $response['id']; self::$messageIdToText[$response['id']] = 'shared::' . $type . '::' . $id; } } #[Then('/^user "([^"]*)" shares rich-object "([^"]*)" "([^"]*)" \'([^\']*)\' to room "([^"]*)" in thread "([^"]*)" with (\d+) \((v1)\)$/')] public function userSharesRichObjectToThread(string $user, string $type, string $id, string $metaData, string $identifier, string $thread, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/share', new TableNode([ ['threadId', self::getMessageIdForText($thread)], ['objectType', $type], ['objectId', $id], ['metaData', $metaData], ]) ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId['shared::' . $type . '::' . $id] = $response['id']; self::$messageIdToText[$response['id']] = 'shared::' . $type . '::' . $id; } } #[Then('/^user "([^"]*)" requests summary for "([^"]*)" starting from ("[^"]*"|\'[^\']*\') with (\d+)(?: \((v1)\))?$/')] public function userSummarizesRoom(string $user, string $identifier, string $message, int $statusCode, string $apiVersion = 'v1', ?TableNode $tableNode = null): void { $message = substr($message, 1, -1); $fromMessageId = self::$textToMessageId[$message]; $this->setCurrentUser($user, $identifier); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/summarize', ['fromMessageId' => $fromMessageId], ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); self::$aiTaskIds[$user . '/summary/' . self::$identifierToToken[$identifier]] = $response['taskId']; if (isset($tableNode?->getRowsHash()['nextOffset'])) { Assert::assertSame(self::$textToMessageId[$tableNode->getRowsHash()['nextOffset']], $response['nextOffset'], 'Offset ID does not match'); } elseif (isset($response['nextOffset'])) { Assert::assertArrayNotHasKey('nextOffset', $response, 'Did not expect a follow-up offset key on response, but received: ' . self::$messageIdToText[$response['nextOffset']]); } } #[Then('/^user "([^"]*)" receives summary for "([^"]*)" with (\d+)$/')] public function userReceivesSummary(string $user, string $identifier, int $statusCode, ?TableNode $tableNode = null): void { $this->sendRequest( 'GET', '/taskprocessing/task/' . self::$aiTaskIds[$user . '/summary/' . self::$identifierToToken[$identifier]], ); $this->assertStatusCode($this->response, $statusCode); $response = $this->getDataFromResponse($this->response); Assert::assertNotNull($response['task']['output'], 'Task output should not be null'); Assert::assertStringContainsString($tableNode->getRowsHash()['contains'], $response['task']['output']['output']); } #[Then('/^user "([^"]*)" creates a poll in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function createPoll(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $data = $formData->getRowsHash(); $data['options'] = json_decode($data['options'], true); if ($data['resultMode'] === 'public') { $data['resultMode'] = 0; } elseif ($data['resultMode'] === 'hidden') { $data['resultMode'] = 1; } else { throw new \Exception('Invalid result mode'); } if ($data['maxVotes'] === 'unlimited') { $data['maxVotes'] = 0; } if (isset($data['draft'])) { $data['draft'] = (bool)$data['draft']; } if (isset($data['threadId'])) { $data['threadId'] = self::$titleToThreadId[$data['threadId']]; } $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier], $data ); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200 && $statusCode !== 201) { return; } $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$questionToPollId[$data['question']] = $response['id']; } } #[Then('/^user "([^"]*)" updates a draft poll in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function updateDraftPoll(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $data = $formData->getRowsHash(); $data['options'] = json_decode($data['options'], true); if ($data['resultMode'] === 'public') { $data['resultMode'] = 0; } elseif ($data['resultMode'] === 'hidden') { $data['resultMode'] = 1; } else { throw new \Exception('Invalid result mode'); } if ($data['maxVotes'] === 'unlimited') { $data['maxVotes'] = 0; } $this->setCurrentUser($user); $result = preg_match('/POLL_ID\(([^)]+)\)/', $data['id'], $matches); if ($result) { $data['id'] = self::$questionToPollId[$matches[1]]; } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/draft/' . $data['id'], $data ); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$questionToPollId[$data['question']] = $response['id']; } } #[Then('/^user "([^"]*)" gets poll drafts for room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function getPollDrafts(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/drafts'); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $response = $this->getDataFromResponse($this->response); if ($formData === null) { Assert::assertEmpty($response); return; } $data = array_map(static function (array $poll): array { $result = preg_match('/POLL_ID\(([^)]+)\)/', $poll['id'], $matches); if ($result) { $poll['id'] = self::$questionToPollId[$matches[1]]; } $poll['resultMode'] = match($poll['resultMode']) { 'public' => 0, 'hidden' => 1, }; $poll['status'] = match($poll['status']) { 'open' => 0, 'closed' => 1, 'draft' => 2, }; $poll['maxVotes'] = (int)$poll['maxVotes']; $poll['options'] = json_decode($poll['options'], true, flags: JSON_THROW_ON_ERROR); return $poll; }, $formData->getColumnsHash()); Assert::assertCount(count($data), $response); Assert::assertSame($data, $response); } #[Then('/^user "([^"]*)" sees poll "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesPollInRoom(string $user, string $question, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/' . self::$questionToPollId[$question]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode === 200 || $formData instanceof TableNode) { $expected = $this->preparePollExpectedData($formData->getRowsHash()); $response = $this->getDataFromResponse($this->response); $this->assertPollEquals($expected, $response); } } #[Then('/^user "([^"]*)" closes poll "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userClosesPollInRoom(string $user, string $question, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/' . self::$questionToPollId[$question]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $expected = $this->preparePollExpectedData($formData->getRowsHash()); $response = $this->getDataFromResponse($this->response); $this->assertPollEquals($expected, $response); } #[Then('/^user "([^"]*)" votes for options "([^"]*)" on poll "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userVotesPollInRoom(string $user, string $options, string $question, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $data = [ 'optionIds' => json_decode($options, true), ]; $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/poll/' . self::$identifierToToken[$identifier] . '/' . self::$questionToPollId[$question], $data ); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200 && $statusCode !== 201) { return; } $expected = $this->preparePollExpectedData($formData->getRowsHash()); $response = $this->getDataFromResponse($this->response); $this->assertPollEquals($expected, $response); } protected function assertPollEquals(array $expected, array $response): void { if (isset($expected['details'])) { $response['details'] = array_map(static function (array $detail): array { unset($detail['id']); return $detail; }, $response['details']); } Assert::assertEquals($expected, $response); } protected function preparePollExpectedData(array $expected): array { if ($expected['resultMode'] === 'public') { $expected['resultMode'] = 0; } elseif ($expected['resultMode'] === 'hidden') { $expected['resultMode'] = 1; } if ($expected['maxVotes'] === 'unlimited') { $expected['maxVotes'] = 0; } if ($expected['status'] === 'open') { $expected['status'] = 0; } elseif ($expected['status'] === 'closed') { $expected['status'] = 1; } elseif ($expected['status'] === 'draft') { $expected['status'] = 2; } if (str_ends_with($expected['actorId'], '@{$LOCAL_URL}')) { $expected['actorId'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $expected['actorId']); } if (str_ends_with($expected['actorId'], '@{$REMOTE_URL}')) { $expected['actorId'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $expected['actorId']); } if (isset($expected['details'])) { if (str_contains($expected['details'], '@{$LOCAL_URL}')) { $expected['details'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $expected['details']); } if (str_contains($expected['details'], '@{$REMOTE_URL}')) { $expected['details'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $expected['details']); } } if ($expected['votedSelf'] === 'not voted') { $expected['votedSelf'] = []; } else { $expected['votedSelf'] = json_decode($expected['votedSelf'], true); } if (isset($expected['votes'])) { $expected['votes'] = json_decode($expected['votes'], true); } if (isset($expected['details'])) { $expected['details'] = json_decode($expected['details'], true); } $expected['numVoters'] = (int)$expected['numVoters']; $expected['options'] = json_decode($expected['options'], true); $result = preg_match('/POLL_ID\(([^)]+)\)/', $expected['id'], $matches); if ($result) { $expected['id'] = self::$questionToPollId[$matches[1]]; } return $expected; } #[Then('/^user "([^"]*)" sees the following entry when loading the list of dashboard widgets(?: \((v1)\))$/')] public function userGetsDashboardWidgets(string $user, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/dashboard/api/' . $apiVersion . '/widgets'); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); $expectedWidgets = $formData->getColumnsHash(); foreach ($expectedWidgets as $widget) { $id = $widget['id']; Assert::assertArrayHasKey($widget['id'], $data); $widgetIconUrl = $widget['icon_url']; $dataIconUrl = $data[$id]['icon_url']; unset($widget['icon_url'], $data[$id]['icon_url']); $widget['item_icons_round'] = (bool)$widget['item_icons_round']; $widget['order'] = (int)$widget['order']; $widget['widget_url'] = str_replace('{$BASE_URL}', $this->baseUrl, $widget['widget_url']); $widget['buttons'] = str_replace('{$BASE_URL}', $this->baseUrl, $widget['buttons']); $widget['buttons'] = json_decode($widget['buttons'], true); $widget['item_api_versions'] = json_decode($widget['item_api_versions'], true); Assert::assertEquals($widget, $data[$id], 'Mismatch of data for widget ' . $id); Assert::assertStringEndsWith($widgetIconUrl, $dataIconUrl, 'Mismatch of icon URL for widget ' . $id); } } #[Then('/^user "([^"]*)" sees the following entries for dashboard widgets "([^"]*)"(?: \((v1|v2)\))$/')] public function userGetsDashboardWidgetItems(string $user, string $widgetId, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/dashboard/api/' . $apiVersion . '/widget-items?widgets[]=' . $widgetId); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); Assert::assertArrayHasKey($widgetId, $data); $expectedItems = $formData->getColumnsHash(); if ($apiVersion === 'v1') { $actualItems = $data[$widgetId]; } else { $actualItems = $data[$widgetId]['items']; } $actualItems = array_values(array_filter($actualItems, static function (array $item): bool { return $item['title'] !== 'Note to self' && $item['title'] !== 'Talk updates ✅' && $item['title'] !== 'Let´s get started!' && $item['title'] !== 'Let\'s get started!'; })); if (empty($expectedItems)) { Assert::assertEmpty($actualItems); return; } Assert::assertCount(count($expectedItems), $actualItems, json_encode($actualItems, JSON_PRETTY_PRINT)); foreach ($expectedItems as $key => $item) { $token = self::$identifierToToken[$item['link']]; $item['link'] = $this->baseUrl . 'index.php/call/' . $token; $item['iconUrl'] = str_replace('{$BASE_URL}', $this->baseUrl, $item['iconUrl']); $item['iconUrl'] = str_replace('{token}', $token, $item['iconUrl']); Assert::assertMatchesRegularExpression('/\?v=\w{8}$/', $actualItems[$key]['iconUrl']); preg_match('/(?\?v=\w{8})$/', $actualItems[$key]['iconUrl'], $matches); $item['iconUrl'] = str_replace('{version}', $matches['version'], $item['iconUrl']); Assert::assertEquals($item, $actualItems[$key], 'Wrong details for item #' . $key); } } #[Then('/^user "([^"]*)" deletes message "([^"]*)" from room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userDeletesMessageFromRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/' . self::$textToMessageId[$message], new TableNode([['message', $message]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" deletes chat history for room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userDeletesHistoryFromRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" reads message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userReadsMessageInRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/read', $message === 'NULL' ? null : new TableNode([['lastReadMessage', self::$textToMessageId[$message]]]), ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" marks room "([^"]*)" as unread with (\d+)(?: \((v1)\))?$/')] public function userMarkUnreadRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'DELETE', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/read', ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sends message "([^"]*)" with reference id "([^"]*)" to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSendsMessageWithReferenceIdToRoom(string $user, string $message, string $referenceId, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier], new TableNode([['message', $message], ['referenceId', $referenceId]]) ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId[$message] = $response['id']; self::$messageIdToText[$response['id']] = $message; } Assert::assertStringStartsWith($response['referenceId'], $referenceId); } #[Then('/^user "([^"]*)" sends reply ("[^"]*"|\'[^\']*\') on (message|thread) ("[^"]*"|\'[^\']*\') to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSendsReplyToRoom(string $user, string $reply, string $messageOrThread, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $reply = substr($reply, 1, -1); $message = substr($message, 1, -1); if ($messageOrThread === 'message') { $replyTo = self::$textToMessageId[$message]; $replyTo = ['replyTo', $replyTo]; } else { $replyTo = self::$titleToThreadId[$message]; $replyTo = ['threadId', $replyTo]; } $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier], new TableNode([['message', $reply], $replyTo]) ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages $response = $this->getDataFromResponse($this->response); if (isset($response['id'])) { self::$textToMessageId[$reply] = $response['id']; self::$messageIdToText[$response['id']] = $reply; } } #[Then('next message request has the following parameters set')] public function setChatParametersForNextRequest(?TableNode $formData = null): void { $parameters = []; foreach ($formData->getRowsHash() as $key => $value) { if (in_array($key, ['lastCommonReadId', 'lastKnownMessageId'], true)) { $parameters[$key] = self::$textToMessageId[$value]; } else { $parameters[$key] = $value; } } self::$nextChatRequestParameters = $parameters; } #[Then('/^user "([^"]*)" sees the following messages in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingMessagesInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $query = ['lookIntoFuture' => 0]; if (self::$nextChatRequestParameters !== null) { $query = array_merge($query, self::$nextChatRequestParameters); self::$nextChatRequestParameters = null; } $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?' . http_build_query($query)); $this->assertStatusCode($this->response, $statusCode); if ($statusCode === 304) { return; } $this->compareDataResponse($formData); } #[Then('/^user "([^"]*)" searches for messages ?(in other rooms)? with "([^"]*)" in room "([^"]*)" with (\d+)$/')] public function userSearchesInRoom(string $user, string $searchProvider, string $search, string $identifier, int $statusCode, ?TableNode $formData = null): void { $searchProvider = $searchProvider === 'in other rooms' ? 'talk-message' : 'talk-message-current'; $searchUrl = '/search/providers/' . $searchProvider . '/search?from=/call/' . self::$identifierToToken[$identifier]; if (str_contains($search, 'conversation:ROOM(')) { if (preg_match('/conversation:ROOM\((?P\w+)\)/', $search, $matches)) { if (array_key_exists($matches['name'], self::$identifierToToken)) { $search = trim(preg_replace('/conversation:ROOM\((\w+)\)/', '', $search)); $searchUrl .= '&conversation=' . self::$identifierToToken[$matches['name']]; } } } $searchUrl .= '&term=' . $search; $this->setCurrentUser($user); $this->sendRequest('GET', $searchUrl); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== 200) { return; } $this->compareSearchResponse($formData); } #[Then('/^user "([^"]*)" sees the following shared (media|audio|voice|file|deckcard|location|pinned|other) in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingSharedMediaInRoom(string $user, string $objectType, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/share?objectType=' . $objectType); $this->assertStatusCode($this->response, $statusCode); $this->compareDataResponse($formData); } #[Then('/^user "([^"]*)" sees the following shared summarized overview in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingSharedOverviewMediaInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/share/overview'); $this->assertStatusCode($this->response, $statusCode); $contents = $this->response->getBody()->getContents(); $this->assertEmptyArrayIsNotAListButADictionary($formData, $contents); $overview = $this->getDataFromResponseBody($contents); if ($formData instanceof TableNode) { $expected = $formData->getRowsHash(); $summarized = array_map(function ($type) { return (string)count($type); }, $overview); Assert::assertEquals($expected, $summarized); } } #[Then('/^user "([^"]*)" received a system messages in room "([^"]*)" to delete "([^"]*)"(?: \((v1)\))?$/')] public function userReceivedDeleteMessage(string $user, string $identifier, string $message, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=0'); $this->assertStatusCode($this->response, 200); $actual = $this->getDataFromResponse($this->response); foreach ($actual as $m) { if ($m['systemMessage'] === 'message_deleted') { if (isset($m['parent']['id']) && $m['parent']['id'] === self::$textToMessageId[$message]) { return; } } } Assert::fail('Missing message_deleted system message for "' . $message . '"'); } #[Then('/^user "([^"]*)" sees the following messages in room "([^"]*)" starting with "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userAwaitsTheFollowingMessagesInRoom(string $user, string $identifier, string $knownMessage, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=1&includeLastKnown=1&lastKnownMessageId=' . self::$textToMessageId[$knownMessage]); $this->assertStatusCode($this->response, $statusCode); $this->compareDataResponse($formData); } protected function compareDataResponse(?TableNode $formData = null): void { $actual = $this->getDataFromResponse($this->response); $messages = []; array_map(function (array $message) use (&$messages) { // Filter out system messages if ($message['systemMessage'] === '') { $messages[] = $message; } }, $actual); foreach ($messages as $message) { // Include the received messages in the list of messages used for // replies; this is needed to get special messages not explicitly // sent like those for shared files. self::$textToMessageId[$message['message']] = $message['id']; self::$messageIdToText[$message['id']] = $message['message']; if ($message['message'] === '{file}' && isset($message['messageParameters']['file']['name'])) { self::$textToMessageId['shared::file::' . $message['messageParameters']['file']['name']] = $message['id']; self::$messageIdToText[$message['id']] = 'shared::file::' . $message['messageParameters']['file']['name']; } } if ($formData === null) { Assert::assertEmpty($messages); return; } $includeParents = in_array('parentMessage', $formData->getRow(0), true); $includeReferenceId = in_array('referenceId', $formData->getRow(0), true); $includeReactions = in_array('reactions', $formData->getRow(0), true); $includeReactionsSelf = in_array('reactionsSelf', $formData->getRow(0), true); $includeLastEdit = in_array('lastEditActorId', $formData->getRow(0), true); $includeMessageType = in_array('messageType', $formData->getRow(0), true); $includeThreadTitle = in_array('threadTitle', $formData->getRow(0), true); $includeThreadReplies = in_array('threadReplies', $formData->getRow(0), true); $includeMetaDataKeys = array_map( static fn (string $field): string => substr($field, strlen('metaData.')), array_filter( $formData->getRow(0), static fn (string $field): bool => str_starts_with($field, 'metaData.') ) ); $expected = $formData->getHash(); $count = count($expected); Assert::assertCount($count, $messages, 'Message count does not match' . "\n" . print_r($messages, true)); for ($i = 0; $i < $count; $i++) { if ($expected[$i]['messageParameters'] === '"IGNORE"') { $messages[$i]['messageParameters'] = 'IGNORE'; } $result = preg_match('/POLL_ID\(([^)]+)\)/', $expected[$i]['messageParameters'], $matches); if ($result) { $expected[$i]['messageParameters'] = str_replace($matches[0], '"' . self::$questionToPollId[$matches[1]] . '"', $expected[$i]['messageParameters']); } if (isset($messages[$i]['messageParameters']['object']['icon-url'])) { $result = preg_match('/"\{VALIDATE_ICON_URL_PATTERN\}"/', $expected[$i]['messageParameters'], $matches); if ($result) { Assert::assertMatchesRegularExpression('/avatar(\?v=\w+)?/', $messages[$i]['messageParameters']['object']['icon-url']); $expected[$i]['messageParameters'] = str_replace($matches[0], json_encode($messages[$i]['messageParameters']['object']['icon-url']), $expected[$i]['messageParameters']); } } $expected[$i]['message'] = str_replace('\n', "\n", $expected[$i]['message']); if (str_ends_with($expected[$i]['actorId'], '@{$LOCAL_URL}')) { $expected[$i]['actorId'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $expected[$i]['actorId']); } if (str_ends_with($expected[$i]['actorId'], '@{$REMOTE_URL}')) { $expected[$i]['actorId'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $expected[$i]['actorId']); } if (str_contains($expected[$i]['messageParameters'], '{$LOCAL_URL}')) { $expected[$i]['messageParameters'] = str_replace('{$LOCAL_URL}', str_replace('/', '\/', rtrim($this->localServerUrl, '/')), $expected[$i]['messageParameters']); } if (str_contains($expected[$i]['messageParameters'], '{$REMOTE_URL}')) { $expected[$i]['messageParameters'] = str_replace('{$REMOTE_URL}', str_replace('/', '\/', rtrim($this->remoteServerUrl, '/')), $expected[$i]['messageParameters']); } if (isset($expected[$i]['lastEditActorId'])) { if (str_ends_with($expected[$i]['lastEditActorId'], '@{$LOCAL_URL}')) { $expected[$i]['lastEditActorId'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $expected[$i]['lastEditActorId']); } if (str_ends_with($expected[$i]['lastEditActorId'], '@{$REMOTE_URL}')) { $expected[$i]['lastEditActorId'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $expected[$i]['lastEditActorId']); } } if ($expected[$i]['actorType'] === 'bots') { $result = preg_match('/BOT\(([^)]+)\)/', $expected[$i]['actorId'], $matches); if ($result && isset(self::$botNameToHash[$matches[1]])) { $expected[$i]['actorId'] = 'bot-' . self::$botNameToHash[$matches[1]]; } } // Replace the date/time line of the call summary because we can not know if we jumped a minute, hour or day on the execution. if (str_contains($expected[$i]['message'], '{DATE}')) { $messages[$i]['message'] = preg_replace( '/[A-Za-z]+day, [A-Za-z]+ \d+, \d+ · \d+:\d+ [AP]M – \d+:\d+ [AP]M \(UTC\)/u', '{DATE}', $messages[$i]['message'] ); } if (isset($messages[$i]['threadTitle']) && $messages[$i]['threadTitle'] === 'NULL') { $messages[$i]['threadTitle'] = null; } if (isset($messages[$i]['threadReplies'])) { if ($messages[$i]['threadReplies'] !== 'NULL') { $messages[$i]['threadReplies'] = (int)$messages[$i]['threadReplies']; } else { $messages[$i]['threadReplies'] = null; } } } Assert::assertEquals($expected, array_map(function ($message, $expected) use ($includeParents, $includeReferenceId, $includeReactions, $includeReactionsSelf, $includeLastEdit, $includeMessageType, $includeThreadTitle, $includeThreadReplies, $includeMetaDataKeys) { $data = [ 'room' => self::$tokenToIdentifier[$message['token']], 'actorType' => $message['actorType'], 'actorId' => $message['actorType'] === 'guests' ? self::$sessionIdToUser[$message['actorId']] : $message['actorId'], 'actorDisplayName' => $message['actorDisplayName'], // TODO test timestamp; it may require using Runkit, php-timecop // or something like that. 'message' => $message['message'], 'messageParameters' => json_encode($message['messageParameters']), ]; if ($includeParents) { $data['parentMessage'] = $message['parent']['message'] ?? ''; } if ($includeReferenceId) { $data['referenceId'] = $message['referenceId']; } if ($includeMessageType) { $data['messageType'] = $message['messageType']; } if (isset($expected['silent'])) { $data['silent'] = isset($message['silent']) ? json_encode($message['silent']) : '!ISSET'; } if ($includeReactions) { $data['reactions'] = json_encode($message['reactions'], JSON_UNESCAPED_UNICODE); } if ($includeReactionsSelf) { if (isset($message['reactionsSelf'])) { $data['reactionsSelf'] = json_encode($message['reactionsSelf'], JSON_UNESCAPED_UNICODE); } else { $data['reactionsSelf'] = null; } } if ($includeLastEdit) { $data['lastEditActorType'] = $message['lastEditActorType'] ?? ''; $data['lastEditActorDisplayName'] = $message['lastEditActorDisplayName'] ?? ''; $data['lastEditActorId'] = $message['lastEditActorId'] ?? ''; if (($message['lastEditActorType'] ?? '') === 'guests') { $data['lastEditActorId'] = self::$sessionIdToUser[$message['lastEditActorId']]; } } if ($includeThreadTitle) { $data['threadTitle'] = $message['threadTitle'] ?? null; } if ($includeThreadReplies) { $data['threadReplies'] = $message['threadReplies'] ?? null; } if (!empty($includeMetaDataKeys)) { $metaData = $message['metaData'] ?? []; var_dump($message['message'], $metaData); foreach ($includeMetaDataKeys as $key) { $data['metaData.' . $key] = $metaData[$key] ?? 'UNSET'; $expectedValue = $expected['metaData.' . $key]; if ($expectedValue === 'NUMERIC' && is_numeric($data['metaData.' . $key])) { $data['metaData.' . $key] = $expectedValue; } } } return $data; }, $messages, $expected)); } protected function compareSearchResponse(?TableNode $formData = null, ?string $expectedCursor = null): void { $data = $this->getDataFromResponse($this->response); $results = $data['entries']; if ($expectedCursor !== null) { Assert::assertSame($expectedCursor, $data['cursor']); } if ($formData === null) { Assert::assertEmpty($results); return; } $expected = array_map(static function (array $result) { if (isset($result['attributes.conversation'])) { $result['attributes.conversation'] = self::$identifierToToken[$result['attributes.conversation']]; } if (isset($result['attributes.threadId'])) { $result['attributes.threadId'] = self::$titleToThreadId[$result['attributes.threadId']]; } if (isset($result['attributes.messageId'])) { $result['attributes.messageId'] = self::$textToMessageId[$result['attributes.messageId']]; } return $result; }, $formData->getHash()); $count = count($expected); Assert::assertCount($count, $results, 'Result count does not match'); Assert::assertEquals($expected, array_map(static function ($actual) { $compare = [ 'title' => $actual['title'], 'subline' => $actual['subline'], ]; if (isset($actual['attributes']['conversation'])) { $compare['attributes.conversation'] = $actual['attributes']['conversation']; } if (isset($actual['attributes']['messageId'])) { $compare['attributes.messageId'] = $actual['attributes']['messageId']; } if (isset($actual['attributes']['threadId'])) { $compare['attributes.threadId'] = $actual['attributes']['threadId']; } return $compare; }, $results)); } #[Then('/^user "([^"]*)" sees the following recent threads in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingRecentThreadsInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/threads/recent'); $this->assertStatusCode($this->response, $statusCode); $results = $this->getDataFromResponse($this->response); $this->compareThreadsResponse($formData, $results); } #[Then('/^user "([^"]*)" sees the following subscribed threads(?: \((v1)\))?$/')] public function userSeesTheFollowingSubscribedThreads(string $user, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/subscribed-threads'); $this->assertStatusCode($this->response, 200); $results = $this->getDataFromResponse($this->response); $this->compareThreadsResponse($formData, $results); } #[Then('/^user "([^"]*)" sees (\d+) number of subscribed threads with (\d+) offset(?: \((v1)\))?$/')] public function userSeesNumberOfSubscribedThreads(string $user, int $limit, int $offset, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/subscribed-threads?limit=' . $limit . '&offset=' . $offset); $this->assertStatusCode($this->response, 200); $results = $this->getDataFromResponse($this->response); $this->compareThreadsResponse($formData, $results); } #[Then('/^user "([^"]*)" creates thread "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userCreatesThreadInRoom(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/threads/' . self::$textToMessageId[$message]); $this->assertStatusCode($this->response, $statusCode); if ($formData !== null) { $result = $this->getDataFromResponse($this->response); $this->compareThreadsResponse($formData, [$result]); } sleep(1); } #[Then('/^user "([^"]*)" subscribes to thread "([^"]*)" in room "([^"]*)" with notification level (\d+) with (\d+)(?: \((v1)\))?$/')] public function userSubscribesThreadInRoom(string $user, string $message, string $identifier, int $level, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/threads/' . self::$textToMessageId[$message] . '/notify', ['level' => $level], ); $this->assertStatusCode($this->response, $statusCode); if ($formData !== null) { $result = $this->getDataFromResponse($this->response); $this->compareThreadsResponse($formData, [$result]); } } protected function compareThreadsResponse(?TableNode $formData, array $results): void { if ($formData === null) { Assert::assertEmpty($results); return; } $count = count($formData->getHash()); Assert::assertCount($count, $results, 'Result count does not match'); $tokenInResult = false; $expected = array_map(static function (array $result) use (&$tokenInResult) { foreach (['t.id', 't.token', 't.title', 't.lastMessage', 'a.notificationLevel', 'firstMessage', 'lastMessage'] as $field) { if (isset($result[$field])) { if ($field === 'a.notificationLevel') { $result[$field] = (int)$result[$field]; } elseif ($result[$field] === '0') { $result[$field] = 0; } elseif ($result[$field] === 'NULL') { $result[$field] = null; } elseif ($field === 't.title') { $result[$field] = trim($result[$field]); } elseif ($field === 't.token') { $tokenInResult = true; $result[$field] = self::$identifierToToken[$result[$field]]; } else { $result[$field] = self::$textToMessageId[$result[$field]]; } } } foreach (['t.numReplies'] as $field) { $result[$field] = (int)$result[$field]; } return $result; }, $formData->getHash()); Assert::assertEquals($expected, array_map(static function ($actual) use ($tokenInResult) { $compare = [ 't.id' => $actual['thread']['id'], 't.title' => $actual['thread']['title'], 't.numReplies' => $actual['thread']['numReplies'], 't.lastMessage' => $actual['thread']['lastMessageId'], 'a.notificationLevel' => $actual['attendee']['notificationLevel'], 'firstMessage' => $actual['first']['id'] ?? null, 'lastMessage' => $actual['last']['id'] ?? null, ]; if ($tokenInResult) { $compare['t.token'] = $actual['thread']['roomToken']; } return $compare; }, $results)); } #[Then('/^user "([^"]*)" searches for conversations with "([^"]*)"(?: offset "([^"]*)")? limit (\d+)(?: expected cursor "([^"]*)")?$/')] public function userSearchesRooms(string $user, string $search, string $offset, int $limit, string $expectedCursor, ?TableNode $formData = null): void { $searchUrl = '/search/providers/talk-conversations/search?limit=' . $limit; if ($offset && array_key_exists($offset, self::$identifierToToken)) { $searchUrl .= '&cursor=' . self::$identifierToToken[$offset]; } $searchUrl .= '&term=' . $search; $this->setCurrentUser($user); $this->sendRequest('GET', $searchUrl); $this->assertStatusCode($this->response, 200); if ($expectedCursor !== null) { $expectedCursor = self::$identifierToToken[$expectedCursor] ?? ''; } $this->compareSearchResponse($formData, $expectedCursor); } #[Then('/^user "([^"]*)" sees the following system messages in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSeesTheFollowingSystemMessagesInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '?lookIntoFuture=0'); $this->assertStatusCode($this->response, $statusCode); $messages = $this->getDataFromResponse($this->response); $messages = array_filter($messages, function (array $message) { return $message['systemMessage'] !== ''; }); // Fix index gaps after the array_filter above $messages = array_values($messages); foreach ($messages as $systemMessage) { // Include the received system messages in the list of messages used // for replies. self::$textToMessageId[$systemMessage['systemMessage']] = $systemMessage['id']; self::$messageIdToText[$systemMessage['id']] = $systemMessage['systemMessage']; } if ($formData === null) { Assert::assertEmpty($messages); return; } $expected = array_map(function (array $message) { if (isset($message['messageParameters'])) { $result = preg_match('/POLL_ID\(([^)]+)\)/', $message['messageParameters'], $matches); if ($result) { $message['messageParameters'] = str_replace($matches[0], '"' . self::$questionToPollId[$matches[1]] . '"', $message['messageParameters']); } $result = preg_match('/THREAD_ID\(([^)]+)\)/', $message['messageParameters'], $matches); if ($result) { $message['messageParameters'] = str_replace($matches[0], '"thread\/' . self::$titleToThreadId[$matches[1]] . '"', $message['messageParameters']); } $message['messageParameters'] = str_replace('{$REMOTE_URL}', trim(json_encode(trim($this->remoteServerUrl, '/')), '"'), $message['messageParameters']); } return $message; }, $formData->getHash()); Assert::assertCount(count($expected), $messages, 'Message count does not match:' . "\n" . json_encode($messages, JSON_PRETTY_PRINT)); Assert::assertEquals($expected, array_map(function ($message, $expected) { $data = [ 'room' => self::$tokenToIdentifier[$message['token']], 'actorType' => (string)$message['actorType'], 'actorId' => ($message['actorType'] === 'guests') ? self::$sessionIdToUser[$message['actorId']] : (string)$message['actorId'], 'systemMessage' => (string)$message['systemMessage'], ]; $data['actorId'] = $this->translateRemoteServer($data['actorId']); if (isset($expected['actorDisplayName'])) { $data['actorDisplayName'] = $message['actorDisplayName']; } if (isset($expected['message'])) { $data['message'] = $message['message']; } if (isset($expected['messageParameters'])) { $data['messageParameters'] = json_encode($message['messageParameters']); if ($expected['messageParameters'] === '"IGNORE"') { $data['messageParameters'] = '"IGNORE"'; } } if (isset($expected['silent'])) { $data['silent'] = isset($message['silent']) ? json_encode($message['silent']) : '!ISSET'; } return $data; }, $messages, $expected)); } #[Then('/^user "([^"]*)" gets the following candidate mentions in room "([^"]*)" for "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userGetsTheFollowingCandidateMentionsInRoomFor(string $user, string $identifier, string $search, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/chat/' . self::$identifierToToken[$identifier] . '/mentions?search=' . $search); $this->assertStatusCode($this->response, $statusCode); $mentions = $this->getDataFromResponse($this->response); if ($formData === null) { Assert::assertEmpty($mentions); return; } $expected = $formData->getHash(); if (empty($expected)) { Assert::assertEmpty($mentions); return; } Assert::assertCount(count($expected), $mentions, 'Mentions count does not match' . "\n" . json_encode($mentions, JSON_PRETTY_PRINT)); usort($mentions, function ($a, $b) { if ($a['source'] === $b['source']) { return $a['label'] <=> $b['label']; } return $a['source'] <=> $b['source']; }); usort($expected, function ($a, $b) { if ($a['source'] === $b['source']) { return $a['label'] <=> $b['label']; } return $a['source'] <=> $b['source']; }); $checkDetails = array_key_exists('details', $expected[0]); foreach ($expected as $key => $row) { if ($row['id'] === 'GUEST_ID') { Assert::assertMatchesRegularExpression('/^guest\/[0-9a-f]{40}$/', $mentions[$key]['id']); $mentions[$key]['id'] = 'GUEST_ID'; } if ($row['mentionId'] === 'GUEST_ID') { Assert::assertMatchesRegularExpression('/^guest\/[0-9a-f]{40}$/', $mentions[$key]['mentionId']); $mentions[$key]['mentionId'] = 'GUEST_ID'; } if (str_ends_with($row['id'], '@{$LOCAL_URL}')) { $row['id'] = str_replace('{$LOCAL_URL}', rtrim($this->localServerUrl, '/'), $row['id']); } if (str_ends_with($row['id'], '@{$REMOTE_URL}')) { $row['id'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $row['id']); } if (str_ends_with($row['mentionId'], '@{$BASE_URL}')) { $row['mentionId'] = str_replace('{$BASE_URL}', rtrim($this->localServerUrl, '/'), $row['mentionId']); } if (str_ends_with($row['mentionId'], '@{$REMOTE_URL}')) { $row['mentionId'] = str_replace('{$REMOTE_URL}', rtrim($this->remoteServerUrl, '/'), $row['mentionId']); } if ($row['source'] === 'teams') { [, $teamId] = explode('/', $row['mentionId'], 2); $row['id'] = $row['mentionId'] = 'team/' . self::getTeamIdForLabel($this->currentServer, $teamId); } if (array_key_exists('avatar', $row)) { Assert::assertMatchesRegularExpression('/' . self::$identifierToToken[$row['avatar']] . '\/avatar/', $mentions[$key]['avatar']); unset($row['avatar']); } unset($mentions[$key]['avatar']); if (!$checkDetails) { unset($mentions[$key]['details']); } elseif (empty($row['details'])) { unset($row['details']); } Assert::assertEquals($row, $mentions[$key]); } } #[Then('/^user "([^"]*)" gets the following collaborator suggestions in room "([^"]*)" for "([^"]*)" with (\d+)$/')] public function userGetsTheFollowingCollaboratorSuggestions(string $user, string $identifier, string $search, int $statusCode, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/core/autocomplete/get?search=' . $search . '&itemType=call&itemId=' . self::$identifierToToken[$identifier] . '&shareTypes[]=0&shareTypes[]=1&shareTypes[]=7&shareTypes[]=4'); $this->assertStatusCode($this->response, $statusCode); $mentions = array_map(static function (array $mention): array { unset($mention['icon']); unset($mention['status']); unset($mention['subline']); unset($mention['shareWithDisplayNameUnique']); return $mention; }, $this->getDataFromResponse($this->response)); if ($formData === null) { Assert::assertEmpty($mentions); return; } Assert::assertCount(count($formData->getHash()), $mentions, 'Mentions count does not match'); usort($mentions, static function (array $a, array $b) { if ($a['source'] === $b['source']) { return $a['label'] <=> $b['label']; } return $a['source'] <=> $b['source']; }); $expected = array_map(function (array $mention): array { $result = preg_match('/TEAM_ID\(([^)]+)\)/', $mention['id'], $matches); if ($result) { $mention['id'] = self::getTeamIdForLabel($this->currentServer, $matches[1]); } return $mention; }, $formData->getHash()); usort($expected, static function (array $a, array $b) { if ($a['source'] === $b['source']) { return $a['label'] <=> $b['label']; } return $a['source'] <=> $b['source']; }); Assert::assertEquals($expected, $mentions); } #[Then('/^guest "([^"]*)" sets name to "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function guestSetsName(string $user, string $name, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/guest/' . self::$identifierToToken[$identifier] . '/name', new TableNode([['displayName', $name]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^last response has (no) last common read message header$/')] public function hasNoChatLastCommonReadHeader(string $no): void { Assert::assertArrayNotHasKey('X-Chat-Last-Common-Read', $this->response->getHeaders(), 'X-Chat-Last-Common-Read is set to ' . ($this->response->getHeader('X-Chat-Last-Common-Read')[0] ?? '0')); } #[Then('/^last response has last common read message header (set to|less than) "([^"]*)"$/')] public function hasChatLastCommonReadHeader(string $setOrLower, string $message): void { Assert::assertArrayHasKey('X-Chat-Last-Common-Read', $this->response->getHeaders()); if ($setOrLower === 'set to') { Assert::assertEquals(self::$textToMessageId[$message], $this->response->getHeader('X-Chat-Last-Common-Read')[0]); } else { // Less than might be required for the first message, because the last read message before is the join/room creation message and we don't know that ID Assert::assertLessThan(self::$textToMessageId[$message], $this->response->getHeader('X-Chat-Last-Common-Read')[0]); } } #[Then('/^last response has federation invites header set to "([^"]*)"$/')] public function hasFederationInvitesHeader(string $count): void { if ($count === 'NULL') { Assert::assertFalse($this->response->hasHeader('X-Nextcloud-Talk-Federation-Invites'), "Should not contain 'X-Nextcloud-Talk-Federation-Invites' header\n" . json_encode($this->response->getHeaders(), JSON_PRETTY_PRINT)); } else { Assert::assertTrue($this->response->hasHeader('X-Nextcloud-Talk-Federation-Invites'), "Should contain 'X-Nextcloud-Talk-Federation-Invites' header\n" . json_encode($this->response->getHeaders(), JSON_PRETTY_PRINT)); Assert::assertEquals($count, $this->response->getHeader('X-Nextcloud-Talk-Federation-Invites')[0]); } } #[Then('/^user "([^"]*)" creates (\d+) (automatic|manual|free) breakout rooms for "([^"]*)" with (\d+) \((v1)\)$/')] public function userCreatesBreakoutRooms(string $user, int $amount, string $modeString, string $identifier, int $status, string $apiVersion, ?TableNode $formData = null): void { switch ($modeString) { case 'automatic': $mode = 1; break; case 'manual': $mode = 2; break; case 'free': $mode = 3; break; default: throw new \InvalidArgumentException('Invalid breakout room mode: ' . $modeString); } $data = [ 'mode' => $mode, 'amount' => $amount, ]; if ($modeString === 'manual' && $formData instanceof TableNode) { $mapArray = []; foreach ($formData->getRowsHash() as $attendee => $roomNumber) { [$type, $id] = explode('::', $attendee); $attendeeId = $this->getAttendeeId($type, $id, $identifier); $mapArray[$attendeeId] = (int)$roomNumber; } $data['attendeeMap'] = json_encode($mapArray, JSON_THROW_ON_ERROR); } $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier], $data); $this->assertStatusCode($this->response, $status); } #[Then('/^user "([^"]*)" removes breakout rooms from "([^"]*)" with (\d+) \((v1)\)$/')] public function userRemovesBreakoutRooms(string $user, string $identifier, int $status, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier]); $this->assertStatusCode($this->response, $status); } #[Then('/^user "([^"]*)" moves participants into different breakout rooms for "([^"]*)" with (\d+) \((v1)\)$/')] public function userMovesParticipantsInsideBreakoutRooms(string $user, string $identifier, int $status, string $apiVersion, ?TableNode $formData = null): void { $data = []; if ($formData instanceof TableNode) { $mapArray = []; foreach ($formData->getRowsHash() as $attendee => $roomNumber) { [$type, $id] = explode('::', $attendee); $attendeeId = $this->getAttendeeId($type, $id, $identifier); $mapArray[$attendeeId] = (int)$roomNumber; } $data['attendeeMap'] = json_encode($mapArray, JSON_THROW_ON_ERROR); } $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier] . '/attendees', $data); $this->assertStatusCode($this->response, $status); } #[Then('/^user "([^"]*)" broadcasts message "([^"]*)" to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userBroadcastsMessageToBreakoutRooms(string $user, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $body = new TableNode([['message', $message]]); $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier] . '/broadcast', $body ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages } #[Then('/^user "([^"]*)" (starts|stops) breakout rooms in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userStartsOrStopsBreakoutRooms(string $user, string $startStop, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( $startStop === 'starts' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier] . '/rooms' ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" switches in room "([^"]*)" to breakout room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSwitchesBreakoutRoom(string $user, string $identifier, string $target, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier] . '/switch', [ 'target' => self::$identifierToToken[$target], ] ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" (requests assistance|cancels request for assistance) in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userRequestsOrCancelsAssistanceInBreakoutRooms(string $user, string $requestCancel, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( $requestCancel === 'requests assistance' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/breakout-rooms/' . self::$identifierToToken[$identifier] . '/request-assistance' ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" sets setting "([^"]*)" to ("[^"]*"|\d) with (\d+)(?: \((v1)\))?$/')] public function userSetting(string $user, string $setting, string|int $value, int $statusCode, string $apiVersion = 'v1'): void { if (str_starts_with($value, '"') && str_ends_with($value, '"')) { $value = substr($value, 1, -1); } else { $value = (int)$value; } $this->setCurrentUser($user); $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/settings/user', new TableNode([['key', $setting], ['value', $value]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^user "([^"]*)" has capability "([^"]*)" set to ("[^"]*"|\d)$/')] public function userCheckCapability(string $user, string $capability, string $value): void { if (str_starts_with($value, '"') && str_ends_with($value, '"')) { $value = substr($value, 1, -1); } else { $value = (int)$value; } $this->setCurrentUser($user); $this->sendRequest( 'GET', '/cloud/capabilities' ); $data = $this->getDataFromResponse($this->response); $capabilities = $data['capabilities']; $keys = explode('=>', $capability); $finalKey = array_pop($keys); $cur = $capabilities; foreach ($keys as $key) { Assert::assertArrayHasKey($key, $cur); $cur = $cur[$key]; } Assert::assertEquals($value, $cur[$finalKey]); } #[Then('/^user "([^"]*)" has room capability "([^"]*)" set to ("[^"]*"|\d) on room "([^"]*)"$/')] public function userCheckCapabilityFromRoomApi(string $user, string $capability, string|int $value, string $identifier): void { if (str_starts_with($value, '"') && str_ends_with($value, '"')) { $value = substr($value, 1, -1); } else { $value = (int)$value; } $this->setCurrentUser($user); $this->sendRequest( 'GET', '/apps/spreed/api/v4/room/' . self::$identifierToToken[$identifier] . '/capabilities' ); $capabilities = $this->getDataFromResponse($this->response); $keys = explode('=>', $capability); $finalKey = array_pop($keys); $cur = $capabilities; foreach ($keys as $key) { Assert::assertArrayHasKey($key, $cur); $cur = $cur[$key]; } Assert::assertEquals($value, $cur[$finalKey]); } /** * Parses the JSON answer to get the array of data returned. */ protected function getDataFromResponse(ResponseInterface $response): mixed { return $this->getDataFromResponseBody($response->getBody()->getContents()); } /** * Parses the JSON answer to get the array of users returned. */ protected function getDataFromResponseBody(string $response): mixed { $jsonBody = json_decode($response, true); return $jsonBody['ocs']['data']; } #[Then('/^status code is ([0-9]*)$/')] public function isStatusCode(int $statusCode): void { $this->assertStatusCode($this->response, $statusCode); } #[Given('the following :appId app config is set')] public function setAppConfig(string $appId, TableNode $formData): void { $currentUser = $this->setCurrentUser('admin'); foreach ($formData->getRows() as $row) { $this->sendRequest('POST', '/apps/provisioning_api/api/v1/config/apps/' . $appId . '/' . $row[0], [ 'value' => $row[1], ]); $this->changedConfigs[$this->currentServer][$appId][] = $row[0]; } $this->setCurrentUser($currentUser); } #[Given('/^(enable|disable) query\.log$/')] public function toggleQueryLog(string $enable): void { if ($enable === 'enable') { $this->runOcc(['config:system:get', 'datadirectory']); $dir = trim($this->lastStdOut); self::$queryLogFile = rtrim($dir, '/') . '/query.log'; $this->runOcc(['config:system:set', 'query_log_file', '--value', self::$queryLogFile]); file_put_contents(self::$queryLogFile, "\n>>>>> START\n" . self::$currentScenario . "\n", FILE_APPEND); } else { file_put_contents(self::$queryLogFile, "\n>>>>> END\n" . self::$currentScenario . "\n", FILE_APPEND); $this->runOcc(['config:system:remove', 'query_log_file']); } } #[Given('/^note query\.log: (.*)$/')] public function noteQueryLog(string $note): void { file_put_contents(self::$queryLogFile, "\n>>>>> NOTE\n" . $note . "\n", FILE_APPEND); } #[Given('/^OCM provider (does not have|has) the following resource types$/')] public function checkOCMProviderResourceTypes(string $shouldFind, TableNode $formData): void { $this->sendFrontpageRequest('GET', '/ocm-provider'); $data = json_decode($this->response->getBody()->getContents(), true); $expectedTypes = $formData->getHash(); $expectedFound = $shouldFind === 'has'; foreach ($expectedTypes as $expected) { $found = false; foreach ($data['resourceTypes'] as $type) { if ($type['name'] === $expected['name']) { $found = true; Assert::assertEquals( json_decode($expected['shareTypes'], true), $type['shareTypes'], ); Assert::assertEquals( json_decode($expected['protocols'], true), $type['protocols'], ); } } Assert::assertEquals($expectedFound, $found); } } #[Then('user :user has the following notifications')] public function userNotifications(string $user, ?TableNode $body = null): void { $this->setCurrentUser($user); $this->sendRequest( 'GET', '/apps/notifications/api/v2/notifications' ); $data = $this->getDataFromResponse($this->response); $filteredNotifications = array_filter($data, static fn (array $notification) => $notification['app'] === 'spreed'); if (count($data) !== count($filteredNotifications)) { echo 'Notifications were filtered by app=spreed'; } if ($body === null) { self::$lastNotifications = []; Assert::assertCount(0, $filteredNotifications, json_encode($data, JSON_PRETTY_PRINT)); return; } $this->assertNotifications($filteredNotifications, $body); self::$lastNotifications = $filteredNotifications; } private function assertNotifications(array $notifications, TableNode $formData): void { Assert::assertCount(count($formData->getHash()), $notifications, 'Notifications count does not match:' . "\n" . json_encode($notifications, JSON_PRETTY_PRINT)); $expectedNotifications = array_map(function (array $expectedNotification): array { if (str_contains($expectedNotification['object_id'], '/')) { [$roomToken, $message] = explode('/', $expectedNotification['object_id'], 2); $result = preg_match('/TEAM_ID\(([^)]+)\)/', $message, $matches); if ($result) { $message = str_replace($matches[0], 'team/' . self::getTeamIdForLabel($this->currentServer, $matches[1]), $message); } $expectedNotification['object_id'] = $roomToken . '/' . $message; } return $expectedNotification; }, $formData->getHash()); Assert::assertEquals($expectedNotifications, array_map(function ($notification, $expectedNotification) { $data = []; if (isset($expectedNotification['object_id'])) { if (str_contains($notification['object_id'], '/')) { $parts = explode('/', $notification['object_id']); $roomToken = $parts[0]; $message = $parts[1]; $messageText = self::$messageIdToText[$message] ?? 'UNKNOWN_MESSAGE'; $messageText = str_replace([$this->localServerUrl, $this->remoteServerUrl], ['{$LOCAL_URL}', '{$REMOTE_URL}'], $messageText); $data['object_id'] = self::$tokenToIdentifier[$roomToken] . '/' . $messageText; if (isset($parts[2])) { // If you end up here with an undefined index, a notification had a thread id // But you most likely have a non-user mention in the message instead $data['object_id'] .= '/' . self::$threadIdToTitle[$parts[2]]; } } elseif (str_contains($expectedNotification['object_id'], 'INVITE_ID')) { $data['object_id'] = 'INVITE_ID(' . self::$inviteIdToRemote[$notification['object_id']] . ')'; } else { [$roomToken,] = explode('/', $notification['object_id']); $data['object_id'] = self::$tokenToIdentifier[$roomToken]; } } if (isset($expectedNotification['subject'])) { $data['subject'] = (string)$notification['subject']; } if (isset($expectedNotification['message'])) { $data['message'] = (string)$notification['message']; $result = preg_match('/ROOM\(([^)]+)\)/', $expectedNotification['message'], $matches); if ($result && isset(self::$identifierToToken[$matches[1]])) { $data['message'] = str_replace(self::$identifierToToken[$matches[1]], $matches[0], $data['message']); } $result = preg_match('/TEAM_ID\(([^)]+)\)/', $expectedNotification['message'], $matches); if ($result) { $data['message'] = str_replace($matches[0], self::getTeamIdForLabel($this->currentServer, $matches[1]), $data['message']); } } if (isset($expectedNotification['object_type'])) { $data['object_type'] = (string)$notification['object_type']; } if (isset($expectedNotification['app'])) { $data['app'] = (string)$notification['app']; } return $data; }, $notifications, $expectedNotifications), json_encode($notifications, JSON_PRETTY_PRINT)); } #[Given('/^guest accounts can be created$/')] public function allowGuestAccountsCreation(): void { $currentUser = $this->setCurrentUser('admin'); // save old state and restore at the end $this->sendRequest('GET', '/cloud/apps?filter=enabled'); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); $this->guestsAppWasEnabled[$this->currentServer] = in_array('guests', $data['apps'], true); if (!$this->guestsAppWasEnabled[$this->currentServer]) { // enable Guests app /* $this->sendRequest('POST', '/cloud/apps/guests'); $this->assertStatusCode($this->response, 200); */ // seems using provisioning API doesn't create tables... $this->runOcc(['app:enable', 'guests']); } // save previously set whitelist $this->sendRequest('GET', '/apps/provisioning_api/api/v1/config/apps/guests/whitelist'); $this->assertStatusCode($this->response, 200); $this->guestsOldWhitelist[$this->currentServer] = $this->getDataFromResponse($this->response)['data']; // set whitelist to allow spreed only $this->sendRequest('POST', '/apps/provisioning_api/api/v1/config/apps/guests/whitelist', [ 'value' => 'spreed', ]); $this->setCurrentUser($currentUser); } #[Given('/^Fake summary task provider is enabled$/')] public function enableTestingApp(): void { $currentUser = $this->setCurrentUser('admin'); // save old state and restore at the end $this->sendRequest('GET', '/cloud/apps?filter=enabled'); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); $this->testingAppWasEnabled[$this->currentServer] = in_array('testing', $data['apps'], true); if (!$this->testingAppWasEnabled[$this->currentServer]) { $this->runOcc(['app:enable', 'testing']); } $this->runOcc(['config:app:get', 'core', 'ai.taskprocessing_provider_preferences']); $this->taskProcessingProviderPreference[$this->currentServer] = $this->lastStdOut; $preferences = json_decode($this->lastStdOut ?: '[]', true, flags: JSON_THROW_ON_ERROR); $preferences['core:text2text:summary'] = 'testing-text2text-summary'; $preferences['core:audio2text'] = 'testing-audio2text'; $this->runOcc(['config:app:set', 'core', 'ai.taskprocessing_provider_preferences', '--value', json_encode($preferences)]); $this->setCurrentUser($currentUser); } #[BeforeScenario] #[AfterScenario] public function resetSpreedAppData(): void { foreach (['LOCAL', 'REMOTE'] as $server) { $this->usingServer($server); $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('DELETE', '/apps/spreedcheats/'); foreach ($this->changedConfigs[$server] as $appId => $configs) { foreach ($configs as $config) { $this->sendRequest('DELETE', '/apps/provisioning_api/api/v1/config/apps/' . $appId . '/' . $config); } } $this->setCurrentUser($currentUser); if ($this->changedBruteforceSetting) { $this->enableDisableBruteForceProtection('disable'); } } $this->usingServer('LOCAL'); } #[AfterScenario] public function resetAppsState(): void { foreach (['LOCAL', 'REMOTE'] as $server) { $this->usingServer($server); if ($this->guestsAppWasEnabled[$server] === null) { // Guests app was not touched continue; } $currentUser = $this->setCurrentUser('admin'); if ($this->guestsOldWhitelist[$server]) { // restore old whitelist $this->sendRequest('POST', '/apps/provisioning_api/api/v1/config/apps/guests/whitelist', [ 'value' => $this->guestsOldWhitelist[$server], ]); } else { // restore to default $this->sendRequest('DELETE', '/apps/provisioning_api/api/v1/config/apps/guests/whitelist'); } // restore app's enabled state $this->sendRequest($this->guestsAppWasEnabled[$server] ? 'POST' : 'DELETE', '/cloud/apps/guests'); $this->setCurrentUser($currentUser); $this->guestsAppWasEnabled[$server] = null; } foreach (['LOCAL', 'REMOTE'] as $server) { $this->usingServer($server); if ($this->testingAppWasEnabled[$server] === null) { // Testing app was not touched continue; } $currentUser = $this->setCurrentUser('admin'); if ($this->taskProcessingProviderPreference[$server]) { $this->runOcc(['config:app:set', 'core', 'ai.taskprocessing_provider_preferences', '--value', $this->taskProcessingProviderPreference[$server]]); } else { $this->runOcc(['config:app:delete', 'core', 'ai.taskprocessing_provider_preferences']); } // restore app's enabled state $this->sendRequest($this->testingAppWasEnabled[$server] ? 'POST' : 'DELETE', '/cloud/apps/testing'); $this->setCurrentUser($currentUser); $this->testingAppWasEnabled[$server] = null; } } /* * User management */ #[Given('/^as user "([^"]*)"$/')] public function setCurrentUser(?string $user): ?string { $oldUser = $this->currentUser; $this->currentUser = $user; return $oldUser; } #[Given('/^user "([^"]*)" exists$/')] public function assureUserExists(string $user): void { $response = $this->userExists($user); if ($response->getStatusCode() !== 200) { $this->createUser($user); // Set a display name different than the user ID to be able to // ensure in the tests that the right value was returned. $this->setUserDisplayName($user); $response = $this->userExists($user); $this->assertStatusCode($response, 200); } } #[Given('/^user "([^"]*)" exists and has an email address$/')] public function assureUserExistsAndHasEmail(string $user): void { $response = $this->userExists($user); if ($response->getStatusCode() !== 200) { $this->createUser($user); // Set a display name different than the user ID to be able to // ensure in the tests that the right value was returned. $this->setUserDisplayName($user); $response = $this->userExists($user); $this->assertStatusCode($response, 200); } $this->setUserEmail($user); } #[Given('/^(enable|disable) brute force protection$/')] public function enableDisableBruteForceProtection(string $enable): void { if ($enable === 'enable') { $this->changedBruteforceSetting = true; } else { // Reset the attempts before disabling $this->runOcc(['security:bruteforce:reset', '127.0.0.1']); $this->theCommandWasSuccessful(); $this->runOcc(['security:bruteforce:reset', '::1']); $this->theCommandWasSuccessful(); } // config:system:get auth.bruteforce.protection.enabled $this->runOcc(['config:system:set', 'auth.bruteforce.protection.enabled', '--type=boolean', '--value=' . ($enable === 'enable' ? 'true' : 'false')]); $this->theCommandWasSuccessful(); // config:system:get auth.bruteforce.protection.testing if ($enable === 'enable') { $this->runOcc(['config:system:set', 'auth.bruteforce.protection.testing', '--type=boolean', '--value=' . 'true']); } else { $this->runOcc(['config:system:delete', 'auth.bruteforce.protection.testing']); } $this->theCommandWasSuccessful(); if ($enable === 'enable') { // Reset the attempts after enabling $this->runOcc(['security:bruteforce:reset', '127.0.0.1']); $this->theCommandWasSuccessful(); $this->runOcc(['security:bruteforce:reset', '::1']); $this->theCommandWasSuccessful(); } else { $this->changedBruteforceSetting = false; } } #[Given('/^the following brute force attempts are registered$/')] public function assertBruteforceAttempts(?TableNode $tableNode = null): void { $totalCount = 0; if ($tableNode instanceof TableNode) { foreach ($tableNode->getRowsHash() as $action => $attempts) { $this->runOcc(['security:bruteforce:attempts', '127.0.0.1', $action, '--output=json']); $this->theCommandWasSuccessful(); $info = json_decode($this->getLastStdOut(), true); $totalCount += $info['attempts']; $ipv4Attempts = $info['attempts']; $this->runOcc(['security:bruteforce:attempts', '::1', $action, '--output=json']); $this->theCommandWasSuccessful(); $info = json_decode($this->getLastStdOut(), true); $totalCount += $info['attempts']; $ipv6Attempts = $info['attempts']; Assert::assertEquals($attempts, $ipv4Attempts + $ipv6Attempts); } } $this->runOcc(['security:bruteforce:attempts', '127.0.0.1', '--output=json']); $this->theCommandWasSuccessful(); $info = json_decode($this->getLastStdOut(), true); $ipv4Attempts = $info['attempts']; $this->runOcc(['security:bruteforce:attempts', '::1', '--output=json']); $this->theCommandWasSuccessful(); $info = json_decode($this->getLastStdOut(), true); $ipv6Attempts = $info['attempts']; Assert::assertEquals($totalCount, $ipv4Attempts + $ipv6Attempts, 'IP has bruteforce attempts for other actions registered'); } #[Given('/^team "([^"]*)" exists$/')] public function assureTeamExists(string $team): void { $this->runOcc(['circles:manage:create', '--type', '1', '--output', 'json', 'admin', $team]); $this->theCommandWasSuccessful(); $output = $this->getLastStdOut(); $data = json_decode($output, true); self::$createdTeams[$this->currentServer][$team] = $data['id']; } #[Given('/^User "([^"]*)" creates team "([^"]*)"$/')] public function createTeamAsUser(string $owner, string $team): void { $this->runOcc(['circles:manage:create', '--type', '1', '--output', 'json', $owner, $team]); $this->theCommandWasSuccessful(); $output = $this->getLastStdOut(); $data = json_decode($output, true); self::$createdTeams[$this->currentServer][$team] = $data['id']; } #[Given('/^team "([^"]*)" is renamed to "([^"]*)"$/')] public function assureTeamRenamed(string $team, string $newName): void { $id = self::$createdTeams[$this->currentServer][$team]; $this->runOcc(['circles:manage:edit', $id, 'displayName', $newName]); $this->theCommandWasSuccessful(); self::$renamedTeams[$this->currentServer][$newName] = $id; } #[Given('/^add user "([^"]*)" to team "([^"]*)"$/')] public function addTeamMember(string $user, string $team): void { $this->runOcc(['circles:members:add', '--type', '1', self::$createdTeams[$this->currentServer][$team], $user]); $this->theCommandWasSuccessful(); } public function deleteTeam(string $team): void { $this->runOcc(['circles:manage:destroy', self::$createdTeams[$this->currentServer][$team]]); $this->theCommandWasSuccessful(); unset(self::$createdTeams[$this->currentServer][$team]); } #[Given('/^user "([^"]*)" is a guest account user/')] public function createGuestUser(string $email): void { $currentUser = $this->setCurrentUser('admin'); // in case it exists $this->deleteUser($email); $lastCode = $this->runOcc([ 'guests:add', // creator user 'admin', // email $email, '--display-name', $email . '-displayname', '--password-from-env', ], [ 'OC_PASS' => self::TEST_PASSWORD, ]); Assert::assertEquals(0, $lastCode, 'Guest creation succeeded for ' . $email); $this->createdGuestAccountUsers[$this->currentServer][$email] = $email; $this->setCurrentUser($currentUser); } private function userExists(string $user): ResponseInterface { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('GET', '/cloud/users/' . $user); $this->setCurrentUser($currentUser); return $this->response; } private function createUser(string $user): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('POST', '/cloud/users', [ 'userid' => $user, 'password' => self::TEST_PASSWORD, ]); $this->assertStatusCode($this->response, 200, 'Failed to create user'); //Quick hack to login once with the current user $this->setCurrentUser($user); $this->sendRequest('GET', '/cloud/users' . '/' . $user); $this->assertStatusCode($this->response, 200, 'Failed to do first login'); $this->createdUsers[$this->currentServer][$user] = $user; $this->setCurrentUser($currentUser); } #[Given('/^user "([^"]*)" is deleted$/')] public function userIsDeleted(string $user): void { $deleted = false; $this->deleteUser($user); $response = $this->userExists($user); $deleted = $response->getStatusCode() === 404; if (!$deleted) { Assert::fail("User $user exists"); } } private function deleteUser(string $user): ResponseInterface { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('DELETE', '/cloud/users/' . $user); $this->setCurrentUser($currentUser); unset($this->createdUsers[$this->currentServer][$user]); return $this->response; } private function deleteGuestUser(string $user): ResponseInterface { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('DELETE', '/cloud/users/' . $user); $this->setCurrentUser($currentUser); unset($this->createdGuestAccountUsers[$this->currentServer][$user]); return $this->response; } private function setUserDisplayName(string $user): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('PUT', '/cloud/users/' . $user, [ 'key' => 'displayname', 'value' => $user . '-displayname' ]); $this->setCurrentUser($currentUser); } private function setUserEmail(string $user): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('PUT', '/cloud/users/' . $user, [ 'key' => 'email', 'value' => $user . '@example.tld' ]); $this->setCurrentUser($currentUser); } #[Given('/^group "([^"]*)" exists$/')] public function assureGroupExists(string $group): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('POST', '/cloud/groups', [ 'groupid' => $group, ]); $jsonBody = json_decode($this->response->getBody()->getContents(), true); if (isset($jsonBody['ocs']['meta'])) { // 102 = group exists // 200 = created with success Assert::assertContains( $jsonBody['ocs']['meta']['statuscode'], [102, 200], $jsonBody['ocs']['meta']['message'] ); } else { throw new \Exception('Invalid response when create group'); } $this->setCurrentUser($currentUser); $this->createdGroups[$this->currentServer][$group] = $group; } #[Given('/^set display name of group "([^"]*)" to "([^"]*)"$/')] public function renameGroup(string $groupId, string $displayName): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('PUT', '/cloud/groups/' . urlencode($groupId), [ 'key' => 'displayname', 'value' => $displayName, ]); $this->assertStatusCode($this->response, 200); $this->setCurrentUser($currentUser); } private function deleteGroup(string $group): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('DELETE', '/cloud/groups/' . $group); $this->setCurrentUser($currentUser); unset($this->createdGroups[$this->currentServer][$group]); $this->setCurrentUser($currentUser); } #[When('/^user "([^"]*)" is member of group "([^"]*)"$/')] public function addingUserToGroup(string $user, string $group): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('POST', "/cloud/users/$user/groups", [ 'groupid' => $group, ]); $this->assertStatusCode($this->response, 200); $this->setCurrentUser($currentUser); } #[When('/^user "([^"]*)" is not member of group "([^"]*)"$/')] public function removeUserFromGroup(string $user, string $group): void { $currentUser = $this->setCurrentUser('admin'); $this->sendRequest('DELETE', "/cloud/users/$user/groups", [ 'groupid' => $group, ]); $this->assertStatusCode($this->response, 200); $this->setCurrentUser($currentUser); } #[Given('/^user "([^"]*)" (delete react|react) with "([^"]*)" on message "([^"]*)" to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userReactWithOnMessageToRoomWith(string $user, string $action, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $token = self::$identifierToToken[$identifier]; $messageId = self::$textToMessageId[$message]; $this->setCurrentUser($user); $verb = $action === 'react' ? 'POST' : 'DELETE'; $this->sendRequest($verb, '/apps/spreed/api/' . $apiVersion . '/reaction/' . $token . '/' . $messageId, [ 'reaction' => $reaction ]); $this->assertStatusCode($this->response, $statusCode); if ($statusCode === 200 || $statusCode === 201) { $this->assertReactionList($formData); } } #[Given('/^user "([^"]*)" retrieve reactions "([^"]*)" of message "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userRetrieveReactionsOfMessageInRoomWith(string $user, string $reaction, string $message, string $identifier, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $message = str_replace('\n', "\n", $message); $token = self::$identifierToToken[$identifier]; $messageId = self::$textToMessageId[$message]; $this->setCurrentUser($user); $reaction = $reaction !== 'all' ? '?reaction=' . $reaction : ''; $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/reaction/' . $token . '/' . $messageId . $reaction); $this->assertStatusCode($this->response, $statusCode); $this->assertReactionList($formData); } private function assertReactionList(?TableNode $formData): void { $contents = $this->response->getBody()->getContents(); $this->assertEmptyArrayIsNotAListButADictionary($formData, $contents); $reactions = $this->getDataFromResponseBody($contents); $expected = []; if (!$formData instanceof TableNode) { return; } foreach ($formData->getHash() as $row) { $reaction = $row['reaction']; unset($row['reaction']); if ($row['actorType'] === 'bots') { $result = preg_match('/BOT\(([^)]+)\)/', $row['actorId'], $matches); if ($result && isset(self::$botNameToHash[$matches[1]])) { $row['actorId'] = 'bot-' . self::$botNameToHash[$matches[1]]; } } $expected[$reaction][] = $row; } $actual = array_map(function ($reaction, $list) use ($expected): array { $list = array_map(function ($reaction) { unset($reaction['timestamp']); $reaction['actorId'] = ($reaction['actorType'] === 'guests') ? self::$sessionIdToUser[$reaction['actorId']] : (string)$reaction['actorId']; if ($reaction['actorType'] === 'federated_users') { $reaction['actorId'] = str_replace(rtrim($this->localServerUrl, '/'), '{$LOCAL_URL}', $reaction['actorId']); $reaction['actorId'] = str_replace(rtrim($this->remoteServerUrl, '/'), '{$REMOTE_URL}', $reaction['actorId']); } return $reaction; }, $list); Assert::assertArrayHasKey($reaction, $expected, 'Not expected reaction: ' . $reaction); Assert::assertCount(count($list), $expected[$reaction], 'Reaction count by type does not match'); usort($expected[$reaction], [self::class, 'sortAttendees']); usort($list, [self::class, 'sortAttendees']); Assert::assertEquals($expected[$reaction], $list, 'Reaction list by type does not match'); return $list; }, array_keys($reactions), array_values($reactions)); Assert::assertCount(count($expected), $actual, 'Reaction count does not match'); } #[Given('/^user "([^"]*)" set the message expiration to ([-\d]+) of room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetTheMessageExpirationToXWithStatusCode(string $user, int $messageExpiration, string $identifier, int $statusCode, string $apiVersion = 'v4'): void { $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/message-expiration', [ 'seconds' => $messageExpiration, ]); $this->assertStatusCode($this->response, $statusCode); } #[Given('/^user "([^"]*)" sets the recording consent to (\d+) for room "([^"]*)" with (\d+) \((v4)\)$/')] public function userSetsTheRecordingConsentToXWithStatusCode(string $user, int $recordingConsent, string $identifier, int $statusCode, string $apiVersion = 'v4'): void { $this->setCurrentUser($user); $this->sendRequest('PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/recording-consent', [ 'recordingConsent' => $recordingConsent, ]); $this->assertStatusCode($this->response, $statusCode); } #[Given('/^aging messages (\d+) hours in room "([^"]*)"$/')] public function occAgeChatMessages(int $hours, string $identifier): void { $this->runOcc(['talk:developer:age-chat-messages', '--hours', $hours, self::$identifierToToken[$identifier]]); $this->theCommandWasSuccessful(); } #[Given('/^the following recording consent is recorded for (room|user) "([^"]*)"$/')] public function occRecordingConsentLists(string $filterType, string $identifier, TableNode $tableNode): void { if ($filterType === 'room') { $filter = ' --token ' . self::$identifierToToken[$identifier]; } else { $filter = ' --actor-type users --actor-id ' . $identifier; } $this->invokingTheCommand('talk:recording:consent --output json' . $filter); $this->theCommandWasSuccessful(); $json = $this->getLastStdOut(); // Replace identifiers with token $expected = array_map(static function (array $data): array { $data['token'] = self::$identifierToToken[$data['token']]; return $data; }, $tableNode->getHash()); // Remove timestamp from output $actual = array_map(static function (array $data): array { Assert::assertIsInt($data['timestamp'], 'Timestamp of recording consent was not an integer'); unset($data['timestamp']); return $data; }, json_decode($json, true, 512, JSON_THROW_ON_ERROR)); Assert::assertEquals($expected, $actual); } #[When('/^wait for ([0-9]+) (second|seconds)$/')] public function waitForXSecond(int $seconds): void { sleep($seconds); } /* * Requests */ #[Given('/^user "([^"]*)" logs in$/')] public function userLogsIn(string $user): void { $loginUrl = $this->baseUrl . '/login'; $cookieJar = $this->getUserCookieJar($user); // Request a new session and extract CSRF token $client = new Client(); $this->response = $client->get( $loginUrl, [ 'cookies' => $cookieJar, ] ); $requestToken = $this->extractRequestTokenFromResponse($this->response); // Login and extract new token $password = ($user === 'admin') ? 'admin' : self::TEST_PASSWORD; $client = new Client(); $this->response = $client->post( $loginUrl, [ 'form_params' => [ 'user' => $user, 'password' => $password, 'requesttoken' => $requestToken, ], 'cookies' => $cookieJar, ] ); $this->assertStatusCode($this->response, 200); } #[When('/^user "([^"]*)" uploads file "([^"]*)" as avatar of room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSendTheFileAsAvatarOfRoom(string $user, string $file, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $options = [ 'multipart' => [ [ 'name' => 'file', 'contents' => $file !== 'invalid' ? fopen(__DIR__ . '/../../../..' . $file, 'r') : '', ], ], ]; $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/avatar', null, [], $options); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" sets emoji "([^"]*)" with color "([^"]*)" as avatar of room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userSetsEmojiAsAvatarOfRoom(string $user, string $emoji, string $color, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $options = [ 'emoji' => $emoji, 'color' => $color, ]; if ($color === 'null') { unset($options['color']); } $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/avatar/emoji', $options); $this->assertStatusCode($this->response, $statusCode); } #[When('/^the room "([^"]*)" has an avatar with (\d+)(?: \((v1)\))?$/')] public function theRoomHasAnAvatarWithStatusCode(string $identifier, int $statusCode, string $apiVersion = 'v1', bool $darkTheme = false): void { $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/avatar' . ($darkTheme ? '/dark' : '')); $this->assertStatusCode($this->response, $statusCode); } #[When('/^the room "([^"]*)" has an svg as (dark avatar|avatar) with (\d+)(?: \((v1)\))?$/')] public function theRoomHasASvgAvatarWithStatusCode(string $identifier, string $darkOrBright, int $statusCode, string $apiVersion = 'v1'): void { $darkTheme = $darkOrBright === 'dark avatar'; $this->theRoomHasNoSvgAvatarWithStatusCode($identifier, $statusCode, $apiVersion, true, $darkTheme); } #[When('/^the room "([^"]*)" has not an svg as avatar with (\d+)(?: \((v1)\))?$/')] public function theRoomHasNoSvgAvatarWithStatusCode(string $identifier, int $statusCode, string $apiVersion = 'v1', bool $expectedToBeSvg = false, bool $darkTheme = false): void { $this->theRoomHasAnAvatarWithStatusCode($identifier, $statusCode, $apiVersion, $darkTheme); $content = $this->response->getBody()->getContents(); try { simplexml_load_string($content); $actualIsSvg = true; } catch (\Throwable $th) { $actualIsSvg = false; } if ($expectedToBeSvg) { Assert::assertEquals($expectedToBeSvg, $actualIsSvg, 'The room avatar needs to be a XML file'); } else { Assert::assertEquals($expectedToBeSvg, $actualIsSvg, 'The room avatar can not be a XML file'); } } #[When('/^the (dark avatar|avatar) svg of room "([^"]*)" (not contains|contains) the string "([^"]*)"(?: \((v1)\))?$/')] public function theAvatarSvgOfRoomContainsTheString(string $darkOrBright, string $identifier, string $contains, string $string, string $apiVersion = 'v1'): void { $darkTheme = $darkOrBright === 'dark avatar'; $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/avatar' . ($darkTheme ? '/dark' : '')); $content = $this->response->getBody()->getContents(); try { simplexml_load_string($content); } catch (\Throwable $th) { throw new Exception('The avatar needs to be a XML'); } if ($contains === 'contains') { Assert::assertStringContainsString($string, $content); } else { Assert::assertStringNotContainsString($string, $content); } } #[When('/^user "([^"]*)" delete the avatar of room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userDeleteTheAvatarOfRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/avatar'); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" starts "(invalid|audio|video)" recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userStartRecordingInRoom(string $user, string $recordingType, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $recordingTypes = [ 'invalid' => -1, 'video' => 1, 'audio' => 2, ]; $data = [ 'status' => $recordingTypes[$recordingType] ]; $this->setCurrentUser($user); $roomToken = self::$identifierToToken[$identifier]; $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/recording/' . $roomToken, $data); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" stops recording in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userStopRecordingInRoom(string $user, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $roomToken = self::$identifierToToken[$identifier]; $this->sendRequest('DELETE', '/apps/spreed/api/' . $apiVersion . '/recording/' . $roomToken); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" store recording file "([^"]*)" in room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userStoreRecordingFileInRoom(string $user, string $file, string $identifier, int $statusCode, string $apiVersion = 'v1'): void { $recordingServerSharedSecret = 'the secret'; $this->setAppConfig('spreed', new TableNode([['recording_servers', json_encode(['secret' => $recordingServerSharedSecret])]])); $validRandom = md5((string)rand()); $validChecksum = hash_hmac('sha256', $validRandom . self::$identifierToToken[$identifier], $recordingServerSharedSecret); $headers = [ 'TALK_RECORDING_RANDOM' => $validRandom, 'TALK_RECORDING_CHECKSUM' => $validChecksum, ]; $options = ['multipart' => []]; if ($user !== 'NULL') { // When exceeding post_max_size, the owner parameter is not sent: // RecordingController::store(): Argument #1 ($owner) must be of type string, null given $options['multipart'][] = ['name' => 'owner', 'contents' => $user]; } if ($file === 'invalid') { // Create invalid content $options['multipart'][] = [ 'name' => 'file', 'contents' => '', ]; } elseif ($file === 'big') { // More details about MAX_FILE_SIZE follow the link: // https://www.php.net/manual/en/features.file-upload.post-method.php $options['multipart'][] = [ 'name' => 'MAX_FILE_SIZE', 'contents' => 1, // Limit the max file size to 1 ]; // Create file with big content $contents = tmpfile(); fwrite($contents, 'fake content'); // Bigger than 1 $options['multipart'][] = [ 'name' => 'file', 'contents' => $contents, 'filename' => 'audio.ogg', // to get the mimetype by extension and do the upload ]; } else { // Upload a file $options['multipart'][] = [ 'name' => 'file', 'contents' => fopen(__DIR__ . '/../../../..' . $file, 'r'), ]; } $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/recording/' . self::$identifierToToken[$identifier] . '/store', null, $headers, $options ); $this->assertStatusCode($this->response, $statusCode); sleep(1); // make sure Postgres manages the order of the messages } #[Then('/^read bot ids from OCC$/')] public function readBotIds(): void { $this->invokingTheCommand('talk:bot:list -v --output json'); $this->theCommandWasSuccessful(); $json = $this->getLastStdOut(); $botData = json_decode($json, true, 512, JSON_THROW_ON_ERROR); foreach ($botData as $bot) { self::$botNameToId[$bot['name']] = $bot['id']; self::$botNameToHash[$bot['name']] = $bot['url_hash']; self::$botIdToName[$bot['id']] = $bot['name']; } } #[Then('/^(setup|remove) bot "([^"]*)" for room "([^"]*)" via OCC$/')] public function setupOrRemoveBotInRoom(string $action, string $botName, string $identifier): void { $this->invokingTheCommand('talk:bot:' . $action . ' ' . self::$botNameToId[$botName] . ' ' . self::$identifierToToken[$identifier]); $this->theCommandWasSuccessful(); } #[Then('/^set state (enabled|disabled|no-setup) for bot "([^"]*)" via OCC$/')] public function stateUpdateForBot(string $state, string $botName, ?TableNode $body = null): void { if ($state === 'enabled') { $state = 1; } elseif ($state === 'disabled') { $state = 0; } elseif ($state === 'no-setup') { $state = 2; } $features = ''; if ($body) { $features = array_map(static fn ($map) => $map['feature'], $body->getColumnsHash()); $features = ' -f ' . implode(' -f ', $features); } $this->invokingTheCommand('talk:bot:state ' . self::$botNameToId[$botName] . ' ' . $state . $features); $this->theCommandWasSuccessful(); } #[Then('/^Bot "([^"]*)" (sends|removes) a (message|reaction) for room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function botSendsRequest(string $botName, string $sends, string $action, string $identifier, int $status, string $apiVersion, TableNode $body): void { $currentUser = $this->setCurrentUser(null); $data = $body->getRowsHash(); $secret = $data['secret']; unset($data['secret']); if ($action === 'message') { $url = '/message'; $toSign = $data['message']; if (isset($data['replyTo'])) { $data['replyTo'] = self::$textToMessageId[$data['replyTo']]; } } else { $url = '/reaction/' . self::$textToMessageId[$data['messageId']]; unset($data['messageId']); $toSign = $data['reaction']; } $random = bin2hex(random_bytes(32)); $hash = hash_hmac('sha256', $random . $toSign, $secret); $headers = [ 'X-Nextcloud-Talk-Bot-Random' => $random, 'X-Nextcloud-Talk-Bot-Signature' => $hash, ]; $this->sendRequest( $sends === 'sends' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/bot/' . self::$identifierToToken[$identifier] . $url, $data, $headers ); $this->assertStatusCode($this->response, $status); $this->setCurrentUser($currentUser); } #[Then('/^user "([^"]*)" (sets up|removes) bot "([^"]*)" for room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function setupOrRemoveBotViaOCSAPI(string $user, string $action, string $botName, string $identifier, int $status, string $apiVersion): void { $this->setCurrentUser($user); $this->sendRequest( $action === 'sets up' ? 'POST' : 'DELETE', '/apps/spreed/api/' . $apiVersion . '/bot/' . self::$identifierToToken[$identifier] . '/' . self::$botNameToId[$botName] ); $this->assertStatusCode($this->response, $status); } #[Then('/^user "([^"]*)" shares file from the (first|last) notification to room "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function userShareLastNotificationFile(string $user, string $firstLast, string $identifier, int $status, string $apiVersion): void { $this->setCurrentUser($user); if (empty(self::$lastNotifications)) { throw new \RuntimeException('No notification data loaded, call userNotifications() before'); } if ($firstLast === 'last') { $lastNotification = end(self::$lastNotifications); } else { $lastNotification = reset(self::$lastNotifications); } $data = [ 'fileId' => $lastNotification['messageRichParameters']['file']['id'], 'timestamp' => (new \DateTime($lastNotification['datetime']))->getTimestamp(), ]; $this->sendRequest( 'POST', '/apps/spreed/api/' . $apiVersion . '/recording/' . self::$identifierToToken[$identifier] . '/share-chat', $data ); $this->assertStatusCode($this->response, $status); } #[When('/^(force run|run|repeating run) "([^"]*)" background jobs$/')] public function runReminderBackgroundJobs(string $useForce, string $class, bool $repeated = false): void { $this->runOcc(['background-job:list', '--output=json_pretty', '--class=' . $class]); try { $list = json_decode($this->lastStdOut, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { var_dump('Output'); var_dump($this->lastStdOut); var_dump('Error'); var_dump($this->lastStdErr); throw $e; } if ($repeated && empty($list)) { return; } Assert::assertNotEmpty($list, 'List of ' . $class . ' should not be empty'); foreach ($list as $job) { if ($useForce === 'force run') { $this->runOcc(['background-job:execute', (string)$job['id'], '--force-execute']); } else { $this->runOcc(['background-job:execute', (string)$job['id']]); } if ($this->lastStdErr) { throw new \RuntimeException($this->lastStdErr); } } if ($useForce === 'repeating run') { $this->runReminderBackgroundJobs($useForce, $class, true); } } #[When('/^user "([^"]*)" sets? status to "([^"]*)" with (\d+)(?: \((v1)\))?$/')] public function setUserStatus(string $user, string $status, int $statusCode, string $apiVersion = 'v1'): void { $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/user_status/api/' . $apiVersion . '/user_status/status', new TableNode([['statusType', $status]]) ); $this->assertStatusCode($this->response, $statusCode); } #[Then('/^client "([^"]*)" requests room list with (\d+) \((v4)\)$/')] public function getRoomListWithSpecificUserAgent(string $userAgent, int $statusCode, string $apiVersion): void { $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room', null, [ 'USER_AGENT' => $userAgent, ]); $this->assertStatusCode($this->response, $statusCode); } protected function assertEmptyArrayIsNotAListButADictionary(?TableNode $formData, string $content): void { if (!$formData instanceof TableNode || empty($formData->getHash())) { $data = json_decode($content); Assert::assertIsNotArray($data->ocs->data, 'Response ocs.data should be an "object" to represent a JSON dictionary, not a list-array'); } } #[Then('the response error matches with :error')] public function assertResponseErrorMatchesWith(string $error): void { $responseData = $this->getDataFromResponse($this->response); Assert::assertEquals(['error' => $error], $responseData); } private function extractRequestTokenFromResponse(ResponseInterface $response): string { return substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89); } #[When('/^last response body (contains|does not contain|starts with|starts not with|ends with|ends not with) "([^"]*)"(| with newlines)$/')] public function lastResponseBodyContains(string $comparison, string $needle, string $replaceNWithNewlines): void { if ($replaceNWithNewlines) { $needle = str_replace('\n', "\n", $needle); } if ($comparison === 'contains') { Assert::assertStringContainsString($needle, $this->response->getBody()->getContents()); } elseif ($comparison === 'does not contain') { Assert::assertStringNotContainsString($needle, $this->response->getBody()->getContents()); } elseif ($comparison === 'starts with') { Assert::assertStringStartsWith($needle, $this->response->getBody()->getContents()); } elseif ($comparison === 'starts not with') { Assert::assertStringStartsNotWith($needle, $this->response->getBody()->getContents()); } elseif ($comparison === 'ends with') { Assert::assertStringEndsWith($needle, $this->response->getBody()->getContents()); } elseif ($comparison === 'ends not with') { Assert::assertStringEndsNotWith($needle, $this->response->getBody()->getContents()); } } public function sendFrontpageRequest(string $verb, string $url, TableNode|array|null $body = null, array $headers = [], array $options = []): void { $fullUrl = $this->baseUrl . 'index.php' . $url; $this->sendRequestFullUrl($verb, $fullUrl, $body, $headers, $options); } #[When('/^sending "([^"]*)" to "([^"]*)" with$/')] public function sendRequest(string $verb, string $url, TableNode|array|null $body = null, array $headers = [], array $options = []): void { $fullUrl = $this->baseUrl . 'ocs/v2.php' . $url; $this->sendRequestFullUrl($verb, $fullUrl, $body, $headers, $options); } #[When('/^sending "([^"]*)" to "([^"]*)" for xml with$/')] public function sendXMLRequest(string $verb, string $url, TableNode|array|null $body = null, array $headers = [], array $options = []): void { $fullUrl = $this->baseUrl . 'ocs/v2.php' . $url; $headers = array_merge([ 'Accept' => 'application/xml', ], $headers); $this->sendRequestFullUrl($verb, $fullUrl, $body, $headers, $options); } #[When('/^user "([^"]*)" sets mention permissions for room "([^"]*)" to (all|moderators) with (\d+) \((v4)\)$/')] public function userSetsMentionPermissionsOfTheRoom(string $user, string $identifier, string $mentionPermissions, int $statusCode, string $apiVersion): void { $intMentionPermissions = 0; // all - default if ($mentionPermissions === 'moderators') { $intMentionPermissions = 1; } $this->setCurrentUser($user); $this->sendRequest( 'PUT', '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/mention-permissions', new TableNode([ ['mentionPermissions', $intMentionPermissions], ]) ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" (unarchives|archives) room "([^"]*)" with (\d+) \((v4)\)$/')] public function userArchivesConversation(string $user, string $action, string $identifier, int $statusCode, string $apiVersion): void { $httpMethod = 'POST'; if ($action === 'unarchives') { $httpMethod = 'DELETE'; } $this->setCurrentUser($user); $this->sendRequest( $httpMethod, '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/archive', ); $this->assertStatusCode($this->response, $statusCode); } #[When('/^user "([^"]*)" marks room "([^"]*)" as (sensitive|insensitive) with (\d+) \((v4)\)$/')] public function userMarksConversationSensitive(string $user, string $identifier, string $action, int $statusCode, string $apiVersion): void { $httpMethod = 'POST'; if ($action === 'insensitive') { $httpMethod = 'DELETE'; } $this->setCurrentUser($user); $this->sendRequest( $httpMethod, '/apps/spreed/api/' . $apiVersion . '/room/' . self::$identifierToToken[$identifier] . '/sensitive', ); $this->assertStatusCode($this->response, $statusCode); } public function sendRequestFullUrl(string $verb, string $fullUrl, TableNode|array|string|null $body = null, array $headers = [], array $options = []): void { $client = new Client(); $options = array_merge($options, ['cookies' => $this->getUserCookieJar($this->currentUser)]); if ($this->currentUser === 'admin') { $options['auth'] = ['admin', 'admin']; } elseif ($this->currentUser !== null && !str_starts_with($this->currentUser, 'guest')) { $options['auth'] = [$this->currentUser, self::TEST_PASSWORD]; } if ($body instanceof TableNode) { $fd = $body->getRowsHash(); $options['form_params'] = $fd; } elseif (is_array($body)) { $options['form_params'] = $body; } elseif (is_string($body)) { $options['body'] = $body; } $options['headers'] = array_merge([ 'OCS-ApiRequest' => 'true', 'Accept' => 'application/json', ], $headers); try { $this->response = $client->{$verb}($fullUrl, $options); } catch (ClientException $ex) { $this->response = $ex->getResponse(); } catch (\GuzzleHttp\Exception\ServerException $ex) { $this->response = $ex->getResponse(); } } protected function getUserCookieJar($user): CookieJar { if (!isset($this->cookieJars[$user])) { $this->cookieJars[$user] = new CookieJar(); } return $this->cookieJars[$user]; } protected function assertStatusCode(ResponseInterface $response, int $statusCode, string $message = ''): void { if ($statusCode !== $response->getStatusCode()) { $content = $this->response->getBody()->getContents(); Assert::assertEquals( $statusCode, $response->getStatusCode(), $message . ($message ? ': ' : '') . $content ); } else { Assert::assertEquals($statusCode, $response->getStatusCode(), $message); } } #[Given('/^age room "([^"]+)" (\d+) (hours|days)$/')] public function ageRoomForRetentionAndExpiration(string $identifier, int $time, string $unit): void { $this->setCurrentUser('admin'); if ($unit === 'days') { $time *= 24; } $this->sendRequest('POST', '/apps/spreedcheats/age', [ 'token' => self::$identifierToToken[$identifier], 'hours' => $time, ]); var_dump($this->response->getBody()->getContents()); $this->assertStatusCode($this->response, 200); } #[Given('/^user "([^"]*)" creates calendar events for a room "([^"]*)" \((v4)\)$/')] public function createCalendarEntriesWithRoom(string $user, string $identifier, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $body = $formData->getRowsHash(); $body['roomName'] = $identifier; if (!isset(self::$tokenToIdentifier[$identifier])) { $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/room', $body); $this->assertStatusCode($this->response, 201); $response = $this->getDataFromResponse($this->response); self::$identifierToToken[$identifier] = $response['token']; self::$identifierToId[$identifier] = $response['id']; self::$tokenToIdentifier[$response['token']] = $identifier; } $location = self::getRoomLocationForToken($identifier); $this->sendRequest('POST', '/apps/spreedcheats/dashboardEvents', [ 'name' => $identifier, 'location' => $location, ]); $this->assertStatusCode($this->response, 200); } #[Given('/^user "([^"]*)" creates calendar events inviting user "([^"]*)" \((v4)\)$/')] public function createEventsForOneToOne(string $user, string $participant, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('POST', '/apps/spreedcheats/mutualEvents', [ 'organizer' => $user, 'attendee' => $participant, ]); $this->assertStatusCode($this->response, 200); } #[Then('/^user "([^"]*)" sees the following entry when loading the dashboard conversations \((v4)\)$/')] public function userGetsEventConversationsForTalkDashboard(string $user, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/dashboard/events'); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); if (!$formData instanceof TableNode) { Assert::assertEmpty($data); return; } $this->assertDashboardData($data, $formData); } #[Then('/^user "([^"]*)" sees the following entry when loading mutual events in room "([^"]*)" \((v4)\)$/')] public function userGetsMutualEventConversations(string $user, string $identifier, string $apiVersion, ?TableNode $formData = null): void { $this->setCurrentUser($user); $token = self::$identifierToToken[$identifier]; $this->sendRequest('GET', '/apps/spreed/api/' . $apiVersion . '/room/' . $token . '/mutual-events'); $this->assertStatusCode($this->response, 200); $data = $this->getDataFromResponse($this->response); if (!$formData instanceof TableNode) { Assert::assertEmpty($data); return; } $this->assertDashboardData($data, $formData); } /** * @param array $dashboardEvents * @param TableNode $formData */ private function assertDashboardData(array $dashboardEvents, TableNode $formData) : void { Assert::assertCount(count($formData->getHash()), $dashboardEvents, 'Event count does not match'); $expected = $formData->getHash(); if (empty($expected)) { return; } $missingKeys = array_diff(array_keys($dashboardEvents[0]), array_keys($expected[0])); Assert::assertEquals(array_map(function ($event) { foreach ($event as $key => $value) { if ($value === 'null') { $event[$key] = null; } } $event['roomType'] = (int)$event['roomType']; $event['eventAttachments'] = (int)$event['eventAttachments']; $event['calendars'] = (int)$event['calendars']; return $event; }, $expected), array_map(static function (array $event) use ($missingKeys): array { $data = $event; foreach ($missingKeys as $key) { unset($data[$key]); } $data['eventAttachments'] = count($event['eventAttachments']); $data['calendars'] = count($event['calendars']); return $data; }, $dashboardEvents)); } }