spreed/tests/integration/features/bootstrap/FeatureContext.php
Anna Larch ef3265c9c7 feat: scheduled message API
Signed-off-by: Anna Larch <anna@nextcloud.com>
2025-12-09 12:54:38 +01:00

4993 lines
196 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017-2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
require __DIR__ . '/../../vendor/autoload.php';
use Behat\Behat\Context\Context;
use Behat\Behat\Context\SnippetAcceptingContext;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\TableNode;
use Behat\Hook\AfterScenario;
use Behat\Hook\BeforeScenario;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\ClientException;
use PHPUnit\Framework\Assert;
use Psr\Http\Message\ResponseInterface;
/**
* Defines application features from the specific context.
*/
class FeatureContext implements Context, SnippetAcceptingContext {
public const TEST_PASSWORD = '123456';
/** @var array<string, string> */
protected static array $identifierToToken;
/** @var array<string, int> */
protected static array $identifierToId;
/** @var array<string, string> */
protected static array $tokenToIdentifier;
/** @var array<string, string> */
protected static array $sessionIdToUser;
/** @var array<string, string> */
protected static array $sessionNameToActorId;
/** @var array<string, string> */
protected static array $userToSessionId;
/** @var array<string, int> */
protected static array $userToAttendeeId;
/** @var array<string, int> */
protected static array $textToMessageId;
/** @var array<string, int> */
protected static array $titleToThreadId;
/** @var array<int, string> */
protected static array $messageIdToText;
/** @var array<int, string> */
protected static array $threadIdToTitle;
/** @var array<string, int> */
protected static array $aiTaskIds;
/** @var array<string, int> */
protected static array $remoteToInviteId;
/** @var array<int, string> */
protected static array $inviteIdToRemote;
/** @var array<string, string> */
protected static array $remoteAuth;
/** @var array<string, int> */
protected static array $questionToPollId;
/** @var array[] */
protected static array $lastNotifications;
/** @var array<int, string> */
protected static array $botIdToName;
/** @var array<string, int> */
protected static array $botNameToId;
/** @var array<string, string> */
protected static array $botNameToHash;
/** @var array<string, string> */
protected static array $phoneNumberToActorId;
/** @var array<string, mixed>|null */
protected static ?array $nextChatRequestParameters = null;
/** @var array<string, int> */
protected static array $modifiedSince;
protected static array $createdTeams = [];
protected static array $renamedTeams = [];
/** @var array<string, int> */
protected static array $userToBanId;
protected static ?string $queryLogFile = null;
protected static ?string $currentScenario = null;
/** @var array<string, string> */
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 = '<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
. ' <d:prop>'
. ' <oc:fileid/>'
. ' </d:prop>'
. '</d:propfind>';
$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('/(?<version>\?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<name>\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));
}
}