Refactor presence API methods for clarity and consistency. Updated method names to include 'Presence' suffix for better identification. Enhanced presence state logic to support unique index-based upserts and improved test coverage for presence functionalities, including custom permissions and expiry handling.

This commit is contained in:
ArnabChatterjee20k
2026-04-29 13:49:57 +05:30
parent 496b91480b
commit e624040e57
9 changed files with 334 additions and 29 deletions
+4 -4
View File
@@ -66,6 +66,9 @@ class PresenceState
try {
if ($this->getSupportForUniqueIndexBasedUpsert()) {
$presenceCreated = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])])->isEmpty();
$presence = $dbForProject->upsertDocument('presenceLogs', $presenceDocument);
} else {
$presence = $this->transactionalUpsertForUser(
$dbForProject,
$presenceDocument,
@@ -73,9 +76,6 @@ class PresenceState
$userId,
$presenceCreated
);
} else {
$presenceCreated = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])])->isEmpty();
$presence = $dbForProject->upsertDocument('presenceLogs', $presenceDocument);
}
if ($presenceCreated && $onPresenceCreated !== null) {
@@ -129,7 +129,7 @@ class PresenceState
private function getSupportForUniqueIndexBasedUpsert(): bool
{
$adapter = \strtolower(System::getEnv('_APP_DB_ADAPTER', 'mariadb'));
return \in_array($adapter, ['mongodb', 'postgres', 'postgresql'], true);
return !\in_array($adapter, ['mongodb', 'postgres', 'postgresql'], true);
}
private function assertPermissionsAgainstAuthorization(array $permissions, Authorization $authorization): void
@@ -41,7 +41,7 @@ class Delete extends Base
->label('sdk', new Method(
namespace: 'presences',
group: 'presences',
name: 'delete',
name: 'deletePresence',
description: 'Delete a presence log by its unique ID.',
auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
@@ -33,7 +33,7 @@ class Get extends Base
->label('sdk', new Method(
namespace: 'presences',
group: 'presences',
name: 'get',
name: 'getPresence',
description: 'Get a presence log by its unique ID.',
auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
@@ -45,7 +45,7 @@ class Update extends PresenceAction
->label('sdk', new Method(
namespace: 'presences',
group: 'presences',
name: 'update',
name: 'updatePresence',
description: 'Update a presence log by its unique ID.',
auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
@@ -49,7 +49,7 @@ class Upsert extends PresenceAction
->label('sdk', new Method(
namespace: 'presences',
group: 'presences',
name: 'upsert',
name: 'upsertPresence',
description: 'Create or update a presence log by its unique ID.',
auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
@@ -43,7 +43,7 @@ class XList extends Base
->label('sdk', new Method(
namespace: 'presences',
group: 'presences',
name: 'list',
name: 'listPresences',
description: 'List presence logs.',
auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT],
responses: [
+298 -15
View File
@@ -4,6 +4,8 @@ namespace Tests\E2E\Services\Presence;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
trait PresenceBase
@@ -27,6 +29,19 @@ trait PresenceBase
return self::$presenceApiKeyCache[$projectId];
}
/**
* Server-side helper: ensure presences requests use a presence-scoped API key.
*/
protected function getPresenceServerHeaders(): array
{
$headers = $this->getHeaders(false);
// Override the project API key added by `SideServer` with a presence-scoped key.
$headers['x-appwrite-key'] = $this->getPresenceApiKey();
return $headers;
}
protected function setupPresence(array $overrides = []): array
{
$projectId = $this->getProject()['$id'];
@@ -93,6 +108,8 @@ trait PresenceBase
public function testUpsertAndGetPresence(): void
{
if ($this->getSide() === 'client') {
$userId = $this->getUser()['$id'];
$upsert = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
@@ -106,7 +123,24 @@ trait PresenceBase
]
);
$this->assertEquals(401, $upsert['headers']['status-code']);
$this->assertEquals(200, $upsert['headers']['status-code']);
$this->assertNotEmpty($upsert['body']['$id']);
$this->assertEquals($userId, $upsert['body']['userId']);
$get = $this->client->call(
Client::METHOD_GET,
'/presences/' . $upsert['body']['$id'],
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false))
);
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertEquals($upsert['body']['$id'], $get['body']['$id']);
$this->assertEquals($userId, $get['body']['userId']);
$this->assertArrayHasKey('expiry', $get['body']);
return;
}
@@ -118,7 +152,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false))
], $this->getPresenceServerHeaders())
);
$this->assertEquals(200, $get['headers']['status-code']);
@@ -130,16 +164,78 @@ trait PresenceBase
public function testListPresences(): void
{
if ($this->getSide() === 'client') {
$upsert = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
[
'status' => 'online',
'metadata' => ['device' => 'web'],
]
);
$this->assertEquals(200, $upsert['headers']['status-code']);
$this->assertNotEmpty($upsert['body']['$id']);
$this->assertArrayHasKey('userId', $upsert['body']);
$list = $this->client->call(
Client::METHOD_GET,
'/presences',
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false))
], $this->getHeaders(false)),
[
'queries' => [
Query::equal('userId', [$upsert['body']['userId']])->toString(),
],
]
);
$this->assertEquals(401, $list['headers']['status-code']);
$this->assertEquals(200, $list['headers']['status-code']);
$this->assertArrayHasKey('total', $list['body']);
$this->assertArrayHasKey('presences', $list['body']);
$this->assertIsArray($list['body']['presences']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
// Client sessions must not be able to list presences belonging to a different user.
$projectId = $this->getProject()['$id'];
$originalUser = $this->getUser();
$otherUserId = $this->getUser(true)['$id'];
// Important: don't let `getUser(true)` overwrite the cached user/session for the rest
// of this test run. We only need the other user's ID.
self::$user[$projectId] = $originalUser;
// Seed another presence for the other user (setup via API key, not the client session).
$this->setupPresence([
'userId' => $otherUserId,
'status' => 'online',
'metadata' => ['device' => 'other-user'],
]);
$otherList = $this->client->call(
Client::METHOD_GET,
'/presences',
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
[
'queries' => [
Query::equal('userId', [$otherUserId])->toString(),
],
]
);
$this->assertEquals(200, $otherList['headers']['status-code']);
$this->assertArrayHasKey('total', $otherList['body']);
$this->assertArrayHasKey('presences', $otherList['body']);
$this->assertSame([], $otherList['body']['presences']);
$this->assertEquals(0, $otherList['body']['total']);
return;
}
@@ -151,7 +247,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
], $this->getPresenceServerHeaders()),
[
'queries' => [
Query::equal('userId', [$presence['userId']])->toString(),
@@ -166,12 +262,179 @@ trait PresenceBase
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
}
public function testClientPresenceCustomPermissionsForOtherUser(): void
{
if ($this->getSide() !== 'client') {
$this->expectNotToPerformAssertions();
return;
}
$projectId = $this->getProject()['$id'];
$user1 = $this->getUser();
$headersUser1 = $this->getHeaders(false);
$user2 = $this->getUser(true);
// Avoid overwriting the cached user for the rest of the test run.
self::$user[$projectId] = $user1;
$headersUser2 = $this->getHeaders(false);
$headersUser2['cookie'] = 'a_session_' . $projectId . '=' . $user2['session'];
$permissionsForUser2 = [
Permission::read(Role::user($user2['$id'])),
Permission::update(Role::user($user2['$id'])),
Permission::delete(Role::user($user2['$id'])),
Permission::write(Role::user($user2['$id'])),
];
$permissionsForUser1 = [
Permission::read(Role::user($user1['$id'])),
Permission::update(Role::user($user1['$id'])),
Permission::delete(Role::user($user1['$id'])),
Permission::write(Role::user($user1['$id'])),
];
// Create a presence for user1 using a presence-scoped API key so we can set ACLs.
$presenceAllow = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getPresenceApiKey(),
]),
[
'userId' => $user1['$id'],
'status' => 'online',
'metadata' => ['case' => 'allow'],
// Owner always retains full permissions; user2 additionally gets access.
'permissions' => \array_merge($permissionsForUser1, $permissionsForUser2),
]
);
$this->assertEquals(200, $presenceAllow['headers']['status-code']);
$presenceIdAllow = $presenceAllow['body']['$id'];
// user2 can read
$get = $this->client->call(
Client::METHOD_GET,
'/presences/' . $presenceIdAllow,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2)
);
$this->assertEquals(200, $get['headers']['status-code']);
// user2 can update
$patch = $this->client->call(
Client::METHOD_PATCH,
'/presences/' . $presenceIdAllow,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2),
[
'status' => 'busy',
'metadata' => ['case' => 'allow-update'],
]
);
$this->assertEquals(200, $patch['headers']['status-code']);
$this->assertEquals('busy', $patch['body']['status']);
// user2 can delete
$delete = $this->client->call(
Client::METHOD_DELETE,
'/presences/' . $presenceIdAllow,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2)
);
$this->assertEquals(204, $delete['headers']['status-code']);
// Create another presence for user1 without granting any special permissions to user2.
$presenceDeny = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $this->getPresenceApiKey(),
]),
[
'userId' => $user1['$id'],
'status' => 'online',
'metadata' => ['case' => 'deny'],
// Only the owner has permissions; user2 should not be able to access this document.
'permissions' => $permissionsForUser1,
]
);
$this->assertEquals(200, $presenceDeny['headers']['status-code']);
$presenceIdDeny = $presenceDeny['body']['$id'];
// user2 cannot read
$getDeny = $this->client->call(
Client::METHOD_GET,
'/presences/' . $presenceIdDeny,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2)
);
// When read permission is missing, the document should be treated as not found.
$this->assertEquals(404, $getDeny['headers']['status-code']);
// user2 cannot update
$patchDeny = $this->client->call(
Client::METHOD_PATCH,
'/presences/' . $presenceIdDeny,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2),
[
'status' => 'busy',
]
);
$this->assertEquals(404, $patchDeny['headers']['status-code']);
// user2 cannot delete
$deleteDeny = $this->client->call(
Client::METHOD_DELETE,
'/presences/' . $presenceIdDeny,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headersUser2)
);
$this->assertEquals(404, $deleteDeny['headers']['status-code']);
}
public function testUpdatePresenceSparseFields(): void
{
if ($this->getSide() === 'client') {
$upsert = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
[
'status' => 'away',
'metadata' => ['source' => 'setup'],
]
);
$this->assertEquals(200, $upsert['headers']['status-code']);
$presenceId = $upsert['body']['$id'];
$update = $this->client->call(
Client::METHOD_PATCH,
'/presences/' . ID::unique(),
'/presences/' . $presenceId,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
@@ -182,7 +445,10 @@ trait PresenceBase
]
);
$this->assertEquals(401, $update['headers']['status-code']);
$this->assertEquals(200, $update['headers']['status-code']);
$this->assertEquals('busy', $update['body']['status']);
$this->assertEquals(['source' => 'update'], $update['body']['metadata']);
return;
}
@@ -206,7 +472,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
], $this->getPresenceServerHeaders()),
$payload
);
@@ -218,16 +484,33 @@ trait PresenceBase
public function testDeletePresence(): void
{
if ($this->getSide() === 'client') {
$upsert = $this->client->call(
Client::METHOD_PUT,
'/presences/' . ID::unique(),
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
[
'status' => 'temp-delete',
'metadata' => ['cleanup' => true],
]
);
$this->assertEquals(200, $upsert['headers']['status-code']);
$presenceId = $upsert['body']['$id'];
$delete = $this->client->call(
Client::METHOD_DELETE,
'/presences/' . ID::unique(),
'/presences/' . $presenceId,
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false))
);
$this->assertEquals(401, $delete['headers']['status-code']);
$this->assertEquals(204, $delete['headers']['status-code']);
return;
}
@@ -242,7 +525,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false))
], $this->getPresenceServerHeaders())
);
$this->assertEquals(204, $delete['headers']['status-code']);
@@ -263,7 +546,7 @@ trait PresenceBase
]
);
$this->assertEquals(401, $response['headers']['status-code']);
$this->assertEquals(404, $response['headers']['status-code']);
return;
}
@@ -281,7 +564,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
], $this->getPresenceServerHeaders()),
$payload
);
@@ -324,7 +607,7 @@ trait PresenceBase
\array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders(false)),
], $this->getPresenceServerHeaders()),
[
'status' => 'online',
]
@@ -345,7 +628,7 @@ trait PresenceBase
$headers = \array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders(false));
], $this->getPresenceServerHeaders());
$firstUpsert = $this->client->call(
Client::METHOD_PUT,
@@ -7,6 +7,7 @@ use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Console;
use Utopia\Database\DateTime;
use Utopia\Database\Helpers\ID;
class PresenceExpiryTest extends Scope
@@ -25,8 +26,8 @@ class PresenceExpiryTest extends Scope
}
self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
'users.read',
'users.write',
'presence.read',
'presence.write',
]);
return self::$presenceApiKeyCache[$projectId];
@@ -36,7 +37,8 @@ class PresenceExpiryTest extends Scope
{
$projectId = $this->getProject()['$id'];
$userId = $this->getUser()['$id'];
$expiredAt = \gmdate('Y-m-d\TH:i:s.v\Z', \time() - 120);
// Must match the format used by the maintenance worker query.
$expiredAt = DateTime::format((new \DateTime())->modify('-120 seconds'));
$createServer = $this->client->call(
Client::METHOD_PUT,
@@ -81,6 +83,7 @@ class PresenceExpiryTest extends Scope
$code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr);
$this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)");
// Maintenance + delete workers are asynchronous; give extra time to observe cleanup.
$this->assertEventually(function () use ($presenceIdServer, $projectId) {
$getServer = $this->client->call(
Client::METHOD_GET,
@@ -93,6 +96,6 @@ class PresenceExpiryTest extends Scope
);
$this->assertEquals(404, $getServer['headers']['status-code']);
});
}, 30000, 1000);
}
}
@@ -18,6 +18,8 @@ class PresenceRealtimeClientTest extends Scope
use ProjectCustom;
use SideClient;
private static array $presenceApiKeyCache = [];
private function bootstrapIsolatedProject(): array
{
$project = $this->getProject(true);
@@ -37,10 +39,27 @@ class PresenceRealtimeClientTest extends Scope
return [
'content-type' => 'application/json',
'x-appwrite-project' => $project['$id'],
'x-appwrite-key' => $project['apiKey'],
'x-appwrite-key' => $this->getPresenceApiKey($project),
];
}
private function getPresenceApiKey(array $project): string
{
$projectId = $project['$id'];
if (!empty(self::$presenceApiKeyCache[$projectId])) {
return self::$presenceApiKeyCache[$projectId];
}
// Realtime tests validate HTTP reads of presences; those endpoints require `presence.read`.
self::$presenceApiKeyCache[$projectId] = $this->getNewKey([
'presence.read',
'presence.write',
]);
return self::$presenceApiKeyCache[$projectId];
}
private function connectRealtimeAndSubscribe(
array $project,
array $headers,