From ab961597c42c4a5619f62ea64aab08d0df7592ee Mon Sep 17 00:00:00 2001
From: snyk-bot
Date: Sun, 28 Nov 2021 07:36:40 +0000
Subject: [PATCH 01/17] fix: upgrade chart.js from 3.5.1 to 3.6.0
Snyk has created this PR to upgrade chart.js from 3.5.1 to 3.6.0.
See this package in npm:
https://www.npmjs.com/package/chart.js
See this project in Snyk:
https://app.snyk.io/org/eldadfux/project/8574b5e4-6e89-4ade-bc02-2eaabc43eed0?utm_source=github&utm_medium=referral&page=upgrade-pr
---
package-lock.json | 14 +++++++-------
package.json | 2 +-
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 9d83c0ded3..27f8f09162 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.1.0",
"license": "BSD-3-Clause",
"dependencies": {
- "chart.js": "^3.5.1",
+ "chart.js": "^3.6.0",
"markdown-it": "^12.2.0",
"pell": "^1.0.6",
"prismjs": "^1.25.0",
@@ -549,9 +549,9 @@
}
},
"node_modules/chart.js": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
- "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.6.0.tgz",
+ "integrity": "sha512-iOzzDKePL+bj+ccIsVAgWQehCXv8xOKGbaU2fO/myivH736zcx535PGJzQGanvcSGVOqX6yuLZsN3ygcQ35UgQ=="
},
"node_modules/chokidar": {
"version": "2.1.8",
@@ -5484,9 +5484,9 @@
"dev": true
},
"chart.js": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
- "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.6.0.tgz",
+ "integrity": "sha512-iOzzDKePL+bj+ccIsVAgWQehCXv8xOKGbaU2fO/myivH736zcx535PGJzQGanvcSGVOqX6yuLZsN3ygcQ35UgQ=="
},
"chokidar": {
"version": "2.1.8",
diff --git a/package.json b/package.json
index 6c71f23a67..135e4ff8a7 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"gulp-less": "^5.0.0"
},
"dependencies": {
- "chart.js": "^3.5.1",
+ "chart.js": "^3.6.0",
"markdown-it": "^12.2.0",
"pell": "^1.0.6",
"prismjs": "^1.25.0",
From e944da2f3e08e2f6b864a0538a9cf2a980d8c02b Mon Sep 17 00:00:00 2001
From: Torsten Dittmann
Date: Mon, 6 Dec 2021 13:03:12 +0100
Subject: [PATCH 02/17] feat(realtime): add console channel
---
app/controllers/shared/api.php | 8 +-
app/init.php | 40 -
app/workers/database.php | 72 +-
public/dist/scripts/app-all.js | 3 +-
public/dist/scripts/app.js | 3 +-
public/scripts/init.js | 23 +-
src/Appwrite/Messaging/Adapter/Realtime.php | 18 +-
tests/e2e/Services/Realtime/RealtimeBase.php | 1041 +----------------
.../Realtime/RealtimeConsoleClientTest.php | 276 +++++
.../Realtime/RealtimeCustomClientTest.php | 1037 ++++++++++++++++
10 files changed, 1434 insertions(+), 1087 deletions(-)
create mode 100644 tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index 210cb46912..3bd1aba579 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -166,7 +166,7 @@ App::init(function ($utopia, $request, $project) {
throw new Exception('JWT authentication is disabled for this project', 501);
}
break;
-
+
default:
throw new Exception('Unsupported authentication route');
break;
@@ -187,7 +187,7 @@ App::shutdown(function ($utopia, $request, $response, $project, $events, $audits
/** @var bool $mode */
if (!empty($events->getParam('event'))) {
- if(empty($events->getParam('eventData'))) {
+ if (empty($events->getParam('eventData'))) {
$events->setParam('eventData', $response->getPayload());
}
@@ -206,10 +206,10 @@ App::shutdown(function ($utopia, $request, $response, $project, $events, $audits
if ($project->getId() !== 'console') {
$payload = new Document($response->getPayload());
- $target = Realtime::fromPayload($events->getParam('event'), $payload);
+ $target = Realtime::fromPayload($events->getParam('event'), $payload, $project);
Realtime::send(
- $project->getId(),
+ $target['projectId'] ?? $project->getId(),
$response->getPayload(),
$events->getParam('event'),
$target['channels'],
diff --git a/app/init.php b/app/init.php
index 6992728987..0c7c9e55fc 100644
--- a/app/init.php
+++ b/app/init.php
@@ -21,9 +21,6 @@ use Appwrite\Extend\PDO;
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Auth;
-use Appwrite\Database\Database as DatabaseOld;
-use Appwrite\Database\Adapter\MySQL as MySQLAdapter;
-use Appwrite\Database\Adapter\Redis as RedisAdapter;
use Appwrite\Event\Event;
use Appwrite\Network\Validator\Email;
use Appwrite\Network\Validator\IP;
@@ -156,43 +153,6 @@ if(!empty($user) || !empty($pass)) {
Resque::setBackend(App::getEnv('_APP_REDIS_HOST', '').':'.App::getEnv('_APP_REDIS_PORT', ''));
}
-/**
- * Old DB Filters
- */
-DatabaseOld::addFilter('json',
- function($value) {
- if(!is_array($value)) {
- return $value;
- }
- return json_encode($value);
- },
- function($value) {
- return json_decode($value, true);
- }
-);
-
-DatabaseOld::addFilter('encrypt',
- function($value) {
- $key = App::getEnv('_APP_OPENSSL_KEY_V1');
- $iv = OpenSSL::randomPseudoBytes(OpenSSL::cipherIVLength(OpenSSL::CIPHER_AES_128_GCM));
- $tag = null;
-
- return json_encode([
- 'data' => OpenSSL::encrypt($value, OpenSSL::CIPHER_AES_128_GCM, $key, 0, $iv, $tag),
- 'method' => OpenSSL::CIPHER_AES_128_GCM,
- 'iv' => bin2hex($iv),
- 'tag' => bin2hex($tag),
- 'version' => '1',
- ]);
- },
- function($value) {
- $value = json_decode($value, true);
- $key = App::getEnv('_APP_OPENSSL_KEY_V'.$value['version']);
-
- return OpenSSL::decrypt($value['data'], $value['method'], $key, 0, hex2bin($value['iv']), hex2bin($value['tag']));
- }
-);
-
/**
* New DB Filters
*/
diff --git a/app/workers/database.php b/app/workers/database.php
index 3b9f36afbd..f58502704a 100644
--- a/app/workers/database.php
+++ b/app/workers/database.php
@@ -1,5 +1,6 @@
isEmpty()) {
throw new Exception('Missing document');
}
-
+
switch (strval($type)) {
case DATABASE_TYPE_CREATE_ATTRIBUTE:
$this->createAttribute($collection, $document, $projectId);
@@ -67,9 +68,11 @@ class DatabaseV1 extends Worker
*/
protected function createAttribute(Document $collection, Document $attribute, string $projectId): void
{
+ $dbForConsole = $this->getConsoleDB();
$dbForInternal = $this->getInternalDB($projectId);
$dbForExternal = $this->getExternalDB($projectId);
+ $event = 'database.attributes.update';
$collectionId = $collection->getId();
$key = $attribute->getAttribute('key', '');
$type = $attribute->getAttribute('type', '');
@@ -81,6 +84,7 @@ class DatabaseV1 extends Worker
$format = $attribute->getAttribute('format', '');
$formatOptions = $attribute->getAttribute('formatOptions', []);
$filters = $attribute->getAttribute('filters', []);
+ $project = $dbForConsole->getDocument('projects', $projectId);
try {
if(!$dbForExternal->createAttribute($collectionId, $key, $type, $size, $required, $default, $signed, $array, $format, $formatOptions, $filters)) {
@@ -90,6 +94,20 @@ class DatabaseV1 extends Worker
} catch (\Throwable $th) {
Console::error($th->getMessage());
$dbForInternal->updateDocument('attributes', $attribute->getId(), $attribute->setAttribute('status', 'failed'));
+ } finally {
+ $target = Realtime::fromPayload($event, $attribute, $project);
+
+ Realtime::send(
+ projectId: 'console',
+ payload: $attribute->getArrayCopy(),
+ event: $event,
+ channels: $target['channels'],
+ roles: $target['roles'],
+ options: [
+ 'projectId' => $projectId,
+ 'collectionId' => $collection->getId()
+ ]
+ );
}
$dbForInternal->deleteCachedDocument('collections', $collectionId);
@@ -102,11 +120,15 @@ class DatabaseV1 extends Worker
*/
protected function deleteAttribute(Document $collection, Document $attribute, string $projectId): void
{
+ $dbForConsole = $this->getConsoleDB();
$dbForInternal = $this->getInternalDB($projectId);
$dbForExternal = $this->getExternalDB($projectId);
+
+ $event = 'database.attributes.delete';
$collectionId = $collection->getId();
$key = $attribute->getAttribute('key', '');
$status = $attribute->getAttribute('status', '');
+ $project = $dbForConsole->getDocument('projects', $projectId);
// possible states at this point:
// - available: should not land in queue; controller flips these to 'deleting'
@@ -122,6 +144,20 @@ class DatabaseV1 extends Worker
} catch (\Throwable $th) {
Console::error($th->getMessage());
$dbForInternal->updateDocument('attributes', $attribute->getId(), $attribute->setAttribute('status', 'stuck'));
+ } finally {
+ $target = Realtime::fromPayload($event, $attribute, $project);
+
+ Realtime::send(
+ projectId: 'console',
+ payload: $attribute->getArrayCopy(),
+ event: $event,
+ channels: $target['channels'],
+ roles: $target['roles'],
+ options: [
+ 'projectId' => $projectId,
+ 'collectionId' => $collection->getId()
+ ]
+ );
}
// The underlying database removes/rebuilds indexes when attribute is removed
@@ -185,15 +221,18 @@ class DatabaseV1 extends Worker
*/
protected function createIndex(Document $collection, Document $index, string $projectId): void
{
+ $dbForConsole = $this->getConsoleDB();
$dbForInternal = $this->getInternalDB($projectId);
$dbForExternal = $this->getExternalDB($projectId);
+ $event = 'database.indexes.update';
$collectionId = $collection->getId();
$key = $index->getAttribute('key', '');
$type = $index->getAttribute('type', '');
$attributes = $index->getAttribute('attributes', []);
$lengths = $index->getAttribute('lengths', []);
$orders = $index->getAttribute('orders', []);
+ $project = $dbForConsole->getDocument('projects', $projectId);
try {
if(!$dbForExternal->createIndex($collectionId, $key, $type, $attributes, $lengths, $orders)) {
@@ -203,6 +242,20 @@ class DatabaseV1 extends Worker
} catch (\Throwable $th) {
Console::error($th->getMessage());
$dbForInternal->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'failed'));
+ } finally {
+ $target = Realtime::fromPayload($event, $index, $project);
+
+ Realtime::send(
+ projectId: 'console',
+ payload: $index->getArrayCopy(),
+ event: $event,
+ channels: $target['channels'],
+ roles: $target['roles'],
+ options: [
+ 'projectId' => $projectId,
+ 'collectionId' => $collection->getId()
+ ]
+ );
}
$dbForInternal->deleteCachedDocument('collections', $collectionId);
@@ -215,12 +268,15 @@ class DatabaseV1 extends Worker
*/
protected function deleteIndex(Document $collection, Document $index, string $projectId): void
{
+ $dbForConsole = $this->getConsoleDB();
$dbForInternal = $this->getInternalDB($projectId);
$dbForExternal = $this->getExternalDB($projectId);
$collectionId = $collection->getId();
$key = $index->getAttribute('key');
$status = $index->getAttribute('status', '');
+ $event = 'database.indexes.delete';
+ $project = $dbForConsole->getDocument('projects', $projectId);
try {
if($status !== 'failed' && !$dbForExternal->deleteIndex($collectionId, $key)) {
@@ -230,6 +286,20 @@ class DatabaseV1 extends Worker
} catch (\Throwable $th) {
Console::error($th->getMessage());
$dbForInternal->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'stuck'));
+ } finally {
+ $target = Realtime::fromPayload($event, $index, $project);
+
+ Realtime::send(
+ projectId: 'console',
+ payload: $index->getArrayCopy(),
+ event: $event,
+ channels: $target['channels'],
+ roles: $target['roles'],
+ options: [
+ 'projectId' => $projectId,
+ 'collectionId' => $collection->getId()
+ ]
+ );
}
$dbForInternal->deleteCachedDocument('collections', $collectionId);
diff --git a/public/dist/scripts/app-all.js b/public/dist/scripts/app-all.js
index cbddd2d614..b9632bf90b 100644
--- a/public/dist/scripts/app-all.js
+++ b/public/dist/scripts/app-all.js
@@ -3427,7 +3427,8 @@ function handler2(){}
handler2.inline=(el,{expression},{cleanup:cleanup2})=>{let root=closestRoot(el);if(!root._x_refs)
root._x_refs={};root._x_refs[expression]=el;cleanup2(()=>delete root._x_refs[expression]);};directive("ref",handler2);directive("if",(el,{expression},{effect:effect3,cleanup:cleanup2})=>{let evaluate2=evaluateLater(el,expression);let show=()=>{if(el._x_currentIfEl)
return el._x_currentIfEl;let clone2=el.content.cloneNode(true).firstElementChild;addScopeToNode(clone2,{},el);mutateDom(()=>{el.after(clone2);initTree(clone2);});el._x_currentIfEl=clone2;el._x_undoIf=()=>{clone2.remove();delete el._x_currentIfEl;};return clone2;};let hide=()=>{if(!el._x_undoIf)
-return;el._x_undoIf();delete el._x_undoIf;};effect3(()=>evaluate2((value)=>{value?show():hide();}));cleanup2(()=>el._x_undoIf&&el._x_undoIf());});mapAttributes(startingWith("@",into(prefix("on:"))));directive("on",skipDuringClone((el,{value,modifiers,expression},{cleanup:cleanup2})=>{let evaluate2=expression?evaluateLater(el,expression):()=>{};let removeListener=on(el,value,modifiers,(e)=>{evaluate2(()=>{},{scope:{$event:e},params:[e]});});cleanup2(()=>removeListener());}));alpine_default.setEvaluator(normalEvaluator);alpine_default.setReactivityEngine({reactive:reactive2,effect:effect2,release:stop,raw:toRaw});var src_default=alpine_default;window.Alpine=src_default;queueMicrotask(()=>{src_default.start();});})();window.ls.error=function(){return function(error){window.console.error(error);if(window.location.pathname!=='/console'){window.location='/console';}};};window.addEventListener("error",function(event){console.error("ERROR-EVENT:",event.error.message,event.error.stack);});document.addEventListener("account.deleteSession",function(){window.location="/auth/signin";});document.addEventListener("account.create",function(){let container=window.ls.container;let form=container.get('serviceForm');let sdk=container.get('console');let promise=sdk.account.createSession(form.email,form.password);container.set("serviceForm",{},true,true);promise.then(function(){var subscribe=document.getElementById('newsletter').checked;if(subscribe){let alerts=container.get('alerts');let loaderId=alerts.add({text:'Loading...',class:""},0);fetch('https://appwrite.io/v1/newsletter/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:form.name,email:form.email,}),}).finally(function(){alerts.remove(loaderId);window.location='/console';});}else{window.location='/console';}},function(error){window.location='/auth/signup?failure=1';});});window.addEventListener("load",async()=>{const bars=12;const realtime=window.ls.container.get('realtime');const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));let current={};window.ls.container.get('console').subscribe('project',event=>{for(let project in event.payload){current[project]=event.payload[project]??0;}});while(true){let newHistory={};let createdHistory=false;for(const project in current){let history=realtime?.history??{};if(!(project in history)){history[project]=new Array(bars).fill({percentage:0,value:0});}
+return;el._x_undoIf();delete el._x_undoIf;};effect3(()=>evaluate2((value)=>{value?show():hide();}));cleanup2(()=>el._x_undoIf&&el._x_undoIf());});mapAttributes(startingWith("@",into(prefix("on:"))));directive("on",skipDuringClone((el,{value,modifiers,expression},{cleanup:cleanup2})=>{let evaluate2=expression?evaluateLater(el,expression):()=>{};let removeListener=on(el,value,modifiers,(e)=>{evaluate2(()=>{},{scope:{$event:e},params:[e]});});cleanup2(()=>removeListener());}));alpine_default.setEvaluator(normalEvaluator);alpine_default.setReactivityEngine({reactive:reactive2,effect:effect2,release:stop,raw:toRaw});var src_default=alpine_default;window.Alpine=src_default;queueMicrotask(()=>{src_default.start();});})();window.ls.error=function(){return function(error){window.console.error(error);if(window.location.pathname!=='/console'){window.location='/console';}};};window.addEventListener("error",function(event){console.error("ERROR-EVENT:",event.error.message,event.error.stack);});document.addEventListener("account.deleteSession",function(){window.location="/auth/signin";});document.addEventListener("account.create",function(){let container=window.ls.container;let form=container.get('serviceForm');let sdk=container.get('console');let promise=sdk.account.createSession(form.email,form.password);container.set("serviceForm",{},true,true);promise.then(function(){var subscribe=document.getElementById('newsletter').checked;if(subscribe){let alerts=container.get('alerts');let loaderId=alerts.add({text:'Loading...',class:""},0);fetch('https://appwrite.io/v1/newsletter/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:form.name,email:form.email,}),}).finally(function(){alerts.remove(loaderId);window.location='/console';});}else{window.location='/console';}},function(error){window.location='/auth/signup?failure=1';});});window.addEventListener("load",async()=>{const bars=12;const realtime=window.ls.container.get('realtime');const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));let current={};window.ls.container.get('console').subscribe(['project','console'],response=>{switch(response.event){case'stats.connections':for(let project in response.payload){current[project]=response.payload[project]??0;}
+break;case'database.attributes.create':case'database.attributes.update':case'database.attributes.delete':document.dispatchEvent(new CustomEvent('database.createAttribute'));break;case'database.indexes.create':case'database.indexes.update':case'database.indexes.delete':document.dispatchEvent(new CustomEvent('database.createIndex'));break;}});while(true){let newHistory={};let createdHistory=false;for(const project in current){let history=realtime?.history??{};if(!(project in history)){history[project]=new Array(bars).fill({percentage:0,value:0});}
history=history[project];history.push({percentage:0,value:current[project]});if(history.length>=bars){history.shift();}
const highest=history.reduce((prev,curr)=>{return(curr.value>prev)?curr.value:prev;},0);history=history.map(({percentage,value})=>{createdHistory=true;percentage=value===0?0:((Math.round((value/highest)*10)/10)*100);if(percentage>100)percentage=100;else if(percentage==0&&value!=0)percentage=5;return{percentage:percentage,value:value};})
newHistory[project]=history;}
diff --git a/public/dist/scripts/app.js b/public/dist/scripts/app.js
index c7eb1c9eee..f2f5a08f79 100644
--- a/public/dist/scripts/app.js
+++ b/public/dist/scripts/app.js
@@ -493,7 +493,8 @@ function handler2(){}
handler2.inline=(el,{expression},{cleanup:cleanup2})=>{let root=closestRoot(el);if(!root._x_refs)
root._x_refs={};root._x_refs[expression]=el;cleanup2(()=>delete root._x_refs[expression]);};directive("ref",handler2);directive("if",(el,{expression},{effect:effect3,cleanup:cleanup2})=>{let evaluate2=evaluateLater(el,expression);let show=()=>{if(el._x_currentIfEl)
return el._x_currentIfEl;let clone2=el.content.cloneNode(true).firstElementChild;addScopeToNode(clone2,{},el);mutateDom(()=>{el.after(clone2);initTree(clone2);});el._x_currentIfEl=clone2;el._x_undoIf=()=>{clone2.remove();delete el._x_currentIfEl;};return clone2;};let hide=()=>{if(!el._x_undoIf)
-return;el._x_undoIf();delete el._x_undoIf;};effect3(()=>evaluate2((value)=>{value?show():hide();}));cleanup2(()=>el._x_undoIf&&el._x_undoIf());});mapAttributes(startingWith("@",into(prefix("on:"))));directive("on",skipDuringClone((el,{value,modifiers,expression},{cleanup:cleanup2})=>{let evaluate2=expression?evaluateLater(el,expression):()=>{};let removeListener=on(el,value,modifiers,(e)=>{evaluate2(()=>{},{scope:{$event:e},params:[e]});});cleanup2(()=>removeListener());}));alpine_default.setEvaluator(normalEvaluator);alpine_default.setReactivityEngine({reactive:reactive2,effect:effect2,release:stop,raw:toRaw});var src_default=alpine_default;window.Alpine=src_default;queueMicrotask(()=>{src_default.start();});})();window.ls.error=function(){return function(error){window.console.error(error);if(window.location.pathname!=='/console'){window.location='/console';}};};window.addEventListener("error",function(event){console.error("ERROR-EVENT:",event.error.message,event.error.stack);});document.addEventListener("account.deleteSession",function(){window.location="/auth/signin";});document.addEventListener("account.create",function(){let container=window.ls.container;let form=container.get('serviceForm');let sdk=container.get('console');let promise=sdk.account.createSession(form.email,form.password);container.set("serviceForm",{},true,true);promise.then(function(){var subscribe=document.getElementById('newsletter').checked;if(subscribe){let alerts=container.get('alerts');let loaderId=alerts.add({text:'Loading...',class:""},0);fetch('https://appwrite.io/v1/newsletter/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:form.name,email:form.email,}),}).finally(function(){alerts.remove(loaderId);window.location='/console';});}else{window.location='/console';}},function(error){window.location='/auth/signup?failure=1';});});window.addEventListener("load",async()=>{const bars=12;const realtime=window.ls.container.get('realtime');const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));let current={};window.ls.container.get('console').subscribe('project',event=>{for(let project in event.payload){current[project]=event.payload[project]??0;}});while(true){let newHistory={};let createdHistory=false;for(const project in current){let history=realtime?.history??{};if(!(project in history)){history[project]=new Array(bars).fill({percentage:0,value:0});}
+return;el._x_undoIf();delete el._x_undoIf;};effect3(()=>evaluate2((value)=>{value?show():hide();}));cleanup2(()=>el._x_undoIf&&el._x_undoIf());});mapAttributes(startingWith("@",into(prefix("on:"))));directive("on",skipDuringClone((el,{value,modifiers,expression},{cleanup:cleanup2})=>{let evaluate2=expression?evaluateLater(el,expression):()=>{};let removeListener=on(el,value,modifiers,(e)=>{evaluate2(()=>{},{scope:{$event:e},params:[e]});});cleanup2(()=>removeListener());}));alpine_default.setEvaluator(normalEvaluator);alpine_default.setReactivityEngine({reactive:reactive2,effect:effect2,release:stop,raw:toRaw});var src_default=alpine_default;window.Alpine=src_default;queueMicrotask(()=>{src_default.start();});})();window.ls.error=function(){return function(error){window.console.error(error);if(window.location.pathname!=='/console'){window.location='/console';}};};window.addEventListener("error",function(event){console.error("ERROR-EVENT:",event.error.message,event.error.stack);});document.addEventListener("account.deleteSession",function(){window.location="/auth/signin";});document.addEventListener("account.create",function(){let container=window.ls.container;let form=container.get('serviceForm');let sdk=container.get('console');let promise=sdk.account.createSession(form.email,form.password);container.set("serviceForm",{},true,true);promise.then(function(){var subscribe=document.getElementById('newsletter').checked;if(subscribe){let alerts=container.get('alerts');let loaderId=alerts.add({text:'Loading...',class:""},0);fetch('https://appwrite.io/v1/newsletter/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:form.name,email:form.email,}),}).finally(function(){alerts.remove(loaderId);window.location='/console';});}else{window.location='/console';}},function(error){window.location='/auth/signup?failure=1';});});window.addEventListener("load",async()=>{const bars=12;const realtime=window.ls.container.get('realtime');const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));let current={};window.ls.container.get('console').subscribe(['project','console'],response=>{switch(response.event){case'stats.connections':for(let project in response.payload){current[project]=response.payload[project]??0;}
+break;case'database.attributes.create':case'database.attributes.update':case'database.attributes.delete':document.dispatchEvent(new CustomEvent('database.createAttribute'));break;case'database.indexes.create':case'database.indexes.update':case'database.indexes.delete':document.dispatchEvent(new CustomEvent('database.createIndex'));break;}});while(true){let newHistory={};let createdHistory=false;for(const project in current){let history=realtime?.history??{};if(!(project in history)){history[project]=new Array(bars).fill({percentage:0,value:0});}
history=history[project];history.push({percentage:0,value:current[project]});if(history.length>=bars){history.shift();}
const highest=history.reduce((prev,curr)=>{return(curr.value>prev)?curr.value:prev;},0);history=history.map(({percentage,value})=>{createdHistory=true;percentage=value===0?0:((Math.round((value/highest)*10)/10)*100);if(percentage>100)percentage=100;else if(percentage==0&&value!=0)percentage=5;return{percentage:percentage,value:value};})
newHistory[project]=history;}
diff --git a/public/scripts/init.js b/public/scripts/init.js
index 711c678ac5..fb0e4b5383 100644
--- a/public/scripts/init.js
+++ b/public/scripts/init.js
@@ -57,10 +57,27 @@ window.addEventListener("load", async () => {
const realtime = window.ls.container.get('realtime');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let current = {};
- window.ls.container.get('console').subscribe('project', event => {
- for (let project in event.payload) {
- current[project] = event.payload[project] ?? 0;
+ window.ls.container.get('console').subscribe(['project', 'console'], response => {
+ switch (response.event) {
+ case 'stats.connections':
+ for (let project in response.payload) {
+ current[project] = response.payload[project] ?? 0;
+ }
+ break;
+ case 'database.attributes.create':
+ case 'database.attributes.update':
+ case 'database.attributes.delete':
+ document.dispatchEvent(new CustomEvent('database.createAttribute'));
+
+ break;
+ case 'database.indexes.create':
+ case 'database.indexes.update':
+ case 'database.indexes.delete':
+ document.dispatchEvent(new CustomEvent('database.createIndex'));
+
+ break;
}
+
});
while (true) {
diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php
index 4269fa04dc..71738e42ba 100644
--- a/src/Appwrite/Messaging/Adapter/Realtime.php
+++ b/src/Appwrite/Messaging/Adapter/Realtime.php
@@ -233,17 +233,19 @@ class Realtime extends Adapter
}
/**
- * Create channels array based on the event name and payload.
- *
+ * Creae channels array based on the event name and payload.
+ *
* @param string $event
* @param Document $payload
+ * @param Document|null $project
* @return array
*/
- public static function fromPayload(string $event, Document $payload): array
+ public static function fromPayload(string $event, Document $payload, Document $project = null): array
{
$channels = [];
$roles = [];
$permissionsChanged = false;
+ $projectId = null;
switch (true) {
case strpos($event, 'account.recovery.') === 0:
@@ -279,6 +281,13 @@ class Realtime extends Adapter
$channels[] = 'collections.' . $payload->getId();
$roles = $payload->getRead();
+ break;
+ case strpos($event, 'database.attributes.') === 0:
+ case strpos($event, 'database.indexes.') === 0:
+ $channels[] = 'console';
+ $projectId = 'console';
+ $roles = ['team:' . $project->getAttribute('teamId')];
+
break;
case strpos($event, 'database.documents.') === 0:
$channels[] = 'documents';
@@ -306,7 +315,8 @@ class Realtime extends Adapter
return [
'channels' => $channels,
'roles' => $roles,
- 'permissionsChanged' => $permissionsChanged
+ 'permissionsChanged' => $permissionsChanged,
+ 'projectId' => $projectId
];
}
}
diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php
index 68a121df93..1f734333d8 100644
--- a/tests/e2e/Services/Realtime/RealtimeBase.php
+++ b/tests/e2e/Services/Realtime/RealtimeBase.php
@@ -2,24 +2,26 @@
namespace Tests\E2E\Services\Realtime;
-use CURLFile;
-use Tests\E2E\Client;
-use WebSocket\Client as WebSocketClient;
use WebSocket\ConnectionException;
+use WebSocket\Client as WebSocketClient;
trait RealtimeBase
{
-
- private function getWebsocket($channels = [], $headers = [])
+ private function getWebsocket($channels = [], $headers = [], $projectId = null)
{
+ if (is_null($projectId)) {
+ $projectId = $this->getProject()['$id'];
+ }
+
$headers = array_merge([
'Origin' => 'appwrite.test'
], $headers);
$query = [
- 'project' => $this->getProject()['$id'],
+ 'project' => $projectId,
'channels' => $channels
];
+
return new WebSocketClient('ws://appwrite-traefik/v1/realtime?' . http_build_query($query), [
'headers' => $headers,
'timeout' => 30,
@@ -38,17 +40,6 @@ trait RealtimeBase
/**
* Test for FAILURE
*/
- $client = $this->getWebsocket(['documents'], ['origin' => 'http://appwrite.unknown']);
- $payload = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $payload);
- $this->assertArrayHasKey('data', $payload);
- $this->assertEquals('error', $payload['type']);
- $this->assertEquals(1008, $payload['data']['code']);
- $this->assertEquals('Invalid Origin. Register your new client (appwrite.unknown) as a new Web platform on your project console dashboard', $payload['data']['message']);
- $this->expectException(ConnectionException::class); // Check if server disconnnected client
- $client->close();
-
$client = $this->getWebsocket();
$payload = json_decode($client->receive(), true);
@@ -90,1020 +81,4 @@ trait RealtimeBase
$this->expectException(ConnectionException::class); // Check if server disconnnected client
$client->close();
}
-
- public function testChannelParsing()
- {
- $user = $this->getUser();
- $userId = $user['$id'] ?? '';
- $session = $user['session'] ?? '';
- $headers = [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session
- ];
-
- $client = $this->getWebsocket(['documents'], $headers);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- $client->close();
-
- $client = $this->getWebsocket(['account'], $headers);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- $client->close();
-
- $client = $this->getWebsocket(['account', 'documents', 'account.123'], $headers);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertCount(3, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- $client->close();
-
- $client = $this->getWebsocket([
- 'account',
- 'files',
- 'files.1',
- 'collections',
- 'collections.1',
- 'collections.1.documents',
- 'collections.2',
- 'collections.2.documents',
- 'documents',
- 'documents.1',
- 'documents.2',
- ], $headers);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertCount(12, $response['data']['channels']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertContains('files', $response['data']['channels']);
- $this->assertContains('files.1', $response['data']['channels']);
- $this->assertContains('collections', $response['data']['channels']);
- $this->assertContains('collections.1', $response['data']['channels']);
- $this->assertContains('collections.1.documents', $response['data']['channels']);
- $this->assertContains('collections.2', $response['data']['channels']);
- $this->assertContains('collections.2.documents', $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('documents.1', $response['data']['channels']);
- $this->assertContains('documents.2', $response['data']['channels']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- $client->close();
- }
-
- public function testManualAuthentication()
- {
- $user = $this->getUser();
- $userId = $user['$id'] ?? '';
- $session = $user['session'] ?? '';
-
- /**
- * Test for SUCCESS
- */
- $client = $this->getWebsocket(['account'], [
- 'origin' => 'http://localhost'
- ]);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('account', $response['data']['channels']);
-
- $client->send(\json_encode([
- 'type' => 'authentication',
- 'data' => [
- 'session' => $session
- ]
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('response', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertEquals('authentication', $response['data']['to']);
- $this->assertTrue($response['data']['success']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- /**
- * Test for FAILURE
- */
- $client->send(\json_encode([
- 'type' => 'authentication',
- 'data' => [
- 'session' => 'invalid_session'
- ]
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('error', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Session is not valid.', $response['data']['message']);
-
- $client->send(\json_encode([
- 'type' => 'authentication',
- 'data' => []
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('error', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Payload is not valid.', $response['data']['message']);
-
- $client->send(\json_encode([
- 'type' => 'unknown',
- 'data' => [
- 'session' => 'invalid_session'
- ]
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('error', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Message type is not valid.', $response['data']['message']);
-
- $client->send(\json_encode([
- 'test' => '123',
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('error', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertEquals(1003, $response['data']['code']);
- $this->assertEquals('Message format is not valid.', $response['data']['message']);
-
-
- $client->close();
- }
-
- public function testChannelAccount()
- {
- $user = $this->getUser();
- $userId = $user['$id'] ?? '';
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['account'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'=' . $session
- ]);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($userId, $response['data']['user']['$id']);
-
- /**
- * Test Account Name Event
- */
- $name = "Torsten Dittmann";
-
- $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_' . $projectId . '=' . $session,
- ]), [
- 'name' => $name
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.update.name', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $this->assertEquals($name, $response['data']['payload']['name']);
-
-
- /**
- * Test Account Password Event
- */
- $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $session,
- ]), [
- 'password' => 'new-password',
- 'oldPassword' => 'password',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.update.password', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $this->assertEquals($name, $response['data']['payload']['name']);
-
- /**
- * Test Account Email Update
- */
- $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $session,
- ]), [
- 'email' => 'torsten@appwrite.io',
- 'password' => 'new-password',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.update.email', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $this->assertEquals('torsten@appwrite.io', $response['data']['payload']['email']);
-
- /**
- * Test Account Verification Create
- */
- $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $session,
- ]), [
- 'url' => 'http://localhost/verification',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.verification.create', $response['data']['event']);
-
- $lastEmail = $this->getLastEmail();
- $verification = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256);
-
- /**
- * Test Account Verification Complete
- */
- $response = $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $session,
- ]), [
- 'userId' => $userId,
- 'secret' => $verification,
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.verification.update', $response['data']['event']);
-
- /**
- * Test Acoount Prefs Update
- */
- $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $session,
- ]), [
- 'prefs' => [
- 'prefKey1' => 'prefValue1',
- 'prefKey2' => 'prefValue2',
- ]
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.update.prefs', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- /**
- * Test Account Session Create
- */
- $response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ]), [
- 'email' => 'torsten@appwrite.io',
- 'password' => 'new-password',
- ]);
-
- $sessionNew = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_'.$projectId];
- $sessionNewId = $response['body']['$id'];
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.sessions.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- /**
- * Test Account Session Delete
- */
- $this->client->call(Client::METHOD_DELETE, '/account/sessions/'.$sessionNewId, array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- 'cookie' => 'a_session_'.$projectId.'=' . $sessionNew,
- ]));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.sessions.delete', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- /**
- * Test Account Create Recovery
- */
- $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ]), [
- 'email' => 'torsten@appwrite.io',
- 'url' => 'http://localhost/recovery',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $lastEmail = $this->getLastEmail();
- $recovery = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.recovery.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ]), [
- 'userId' => $userId,
- 'secret' => $recovery,
- 'password' => 'test-recovery',
- 'passwordAgain' => 'test-recovery',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertContains('account', $response['data']['channels']);
- $this->assertContains('account.' . $userId, $response['data']['channels']);
- $this->assertEquals('account.recovery.update', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $client->close();
- }
-
- public function testChannelDatabase()
- {
- $user = $this->getUser();
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['documents', 'collections'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'=' . $session
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('collections', $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($user['$id'], $response['data']['user']['$id']);
-
- /**
- * Test Collection Create
- */
- $actors = $this->client->call(Client::METHOD_POST, '/database/collections', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ]), [
- 'collectionId' => 'unique()',
- 'name' => 'Actors',
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- 'permission' => 'collection'
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('collections', $response['data']['channels']);
- $this->assertContains('collections.' . $actors['body']['$id'], $response['data']['channels']);
- $this->assertEquals('database.collections.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $data = ['actorsId' => $actors['body']['$id']];
-
- $name = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/attributes/string', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ]), [
- 'attributeId' => 'name',
- 'size' => 256,
- 'required' => true,
- ]);
-
- $this->assertEquals($name['headers']['status-code'], 201);
- $this->assertEquals($name['body']['key'], 'name');
- $this->assertEquals($name['body']['type'], 'string');
- $this->assertEquals($name['body']['size'], 256);
- $this->assertEquals($name['body']['required'], true);
-
- sleep(2);
-
- /**
- * Test Document Create
- */
- $document = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/documents', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'documentId' => 'unique()',
- 'data' => [
- 'name' => 'Chris Evans'
- ],
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('documents.' . $document['body']['$id'], $response['data']['channels']);
- $this->assertContains('collections.' . $actors['body']['$id'] . '.documents', $response['data']['channels']);
- $this->assertEquals('database.documents.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
- $this->assertEquals($response['data']['payload']['name'], 'Chris Evans');
-
- $data['documentId'] = $document['body']['$id'];
-
- /**
- * Test Document Update
- */
- $document = $this->client->call(Client::METHOD_PATCH, '/database/collections/' . $data['actorsId'] . '/documents/' . $data['documentId'], array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'documentId' => 'unique()',
- 'data' => [
- 'name' => 'Chris Evans 2'
- ],
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('documents.' . $data['documentId'], $response['data']['channels']);
- $this->assertContains('collections.' . $data['actorsId'] . '.documents', $response['data']['channels']);
- $this->assertEquals('database.documents.update', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $this->assertEquals($response['data']['payload']['name'], 'Chris Evans 2');
-
-
- /**
- * Test Document Delete
- */
- $document = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/documents', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'documentId' => 'unique()',
- 'data' => [
- 'name' => 'Bradley Cooper'
- ],
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- ]);
-
- $client->receive();
-
- $this->client->call(Client::METHOD_DELETE, '/database/collections/' . $data['actorsId'] . '/documents/' . $document['body']['$id'], array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
- $this->assertContains('documents', $response['data']['channels']);
- $this->assertContains('documents.' . $document['body']['$id'], $response['data']['channels']);
- $this->assertContains('collections.' . $data['actorsId'] . '.documents', $response['data']['channels']);
- $this->assertEquals('database.documents.delete', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
- $this->assertEquals($response['data']['payload']['name'], 'Bradley Cooper');
-
- $client->close();
- }
-
- public function testChannelFiles()
- {
- $user = $this->getUser();
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['files'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'=' . $session
- ]);
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('files', $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($user['$id'], $response['data']['user']['$id']);
-
- /**
- * Test File Create
- */
- $file = $this->client->call(Client::METHOD_POST, '/storage/files', array_merge([
- 'content-type' => 'multipart/form-data',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'fileId' => 'unique()',
- 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'),
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- 'folderId' => 'xyz',
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('files', $response['data']['channels']);
- $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
- $this->assertEquals('storage.files.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $data = ['fileId' => $file['body']['$id']];
-
- /**
- * Test File Update
- */
- $this->client->call(Client::METHOD_PUT, '/storage/files/' . $data['fileId'], array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'read' => ['role:all'],
- 'write' => ['role:all'],
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('files', $response['data']['channels']);
- $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
- $this->assertEquals('storage.files.update', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- /**
- * Test File Delete
- */
- $this->client->call(Client::METHOD_DELETE, '/storage/files/' . $data['fileId'], array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('files', $response['data']['channels']);
- $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
- $this->assertEquals('storage.files.delete', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $client->close();
- }
-
- public function testChannelExecutions()
- {
- $user = $this->getUser();
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['executions'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'=' . $session
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('executions', $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($user['$id'], $response['data']['user']['$id']);
-
- /**
- * Test Functions Create
- */
- $function = $this->client->call(Client::METHOD_POST, '/functions', [
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ], [
- 'functionId' => 'unique()',
- 'name' => 'Test',
- 'execute' => ['role:member'],
- 'runtime' => 'php-8.0',
- 'timeout' => 10,
- ]);
-
- $functionId = $function['body']['$id'] ?? '';
-
- $this->assertEquals($function['headers']['status-code'], 201);
- $this->assertNotEmpty($function['body']['$id']);
-
- $tag = $this->client->call(Client::METHOD_POST, '/functions/'.$functionId.'/tags', array_merge([
- 'content-type' => 'multipart/form-data',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ]), [
- 'command' => 'php index.php',
- 'code' => new CURLFile(realpath(__DIR__ . '/../../../resources/functions/timeout.tar.gz'), 'application/x-gzip', 'php-fx.tar.gz'),
- ]);
-
- $tagId = $tag['body']['$id'] ?? '';
-
- $this->assertEquals($tag['headers']['status-code'], 201);
- $this->assertNotEmpty($tag['body']['$id']);
-
- $response = $this->client->call(Client::METHOD_PATCH, '/functions/'.$functionId.'/tag', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- 'x-appwrite-key' => $this->getProject()['apiKey']
- ]), [
- 'tag' => $tagId,
- ]);
-
- $this->assertEquals($response['headers']['status-code'], 200);
- $this->assertNotEmpty($response['body']['$id']);
-
- $execution = $this->client->call(Client::METHOD_POST, '/functions/'.$functionId.'/executions', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id']
- ], $this->getHeaders()), []);
-
- $this->assertEquals($execution['headers']['status-code'], 201);
- $this->assertNotEmpty($execution['body']['$id']);
-
- $response = json_decode($client->receive(), true);
- $responseUpdate = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(3, $response['data']['channels']);
- $this->assertContains('executions', $response['data']['channels']);
- $this->assertContains('executions.' . $execution['body']['$id'], $response['data']['channels']);
- $this->assertContains('functions.' . $execution['body']['functionId'], $response['data']['channels']);
- $this->assertEquals('functions.executions.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $this->assertArrayHasKey('type', $responseUpdate);
- $this->assertArrayHasKey('data', $responseUpdate);
- $this->assertEquals('event', $responseUpdate['type']);
- $this->assertNotEmpty($responseUpdate['data']);
- $this->assertArrayHasKey('timestamp', $responseUpdate['data']);
- $this->assertCount(3, $responseUpdate['data']['channels']);
- $this->assertContains('executions', $responseUpdate['data']['channels']);
- $this->assertContains('executions.' . $execution['body']['$id'], $responseUpdate['data']['channels']);
- $this->assertContains('functions.' . $execution['body']['functionId'], $responseUpdate['data']['channels']);
- $this->assertEquals('functions.executions.update', $responseUpdate['data']['event']);
- $this->assertNotEmpty($responseUpdate['data']['payload']);
-
- $client->close();
- }
-
- public function testChannelTeams(): array
- {
- $user = $this->getUser();
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['teams'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'=' . $session
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('teams', $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($user['$id'], $response['data']['user']['$id']);
-
- /**
- * Test Team Create
- */
- $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ], $this->getHeaders()), [
- 'teamId' => 'unique()',
- 'name' => 'Arsenal'
- ]);
-
- $teamId = $team['body']['$id'] ?? '';
-
- $this->assertEquals(201, $team['headers']['status-code']);
- $this->assertNotEmpty($team['body']['$id']);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('teams', $response['data']['channels']);
- $this->assertContains('teams.' . $teamId, $response['data']['channels']);
- $this->assertEquals('teams.create', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- /**
- * Test Team Update
- */
- $team = $this->client->call(Client::METHOD_PUT, '/teams/'.$teamId, array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $projectId,
- ], $this->getHeaders()), [
- 'name' => 'Manchester'
- ]);
-
- $this->assertEquals($team['headers']['status-code'], 200);
- $this->assertNotEmpty($team['body']['$id']);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('teams', $response['data']['channels']);
- $this->assertContains('teams.' . $teamId, $response['data']['channels']);
- $this->assertEquals('teams.update', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $client->close();
-
- return ['teamId' => $teamId];
- }
-
- /**
- * @depends testChannelTeams
- */
- public function testChannelMemberships(array $data)
- {
- $teamId = $data['teamId'] ?? '';
-
- $user = $this->getUser();
- $session = $user['session'] ?? '';
- $projectId = $this->getProject()['$id'];
-
- $client = $this->getWebsocket(['memberships'], [
- 'origin' => 'http://localhost',
- 'cookie' => 'a_session_'.$projectId.'='.$session
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('connected', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertCount(1, $response['data']['channels']);
- $this->assertContains('memberships', $response['data']['channels']);
- $this->assertNotEmpty($response['data']['user']);
- $this->assertEquals($user['$id'], $response['data']['user']['$id']);
-
- $response = $this->client->call(Client::METHOD_GET, '/teams/'.$teamId.'/memberships', array_merge([
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()));
-
- $membershipId = $response['body']['memberships'][0]['$id'];
-
- /**
- * Test Update Membership
- */
- $roles = ['admin', 'editor', 'uncle'];
- $this->client->call(Client::METHOD_PATCH, '/teams/'.$teamId.'/memberships/'.$membershipId, array_merge([
- 'origin' => 'http://localhost',
- 'content-type' => 'application/json',
- 'x-appwrite-project' => $this->getProject()['$id'],
- ], $this->getHeaders()), [
- 'roles' => $roles
- ]);
-
- $response = json_decode($client->receive(), true);
-
- $this->assertArrayHasKey('type', $response);
- $this->assertArrayHasKey('data', $response);
- $this->assertEquals('event', $response['type']);
- $this->assertNotEmpty($response['data']);
- $this->assertArrayHasKey('timestamp', $response['data']);
- $this->assertCount(2, $response['data']['channels']);
- $this->assertContains('memberships', $response['data']['channels']);
- $this->assertContains('memberships.' . $membershipId, $response['data']['channels']);
- $this->assertEquals('teams.memberships.update', $response['data']['event']);
- $this->assertNotEmpty($response['data']['payload']);
-
- $client->close();
- }
}
diff --git a/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
new file mode 100644
index 0000000000..00c39c5d05
--- /dev/null
+++ b/tests/e2e/Services/Realtime/RealtimeConsoleClientTest.php
@@ -0,0 +1,276 @@
+getUser();
+ $session = $user['session'] ?? '';
+ $projectId = 'console';
+
+ $client = $this->getWebsocket(['console'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_console='. $this->getRoot()['session'],
+ ], $projectId);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+
+ /**
+ * Test Attributes
+ */
+ $actors = $this->client->call(Client::METHOD_POST, '/database/collections', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'collectionId' => 'unique()',
+ 'name' => 'Actors',
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ 'permission' => 'collection'
+ ]);
+
+ $data = ['actorsId' => $actors['body']['$id']];
+
+ $name = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/attributes/string', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'attributeId' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+
+ $this->assertEquals($name['headers']['status-code'], 201);
+ $this->assertEquals($name['body']['key'], 'name');
+ $this->assertEquals($name['body']['type'], 'string');
+ $this->assertEquals($name['body']['size'], 256);
+ $this->assertEquals($name['body']['required'], true);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.attributes.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals('processing', $response['data']['payload']['status']);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.attributes.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals('available', $response['data']['payload']['status']);
+
+ $client->close();
+
+ return $data;
+ }
+
+ /**
+ * @depends testAttributes
+ */
+ public function testIndexes(array $data)
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = 'console';
+
+ $client = $this->getWebsocket(['console'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_console='. $this->getRoot()['session'],
+ ], $projectId);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+
+ /**
+ * Test Indexes
+ */
+ $index = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/indexes', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'indexId' => 'key_name',
+ 'type' => 'key',
+ 'attributes' => [
+ 'name',
+ ],
+ ]);
+
+ $this->assertEquals($index['headers']['status-code'], 201);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.indexes.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals('processing', $response['data']['payload']['status']);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.indexes.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals('available', $response['data']['payload']['status']);
+
+ $client->close();
+
+ return $data;
+ }
+
+ /**
+ * @depends testIndexes
+ */
+ public function testDeleteIndex(array $data)
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = 'console';
+
+ $client = $this->getWebsocket(['console'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_console='. $this->getRoot()['session'],
+ ], $projectId);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+
+ /**
+ * Test Delete Index
+ */
+ $attribute = $this->client->call(Client::METHOD_DELETE, '/database/collections/' . $data['actorsId'] . '/indexes/key_name', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals($attribute['headers']['status-code'], 204);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.indexes.delete', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+
+ return $data;
+ }
+
+ /**
+ * @depends testDeleteIndex
+ */
+ public function testDeleteAttribute(array $data)
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = 'console';
+
+ $client = $this->getWebsocket(['console'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_console='. $this->getRoot()['session'],
+ ], $projectId);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+
+ /**
+ * Test Delete Attribute
+ */
+ $attribute = $this->client->call(Client::METHOD_DELETE, '/database/collections/' . $data['actorsId'] . '/attributes/name', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals($attribute['headers']['status-code'], 204);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('console', $response['data']['channels']);
+ $this->assertEquals('database.attributes.delete', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+ }
+}
\ No newline at end of file
diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
index 8822c493b2..ed10aefa59 100644
--- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
+++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php
@@ -2,9 +2,12 @@
namespace Tests\E2E\Services\Realtime;
+use CURLFile;
+use Tests\E2E\Client;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\SideClient;
+use WebSocket\ConnectionException;
class RealtimeCustomClientTest extends Scope
@@ -12,4 +15,1038 @@ class RealtimeCustomClientTest extends Scope
use RealtimeBase;
use ProjectCustom;
use SideClient;
+
+ public function testChannelParsing()
+ {
+ $user = $this->getUser();
+ $userId = $user['$id'] ?? '';
+ $session = $user['session'] ?? '';
+
+ $headers = [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$this->getProject()['$id'].'=' . $session
+ ];
+
+ $client = $this->getWebsocket(['documents'], $headers);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ $client->close();
+
+ $client = $this->getWebsocket(['account'], $headers);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ $client->close();
+
+ $client = $this->getWebsocket(['account', 'documents', 'account.123'], $headers);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertCount(3, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ $client->close();
+
+ $client = $this->getWebsocket([
+ 'account',
+ 'files',
+ 'files.1',
+ 'collections',
+ 'collections.1',
+ 'collections.1.documents',
+ 'collections.2',
+ 'collections.2.documents',
+ 'documents',
+ 'documents.1',
+ 'documents.2',
+ ], $headers);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertCount(12, $response['data']['channels']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertContains('files', $response['data']['channels']);
+ $this->assertContains('files.1', $response['data']['channels']);
+ $this->assertContains('collections', $response['data']['channels']);
+ $this->assertContains('collections.1', $response['data']['channels']);
+ $this->assertContains('collections.1.documents', $response['data']['channels']);
+ $this->assertContains('collections.2', $response['data']['channels']);
+ $this->assertContains('collections.2.documents', $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('documents.1', $response['data']['channels']);
+ $this->assertContains('documents.2', $response['data']['channels']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ $client->close();
+ }
+
+ public function testManualAuthentication()
+ {
+ $user = $this->getUser();
+ $userId = $user['$id'] ?? '';
+ $session = $user['session'] ?? '';
+
+ /**
+ * Test for SUCCESS
+ */
+ $client = $this->getWebsocket(['account'], [
+ 'origin' => 'http://localhost'
+ ]);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('account', $response['data']['channels']);
+
+ $client->send(\json_encode([
+ 'type' => 'authentication',
+ 'data' => [
+ 'session' => $session
+ ]
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('response', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertEquals('authentication', $response['data']['to']);
+ $this->assertTrue($response['data']['success']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ /**
+ * Test for FAILURE
+ */
+ $client->send(\json_encode([
+ 'type' => 'authentication',
+ 'data' => [
+ 'session' => 'invalid_session'
+ ]
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('error', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertEquals(1003, $response['data']['code']);
+ $this->assertEquals('Session is not valid.', $response['data']['message']);
+
+ $client->send(\json_encode([
+ 'type' => 'authentication',
+ 'data' => []
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('error', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertEquals(1003, $response['data']['code']);
+ $this->assertEquals('Payload is not valid.', $response['data']['message']);
+
+ $client->send(\json_encode([
+ 'type' => 'unknown',
+ 'data' => [
+ 'session' => 'invalid_session'
+ ]
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('error', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertEquals(1003, $response['data']['code']);
+ $this->assertEquals('Message type is not valid.', $response['data']['message']);
+
+ $client->send(\json_encode([
+ 'test' => '123',
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('error', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertEquals(1003, $response['data']['code']);
+ $this->assertEquals('Message format is not valid.', $response['data']['message']);
+
+
+ $client->close();
+ }
+
+ public function testConnectionPlatform()
+ {
+ /**
+ * Test for FAILURE
+ */
+ $client = $this->getWebsocket(['documents'], ['origin' => 'http://appwrite.unknown']);
+ $payload = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $payload);
+ $this->assertArrayHasKey('data', $payload);
+ $this->assertEquals('error', $payload['type']);
+ $this->assertEquals(1008, $payload['data']['code']);
+ $this->assertEquals('Invalid Origin. Register your new client (appwrite.unknown) as a new Web platform on your project console dashboard', $payload['data']['message']);
+ $this->expectException(ConnectionException::class); // Check if server disconnnected client
+ $client->close();
+ }
+
+ public function testChannelAccount()
+ {
+ $user = $this->getUser();
+ $userId = $user['$id'] ?? '';
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['account'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'=' . $session
+ ]);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($userId, $response['data']['user']['$id']);
+
+ /**
+ * Test Account Name Event
+ */
+ $name = "Torsten Dittmann";
+
+ $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_' . $projectId . '=' . $session,
+ ]), [
+ 'name' => $name
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.update.name', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $this->assertEquals($name, $response['data']['payload']['name']);
+
+
+ /**
+ * Test Account Password Event
+ */
+ $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $session,
+ ]), [
+ 'password' => 'new-password',
+ 'oldPassword' => 'password',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.update.password', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $this->assertEquals($name, $response['data']['payload']['name']);
+
+ /**
+ * Test Account Email Update
+ */
+ $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $session,
+ ]), [
+ 'email' => 'torsten@appwrite.io',
+ 'password' => 'new-password',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.update.email', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $this->assertEquals('torsten@appwrite.io', $response['data']['payload']['email']);
+
+ /**
+ * Test Account Verification Create
+ */
+ $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $session,
+ ]), [
+ 'url' => 'http://localhost/verification',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.verification.create', $response['data']['event']);
+
+ $lastEmail = $this->getLastEmail();
+ $verification = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256);
+
+ /**
+ * Test Account Verification Complete
+ */
+ $response = $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $session,
+ ]), [
+ 'userId' => $userId,
+ 'secret' => $verification,
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.verification.update', $response['data']['event']);
+
+ /**
+ * Test Acoount Prefs Update
+ */
+ $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $session,
+ ]), [
+ 'prefs' => [
+ 'prefKey1' => 'prefValue1',
+ 'prefKey2' => 'prefValue2',
+ ]
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.update.prefs', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ /**
+ * Test Account Session Create
+ */
+ $response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ]), [
+ 'email' => 'torsten@appwrite.io',
+ 'password' => 'new-password',
+ ]);
+
+ $sessionNew = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_'.$projectId];
+ $sessionNewId = $response['body']['$id'];
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.sessions.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ /**
+ * Test Account Session Delete
+ */
+ $this->client->call(Client::METHOD_DELETE, '/account/sessions/'.$sessionNewId, array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ 'cookie' => 'a_session_'.$projectId.'=' . $sessionNew,
+ ]));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.sessions.delete', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ /**
+ * Test Account Create Recovery
+ */
+ $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ]), [
+ 'email' => 'torsten@appwrite.io',
+ 'url' => 'http://localhost/recovery',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $lastEmail = $this->getLastEmail();
+ $recovery = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 256);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.recovery.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ]), [
+ 'userId' => $userId,
+ 'secret' => $recovery,
+ 'password' => 'test-recovery',
+ 'passwordAgain' => 'test-recovery',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertContains('account', $response['data']['channels']);
+ $this->assertContains('account.' . $userId, $response['data']['channels']);
+ $this->assertEquals('account.recovery.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+ }
+
+ public function testChannelDatabase()
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['documents', 'collections'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'=' . $session
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('collections', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($user['$id'], $response['data']['user']['$id']);
+
+ /**
+ * Test Collection Create
+ */
+ $actors = $this->client->call(Client::METHOD_POST, '/database/collections', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ]), [
+ 'collectionId' => 'unique()',
+ 'name' => 'Actors',
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ 'permission' => 'collection'
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('collections', $response['data']['channels']);
+ $this->assertContains('collections.' . $actors['body']['$id'], $response['data']['channels']);
+ $this->assertEquals('database.collections.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $data = ['actorsId' => $actors['body']['$id']];
+
+ $name = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/attributes/string', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ]), [
+ 'attributeId' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+
+ $this->assertEquals($name['headers']['status-code'], 201);
+ $this->assertEquals($name['body']['key'], 'name');
+ $this->assertEquals($name['body']['type'], 'string');
+ $this->assertEquals($name['body']['size'], 256);
+ $this->assertEquals($name['body']['required'], true);
+
+ sleep(2);
+
+ /**
+ * Test Document Create
+ */
+ $document = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/documents', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'documentId' => 'unique()',
+ 'data' => [
+ 'name' => 'Chris Evans'
+ ],
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(3, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('documents.' . $document['body']['$id'], $response['data']['channels']);
+ $this->assertContains('collections.' . $actors['body']['$id'] . '.documents', $response['data']['channels']);
+ $this->assertEquals('database.documents.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals($response['data']['payload']['name'], 'Chris Evans');
+
+ $data['documentId'] = $document['body']['$id'];
+
+ /**
+ * Test Document Update
+ */
+ $document = $this->client->call(Client::METHOD_PATCH, '/database/collections/' . $data['actorsId'] . '/documents/' . $data['documentId'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'documentId' => 'unique()',
+ 'data' => [
+ 'name' => 'Chris Evans 2'
+ ],
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(3, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('documents.' . $data['documentId'], $response['data']['channels']);
+ $this->assertContains('collections.' . $data['actorsId'] . '.documents', $response['data']['channels']);
+ $this->assertEquals('database.documents.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $this->assertEquals($response['data']['payload']['name'], 'Chris Evans 2');
+
+
+ /**
+ * Test Document Delete
+ */
+ $document = $this->client->call(Client::METHOD_POST, '/database/collections/' . $data['actorsId'] . '/documents', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'documentId' => 'unique()',
+ 'data' => [
+ 'name' => 'Bradley Cooper'
+ ],
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ ]);
+
+ $client->receive();
+
+ $this->client->call(Client::METHOD_DELETE, '/database/collections/' . $data['actorsId'] . '/documents/' . $document['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(3, $response['data']['channels']);
+ $this->assertContains('documents', $response['data']['channels']);
+ $this->assertContains('documents.' . $document['body']['$id'], $response['data']['channels']);
+ $this->assertContains('collections.' . $data['actorsId'] . '.documents', $response['data']['channels']);
+ $this->assertEquals('database.documents.delete', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+ $this->assertEquals($response['data']['payload']['name'], 'Bradley Cooper');
+
+ $client->close();
+ }
+
+ public function testChannelFiles()
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['files'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'=' . $session
+ ]);
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('files', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($user['$id'], $response['data']['user']['$id']);
+
+ /**
+ * Test File Create
+ */
+ $file = $this->client->call(Client::METHOD_POST, '/storage/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => 'unique()',
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'),
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ 'folderId' => 'xyz',
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('files', $response['data']['channels']);
+ $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
+ $this->assertEquals('storage.files.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $data = ['fileId' => $file['body']['$id']];
+
+ /**
+ * Test File Update
+ */
+ $this->client->call(Client::METHOD_PUT, '/storage/files/' . $data['fileId'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'read' => ['role:all'],
+ 'write' => ['role:all'],
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('files', $response['data']['channels']);
+ $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
+ $this->assertEquals('storage.files.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ /**
+ * Test File Delete
+ */
+ $this->client->call(Client::METHOD_DELETE, '/storage/files/' . $data['fileId'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('files', $response['data']['channels']);
+ $this->assertContains('files.' . $file['body']['$id'], $response['data']['channels']);
+ $this->assertEquals('storage.files.delete', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+ }
+
+ public function testChannelExecutions()
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['executions'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'=' . $session
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('executions', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($user['$id'], $response['data']['user']['$id']);
+
+ /**
+ * Test Functions Create
+ */
+ $function = $this->client->call(Client::METHOD_POST, '/functions', [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ], [
+ 'functionId' => 'unique()',
+ 'name' => 'Test',
+ 'execute' => ['role:member'],
+ 'runtime' => 'php-8.0',
+ 'timeout' => 10,
+ ]);
+
+ $functionId = $function['body']['$id'] ?? '';
+
+ $this->assertEquals($function['headers']['status-code'], 201);
+ $this->assertNotEmpty($function['body']['$id']);
+
+ $tag = $this->client->call(Client::METHOD_POST, '/functions/'.$functionId.'/tags', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ]), [
+ 'command' => 'php index.php',
+ 'code' => new CURLFile(realpath(__DIR__ . '/../../../resources/functions/timeout.tar.gz'), 'application/x-gzip', 'php-fx.tar.gz'),
+ ]);
+
+ $tagId = $tag['body']['$id'] ?? '';
+
+ $this->assertEquals($tag['headers']['status-code'], 201);
+ $this->assertNotEmpty($tag['body']['$id']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/functions/'.$functionId.'/tag', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ]), [
+ 'tag' => $tagId,
+ ]);
+
+ $this->assertEquals($response['headers']['status-code'], 200);
+ $this->assertNotEmpty($response['body']['$id']);
+
+ $execution = $this->client->call(Client::METHOD_POST, '/functions/'.$functionId.'/executions', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id']
+ ], $this->getHeaders()), []);
+
+ $this->assertEquals($execution['headers']['status-code'], 201);
+ $this->assertNotEmpty($execution['body']['$id']);
+
+ $response = json_decode($client->receive(), true);
+ $responseUpdate = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(3, $response['data']['channels']);
+ $this->assertContains('executions', $response['data']['channels']);
+ $this->assertContains('executions.' . $execution['body']['$id'], $response['data']['channels']);
+ $this->assertContains('functions.' . $execution['body']['functionId'], $response['data']['channels']);
+ $this->assertEquals('functions.executions.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $this->assertArrayHasKey('type', $responseUpdate);
+ $this->assertArrayHasKey('data', $responseUpdate);
+ $this->assertEquals('event', $responseUpdate['type']);
+ $this->assertNotEmpty($responseUpdate['data']);
+ $this->assertArrayHasKey('timestamp', $responseUpdate['data']);
+ $this->assertCount(3, $responseUpdate['data']['channels']);
+ $this->assertContains('executions', $responseUpdate['data']['channels']);
+ $this->assertContains('executions.' . $execution['body']['$id'], $responseUpdate['data']['channels']);
+ $this->assertContains('functions.' . $execution['body']['functionId'], $responseUpdate['data']['channels']);
+ $this->assertEquals('functions.executions.update', $responseUpdate['data']['event']);
+ $this->assertNotEmpty($responseUpdate['data']['payload']);
+
+ $client->close();
+ }
+
+ public function testChannelTeams(): array
+ {
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['teams'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'=' . $session
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('teams', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($user['$id'], $response['data']['user']['$id']);
+
+ /**
+ * Test Team Create
+ */
+ $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $this->getHeaders()), [
+ 'teamId' => 'unique()',
+ 'name' => 'Arsenal'
+ ]);
+
+ $teamId = $team['body']['$id'] ?? '';
+
+ $this->assertEquals(201, $team['headers']['status-code']);
+ $this->assertNotEmpty($team['body']['$id']);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('teams', $response['data']['channels']);
+ $this->assertContains('teams.' . $teamId, $response['data']['channels']);
+ $this->assertEquals('teams.create', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ /**
+ * Test Team Update
+ */
+ $team = $this->client->call(Client::METHOD_PUT, '/teams/'.$teamId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $projectId,
+ ], $this->getHeaders()), [
+ 'name' => 'Manchester'
+ ]);
+
+ $this->assertEquals($team['headers']['status-code'], 200);
+ $this->assertNotEmpty($team['body']['$id']);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('teams', $response['data']['channels']);
+ $this->assertContains('teams.' . $teamId, $response['data']['channels']);
+ $this->assertEquals('teams.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+
+ return ['teamId' => $teamId];
+ }
+
+ /**
+ * @depends testChannelTeams
+ */
+ public function testChannelMemberships(array $data)
+ {
+ $teamId = $data['teamId'] ?? '';
+
+ $user = $this->getUser();
+ $session = $user['session'] ?? '';
+ $projectId = $this->getProject()['$id'];
+
+ $client = $this->getWebsocket(['memberships'], [
+ 'origin' => 'http://localhost',
+ 'cookie' => 'a_session_'.$projectId.'='.$session
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('connected', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertCount(1, $response['data']['channels']);
+ $this->assertContains('memberships', $response['data']['channels']);
+ $this->assertNotEmpty($response['data']['user']);
+ $this->assertEquals($user['$id'], $response['data']['user']['$id']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/teams/'.$teamId.'/memberships', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $membershipId = $response['body']['memberships'][0]['$id'];
+
+ /**
+ * Test Update Membership
+ */
+ $roles = ['admin', 'editor', 'uncle'];
+ $this->client->call(Client::METHOD_PATCH, '/teams/'.$teamId.'/memberships/'.$membershipId, array_merge([
+ 'origin' => 'http://localhost',
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'roles' => $roles
+ ]);
+
+ $response = json_decode($client->receive(), true);
+
+ $this->assertArrayHasKey('type', $response);
+ $this->assertArrayHasKey('data', $response);
+ $this->assertEquals('event', $response['type']);
+ $this->assertNotEmpty($response['data']);
+ $this->assertArrayHasKey('timestamp', $response['data']);
+ $this->assertCount(2, $response['data']['channels']);
+ $this->assertContains('memberships', $response['data']['channels']);
+ $this->assertContains('memberships.' . $membershipId, $response['data']['channels']);
+ $this->assertEquals('teams.memberships.update', $response['data']['event']);
+ $this->assertNotEmpty($response['data']['payload']);
+
+ $client->close();
+ }
}
\ No newline at end of file
From 277d397a41ee33b3d3e2d677313b39bb689f8a6e Mon Sep 17 00:00:00 2001
From: Matej Baco
Date: Mon, 6 Dec 2021 16:03:11 +0100
Subject: [PATCH 03/17] Attempt to fix tests
---
tests/e2e/Services/Locale/LocaleBase.php | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/e2e/Services/Locale/LocaleBase.php b/tests/e2e/Services/Locale/LocaleBase.php
index 5624571c41..98df97a706 100644
--- a/tests/e2e/Services/Locale/LocaleBase.php
+++ b/tests/e2e/Services/Locale/LocaleBase.php
@@ -205,15 +205,15 @@ trait LocaleBase
$this->assertEquals($response['headers']['status-code'], 200);
$this->assertIsArray($response['body']);
- $this->assertEquals(185, $response['body']['sum']);
+ $this->assertEquals(184, $response['body']['sum']);
$this->assertEquals($response['body']['languages'][0]['code'], 'aa');
$this->assertEquals($response['body']['languages'][0]['name'], 'Afar');
$this->assertEquals($response['body']['languages'][0]['nativeName'], 'Afar');
- $this->assertEquals($response['body']['languages'][184]['code'], 'zu');
- $this->assertEquals($response['body']['languages'][184]['name'], 'Zulu');
- $this->assertEquals($response['body']['languages'][184]['nativeName'], 'isiZulu');
+ $this->assertEquals($response['body']['languages'][183]['code'], 'zu');
+ $this->assertEquals($response['body']['languages'][183]['name'], 'Zulu');
+ $this->assertEquals($response['body']['languages'][183]['nativeName'], 'isiZulu');
/**
* Test for FAILURE
From 27b7b73b929d174d41601c78f88b40494e8969ab Mon Sep 17 00:00:00 2001
From: "Eldad A. Fux"
Date: Tue, 7 Dec 2021 18:27:56 +0200
Subject: [PATCH 04/17] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index e71cbf57e9..5d2652cf19 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
[](https://hacktoberfest.appwrite.io)
-[](https://appwrite.io/discord)
+[](https://appwrite.io/discord?r=Github)
[](https://hub.docker.com/r/appwrite/appwrite)
[](https://travis-ci.com/appwrite/appwrite)
[](https://twitter.com/appwrite_io)
From 76bf24ac9b31236cc53cc171225be6549f0e362b Mon Sep 17 00:00:00 2001
From: Eldad Fux
Date: Sat, 11 Dec 2021 02:17:28 +0200
Subject: [PATCH 05/17] Updated Youtube URL
---
app/init.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/init.php b/app/init.php
index 52cb48093d..84a9f81966 100644
--- a/app/init.php
+++ b/app/init.php
@@ -64,7 +64,7 @@ const APP_SOCIAL_DISCORD = 'https://appwrite.io/discord';
const APP_SOCIAL_DISCORD_CHANNEL = '564160730845151244';
const APP_SOCIAL_DEV = 'https://dev.to/appwrite';
const APP_SOCIAL_STACKSHARE = 'https://stackshare.io/appwrite';
-const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite';
+const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite?sub_confirmation=1';
// Deletion Types
const DELETE_TYPE_DOCUMENT = 'document';
From 8a46713d8ec71ec6dbebee68497452f2a9a53e00 Mon Sep 17 00:00:00 2001
From: Eldad Fux
Date: Sat, 11 Dec 2021 22:17:33 +0200
Subject: [PATCH 06/17] Updated our Twitter handle
---
CONTRIBUTING.md | 4 ++--
README.md | 4 ++--
app/init.php | 4 ++--
app/tasks/sdks.php | 2 +-
4 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 01aa3d2ede..a1f9b50c77 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,7 @@ We would ❤️ for you to contribute to Appwrite and help make it better! We wa
## How to Start?
-If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or [@appwrite_io](https://twitter.com/appwrite_io) on Twitter, and anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you!
+If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or [@appwrite](https://twitter.com/appwrite) on Twitter, and anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you!
## Code of Conduct
@@ -395,7 +395,7 @@ Pull requests are great, but there are many other areas where you can help Appwr
### Blogging & Speaking
-Blogging, speaking about, or creating tutorials about one of Appwrite’s many features. Mention [@appwrite_io](https://twitter.com/appwrite_io) on Twitter and/or [email team@appwrite.io](mailto:team@appwrite.io) so we can give pointers and tips and help you spread the word by promoting your content on the different Appwrite communication channels. Please add your blog posts and videos of talks to our [Awesome Appwrite](https://github.com/appwrite/awesome-appwrite) repo on GitHub.
+Blogging, speaking about, or creating tutorials about one of Appwrite’s many features. Mention [@appwrite](https://twitter.com/appwrite) on Twitter and/or [email team@appwrite.io](mailto:team@appwrite.io) so we can give pointers and tips and help you spread the word by promoting your content on the different Appwrite communication channels. Please add your blog posts and videos of talks to our [Awesome Appwrite](https://github.com/appwrite/awesome-appwrite) repo on GitHub.
### Presenting at Meetups
diff --git a/README.md b/README.md
index 5d2652cf19..d61a286fb8 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
[](https://appwrite.io/discord?r=Github)
[](https://hub.docker.com/r/appwrite/appwrite)
[](https://travis-ci.com/appwrite/appwrite)
-[](https://twitter.com/appwrite_io)
+[](https://twitter.com/appwrite)
[](docs/tutorials/add-translations.md)
@@ -155,7 +155,7 @@ For security issues, kindly email us at [security@appwrite.io](mailto:security@a
## Follow Us
-Join our growing community around the world! See our official [Blog](https://medium.com/appwrite-io). Follow us on [Twitter](https://twitter.com/appwrite_io), [Facebook Page](https://www.facebook.com/appwrite.io), [Facebook Group](https://www.facebook.com/groups/appwrite.developers/) , [Dev Community](https://dev.to/appwrite) or join our live [Discord server](https://discord.gg/GSeTUeA) for more help, ideas, and discussions.
+Join our growing community around the world! See our official [Blog](https://medium.com/appwrite-io). Follow us on [Twitter](https://twitter.com/appwrite), [Facebook Page](https://www.facebook.com/appwrite.io), [Facebook Group](https://www.facebook.com/groups/appwrite.developers/) , [Dev Community](https://dev.to/appwrite) or join our live [Discord server](https://discord.gg/GSeTUeA) for more help, ideas, and discussions.
## License
diff --git a/app/init.php b/app/init.php
index 7461116bc0..26e291b82c 100644
--- a/app/init.php
+++ b/app/init.php
@@ -54,8 +54,8 @@ const APP_STORAGE_FUNCTIONS = '/storage/functions';
const APP_STORAGE_CACHE = '/storage/cache';
const APP_STORAGE_CERTIFICATES = '/storage/certificates';
const APP_STORAGE_CONFIG = '/storage/config';
-const APP_SOCIAL_TWITTER = 'https://twitter.com/appwrite_io';
-const APP_SOCIAL_TWITTER_HANDLE = 'appwrite_io';
+const APP_SOCIAL_TWITTER = 'https://twitter.com/appwrite';
+const APP_SOCIAL_TWITTER_HANDLE = 'appwrite';
const APP_SOCIAL_FACEBOOK = 'https://www.facebook.com/appwrite.io';
const APP_SOCIAL_LINKEDIN = 'https://www.linkedin.com/company/appwrite';
const APP_SOCIAL_INSTAGRAM = 'https://www.instagram.com/appwrite.io';
diff --git a/app/tasks/sdks.php b/app/tasks/sdks.php
index fa17c25855..2517d526d3 100644
--- a/app/tasks/sdks.php
+++ b/app/tasks/sdks.php
@@ -180,7 +180,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
->setShareText('Appwrite is a backend as a service for building web or mobile apps')
->setShareURL('http://appwrite.io')
->setShareTags('JS,javascript,reactjs,angular,ios,android,serverless')
- ->setShareVia('appwrite_io')
+ ->setShareVia('appwrite')
->setWarning($warning)
->setReadme($readme)
->setGettingStarted($gettingStarted)
From 624c18c0bf076e54f18da7164b6ed7c896f635ac Mon Sep 17 00:00:00 2001
From: Eldad Fux
Date: Sun, 12 Dec 2021 18:05:33 +0200
Subject: [PATCH 07/17] Updated og image
---
app/views/layouts/default.phtml | 2 +-
public/images/ogimage.png | Bin 0 -> 676279 bytes
2 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 public/images/ogimage.png
diff --git a/app/views/layouts/default.phtml b/app/views/layouts/default.phtml
index c6c0de795e..3b7445e9bc 100644
--- a/app/views/layouts/default.phtml
+++ b/app/views/layouts/default.phtml
@@ -10,7 +10,7 @@ $litespeed = $this->getParam('litespeed', true);
$analytics = $this->getParam('analytics', 'UA-26264668-9');
$mode = $this->getParam('mode', '');
$canonical = $this->getParam('canonical', '');
-$image = $this->getParam('image', '/images/logo.png');
+$image = $this->getParam('image', '/images/ogimage.png');
$locale = $this->getParam('locale', null);
$runtimes = $this->getParam('runtimes', null);
diff --git a/public/images/ogimage.png b/public/images/ogimage.png
new file mode 100644
index 0000000000000000000000000000000000000000..1b356dbbd5df8f12f8d2dacca5b9eb5675214af6
GIT binary patch
literal 676279
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