Skip to content

Commit

Permalink
Add snippet and system tests
Browse files Browse the repository at this point in the history
  • Loading branch information
jdpedrie committed Aug 28, 2019
1 parent d5212d1 commit 9166018
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 47 deletions.
93 changes: 53 additions & 40 deletions Firestore/src/FirestoreSessionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,86 +16,99 @@
*/
namespace Google\Cloud\Firestore;

use Exception;
use InvalidArgumentException;
use Google\Cloud\Core\Exception\ServiceException;
use SessionHandlerInterface;

/**
* Custom session handler backed by Cloud Firestore.
*
* Instead of storing the session data in a local file, it stores the data to
* Cloud Firestore. The biggest benefit of doing this is the data can be
* shared by multiple instances, so it's suitable for cloud applications.
* Instead of storing the session data in a local file, this handler stores the
* data in Firestore. The biggest benefit of doing this is the data can be
* shared by multiple instances, making it suitable for cloud applications.
*
* The downside of using Cloud Firestore is the write operations will cost you
* some money, so it is highly recommended to minimize the write operations
* with your session data with this handler. In order to do so, keep the data
* in the session as limited as possible; for example, it is ok to put only
* signed-in state and the user id in the session with this handler. However,
* for example, it is definitely not recommended that you store your
* application's whole undo history in the session, because every user
* operations will cause the Firestore write and then it will cost you lot of
* money.
* The downside of using Firestore is that write operations will cost you some
* money, so it is highly recommended to minimize the write operations while
* using this handler. In order to do so, keep the data in the session as
* limited as possible; for example, it is ok to put only signed-in state and
* the user id in the session with this handler. However, for example, it is
* definitely not recommended that you store your application's whole undo
* history in the session, because every user operation will cause a Firestore
* write, potentially costing you a lot of money.
*
* This handler doesn't provide pessimistic lock for session data. Instead, it
* uses a Firestore transaction for data consistency. This means that if
* multiple requests are modifying the same session data simultaneously, there
* will be more probablity that some of the `write` operations will fail.
*
* If you are building an AJAX application which may issue multiple requests
* to the server, please design the session data carefully, in order to avoid
* to the server, please design your session data carefully in order to avoid
* possible data contentions. Also please see the 2nd example below for how to
* properly handle errors on `write` operations.
*
* It uses the session.save_path as the Firestore namespace for isolating the
* session data from your application data, it also uses the session.name as
* the Firestore kind, the session id as the Firestore id. By default, it
* does nothing on gc for reducing the cost. Pass positive value up to 1000
* for $gcLimit parameter to delete entities in gc.
* The handler stores data in a collection provided by the value of
* session.save_path, isolating the session data from your application data. It
* creates documents in the specified collection where the session name and ID
* are concatenated. By default, it does nothing on gc for reducing the cost.
* Pass a positive value up to 1000 for $gcLimit parameter to delete entities in
* gc.
*
* The first example automatically writes the session data. It's handy, but
* the code doesn't stop even if it fails to write the session data, because
* the `write` happens when the code exits. If you want to know whether the
* session data is correctly written to Firestore, you need to call
* `session_write_close()` explicitly and then handle `E_USER_WARNING`
* properly. See the second example for a demonstration.
*
* Example without error handling:
* Example:
* ```
* use Google\Cloud\Firestore\FirestoreClient;
* use Google\Cloud\Firestore\FirestoreSessionHandler;
*
* $firestore = new FirestoreClient(['projectId' => $projectId]);
* $firestore = new FirestoreClient();
*
* $handler = new FirestoreSessionHandler($firestore);
*
* session_set_save_handler($handler, true);
* session_save_path('sessions');
* session_start();
*
* // Then read and write the $_SESSION array.
*
* // Then write and read the $_SESSION array.
* $_SESSION['name'] = 'Bob';
* echo $_SESSION['name'];
* ```
*
* The above example automatically writes the session data. It's handy, but
* the code doesn't stop even if it fails to write the session data, because
* the `write` happens when the code exits. If you want to know the session
* data is correctly written to the Firestore, you need to call
* `session_write_close()` explicitly and then handle `E_USER_WARNING`
* properly like the following example.
*
* Example with error handling:
*
* ```
* // Session handler with error handling:
* use Google\Cloud\Firestore\FirestoreClient;
* use Google\Cloud\Firestore\FirestoreSessionHandler;
*
* $firestore = new FirestoreClient(['projectId' => $projectId]);
* $firestore = new FirestoreClient();
*
* $handler = new FirestoreSessionHandler($firestore);
* session_set_save_handler($handler, true);
* session_save_path('sessions');
* session_start();
*
* // Then read and write the $_SESSION array.
* $_SESSION['name'] = 'Bob';
*
* function handle_session_error($errNo, $errStr, $errFile, $errLine) {
* # We throw an exception here, but you can do whatever you need.
* throw new Exception("$errStr in $errFile on line $errLine", $errNo);
* // We throw an exception here, but you can do whatever you need.
* throw new RuntimeException(
* "$errStr in $errFile on line $errLine",
* $errNo
* );
* }
* set_error_handler('handle_session_error', E_USER_WARNING);
*
* // If `write` fails for any reason, an exception will be thrown.
* session_write_close();
* restore_error_handler();
*
* // You can still read the $_SESSION array after closing the session.
* echo $_SESSION['name'];
* ```
*
* @see http://php.net/manual/en/class.sessionhandlerinterface.php SessionHandlerInterface
*/
class FirestoreSessionHandler implements SessionHandlerInterface
Expand Down Expand Up @@ -165,7 +178,7 @@ public function read($id)
if ($snapshot->exists() && isset($snapshot['data'])) {
return $snapshot->get('data');
}
} catch (Exception $e) {
} catch (ServiceException $e) {
trigger_error(
sprintf('Firestore lookup failed: %s', $e->getMessage()),
E_USER_WARNING
Expand Down Expand Up @@ -193,7 +206,7 @@ function (Transaction $transaction) use ($docRef, $data) {
]);
}
);
} catch (Exception $e) {
} catch (ServiceException $e) {
trigger_error(
sprintf('Firestore upsert failed: %s', $e->getMessage()),
E_USER_WARNING
Expand All @@ -213,7 +226,7 @@ public function destroy($id)
{
try {
$this->collection->document($this->formatId($id))->delete();
} catch (Exception $e) {
} catch (ServiceException $e) {
trigger_error(
sprintf('Firestore delete failed: %s', $e->getMessage()),
E_USER_WARNING
Expand Down Expand Up @@ -243,7 +256,7 @@ public function gc($maxlifetime)
foreach ($query->documents() as $snapshot) {
$snapshot->reference()->delete();
}
} catch (Exception $e) {
} catch (ServiceException $e) {
trigger_error(
sprintf('Session gc failed: %s', $e->getMessage()),
E_USER_WARNING
Expand Down
136 changes: 136 additions & 0 deletions Firestore/tests/Snippet/FirestoreSessionHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php
/**
* Copyright 2019 Google LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Google\Cloud\Firestore\Tests\Snippet;

use Google\Cloud\Core\Testing\Snippet\SnippetTestCase;
use Google\Cloud\Core\Testing\TestHelpers;
use Google\Cloud\Firestore\Connection\ConnectionInterface;
use Google\Cloud\Firestore\FirestoreClient;
use Google\Cloud\Firestore\FirestoreSessionHandler;
use Prophecy\Argument;

/**
* @group firestore
* @group firestore-session
* @runTestsInSeparateProcesses
*/
class FirestoreSessionHandlerTest extends SnippetTestCase
{
const TRANSACTION = 'transaction-id';

private $connection;
private $client;

public static function setUpBeforeClass()
{
parent::setUpBeforeClass();

// Since the tests in this class must run in isolation, they won't be
// recognized as having been covered, and will cause a CI error.
// We can call `snippetFromClass` in the parent process to mark the
// snippets as having been covered.
self::snippetFromClass(FirestoreSessionHandler::class);
self::snippetFromClass(FirestoreSessionHandler::class, 1);
}

public function setUp()
{
$this->connection = $this->prophesize(ConnectionInterface::class);
$this->client = TestHelpers::stub(FirestoreClient::class);
}

public function testClass()
{
$snippet = $this->snippetFromClass(FirestoreSessionHandler::class);
$snippet->replace('$firestore = new FirestoreClient();', '');

$this->connection->batchGetDocuments(Argument::withEntry('documents', Argument::type('array')))
->shouldBeCalled()
->willReturn(new \ArrayIterator([
'found' => [
[
'name' => '',
'fields' => []
]
]
]));

$this->connection->beginTransaction(Argument::any())
->shouldBeCalled()
->willReturn([
'transaction' => self::TRANSACTION
]);

$value = 'name|' . serialize('Bob');
$this->connection->commit(Argument::allOf(
Argument::that(function ($args) use ($value) {
return strpos($args['writes'][0]['update']['name'], 'PHPSESSID:') !== false
&& $args['writes'][0]['update']['fields']['data']['stringValue'] === $value
&& isset($args['writes'][0]['update']['fields']['t']['integerValue']);
}),
Argument::withEntry('transaction', self::TRANSACTION)
))->shouldBeCalled()->willReturn([
'writeResults' => []
]);

$this->client->___setProperty('connection', $this->connection->reveal());
$snippet->addLocal('firestore', $this->client);

$res = $snippet->invoke();
session_write_close();

$this->assertEquals('Bob', $res->output());
}

/**
* @expectedException \RuntimeException
*/
public function testClassErrorHandler()
{
$snippet = $this->snippetFromClass(FirestoreSessionHandler::class, 1);
$snippet->replace('$firestore = new FirestoreClient();', '');

$this->connection->batchGetDocuments(Argument::any())
->shouldBeCalled()
->willReturn(new \ArrayIterator([
'found' => [
[
'name' => '',
'fields' => []
]
]
]));

$this->connection->beginTransaction(Argument::any())
->shouldBeCalled()
->willReturn([
'transaction' => self::TRANSACTION
]);

$this->connection->commit(Argument::any())
->shouldBeCalled()
->will(function () {
trigger_error('oops!', E_USER_WARNING);
});

$this->client->___setProperty('connection', $this->connection->reveal());
$snippet->addLocal('firestore', $this->client);

$res = $snippet->invoke();
}
}
61 changes: 61 additions & 0 deletions Firestore/tests/System/FirestoreSessionHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
/**
* Copyright 2019 Google LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Google\Cloud\Firestore\Tests\System;

use Google\Cloud\Firestore\FirestoreSessionHandler;

/**
* @group firestore
* @group firestore-session
*
* @runTestsInSeparateProcesses
*/
class FirestoreSessionHandlerTest extends FirestoreTestCase
{
public function testSessionHandler()
{
$client = self::$client;

$namespace = uniqid('sess-' . self::COLLECTION_NAME);
$content = 'foo';
$storedValue = 'name|' . serialize($content);

$handler = new FirestoreSessionHandler($client);

session_set_save_handler($handler, true);
session_save_path($namespace);
session_start();

$sessionId = session_id();
$_SESSION['name'] = $content;

session_write_close();
sleep(1);

$hasDocument = false;
$query = $client->collection($namespace);
foreach ($query->documents() as $snapshot) {
self::$deletionQueue->add($snapshot->reference());
if (!$hasDocument) {
$hasDocument = $snapshot['data'] === $storedValue;
}
}

$this->assertTrue($hasDocument);
}
}
Loading

0 comments on commit 9166018

Please sign in to comment.