appwrite/tests/e2e/Services/Databases/Transactions/TransactionsTest.php
2025-09-05 01:02:30 +12:00

1387 lines
55 KiB
PHP

<?php
namespace Tests\E2E\Services\Databases\Transactions;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideClient;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
class TransactionsTest extends Scope
{
use ProjectCustom;
use SideClient;
/**
* Test creating a transaction
*/
public function testCreate(): array
{
// Create database first
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'TransactionTestDatabase'
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Test creating a transaction with default TTL
$response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertArrayHasKey('$id', $response['body']);
$this->assertArrayHasKey('status', $response['body']);
$this->assertArrayHasKey('operations', $response['body']);
$this->assertArrayHasKey('expiresAt', $response['body']);
$this->assertEquals('pending', $response['body']['status']);
$this->assertEquals(0, $response['body']['operations']);
$transactionId1 = $response['body']['$id'];
// Test creating a transaction with custom TTL
$response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'ttl' => 900
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals('pending', $response['body']['status']);
$expiresAt = new \DateTime($response['body']['expiresAt']);
$now = new \DateTime();
$diff = $expiresAt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThan(800, $diff);
$this->assertLessThan(1000, $diff);
$transactionId2 = $response['body']['$id'];
// Test invalid TTL values
$response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'ttl' => 30 // Below minimum
]);
$this->assertEquals(400, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'ttl' => 4000 // Above maximum
]);
$this->assertEquals(400, $response['headers']['status-code']);
return [
'databaseId' => $databaseId,
'transactionId1' => $transactionId1,
'transactionId2' => $transactionId2
];
}
/**
* @depends testCreate
*/
public function testAddOperations(array $data): array
{
$databaseId = $data['databaseId'];
$transactionId = $data['transactionId1'];
// Create a collection for testing
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TransactionOperationsTest',
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Add attributes
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for attribute to be created
sleep(2);
// Add valid operations
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => 'doc1',
'data' => [
'name' => 'Test Document 1'
]
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => 'doc2',
'data' => [
'name' => 'Test Document 2'
]
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals(2, $response['body']['operations']);
// Test adding more operations
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'doc1',
'data' => [
'name' => 'Updated Document 1'
]
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals(3, $response['body']['operations']);
// Test invalid database ID
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => 'invalid_database',
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['name' => 'Test']
]
]
]);
$this->assertEquals(404, $response['headers']['status-code'], 'Invalid database should return 404. Got: ' . json_encode($response['body']));
// Test invalid collection ID
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => 'invalid_collection',
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['name' => 'Test']
]
]
]);
$this->assertEquals(404, $response['headers']['status-code']);
return array_merge($data, [
'collectionId' => $collectionId
]);
}
/**
* @depends testAddOperations
*/
public function testCommit(array $data): void
{
$databaseId = $data['databaseId'];
$collectionId = $data['collectionId'];
$transactionId = $data['transactionId1'];
// Commit the transaction
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('committed', $response['body']['status']);
// Verify documents were created
$documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $documents['headers']['status-code']);
$this->assertEquals(2, $documents['body']['total']);
// Verify the update was applied
$doc1Found = false;
foreach ($documents['body']['documents'] as $doc) {
if ($doc['$id'] === 'doc1') {
$this->assertEquals('Updated Document 1', $doc['name']);
$doc1Found = true;
}
}
$this->assertTrue($doc1Found, 'Document doc1 should exist with updated name');
// Test committing already committed transaction
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
/**
* @depends testCreate
*/
public function testRollback(array $data): void
{
$databaseId = $data['databaseId'];
$transactionId = $data['transactionId2'];
// Create a collection for rollback test
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TransactionRollbackTest',
'documentSecurity' => false,
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Add attribute
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'value',
'size' => 256,
'required' => true,
]);
sleep(2);
// Add operations
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => 'rollback_doc',
'data' => [
'value' => 'Should not exist'
]
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Rollback the transaction
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rollback' => true
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('rolledBack', $response['body']['status']);
// Verify no documents were created
$documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $documents['headers']['status-code']);
$this->assertEquals(0, $documents['body']['total']);
}
/**
* Test transaction expiration
*/
public function testTransactionExpiration(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'ExpirationTestDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'data',
'size' => 256,
'required' => false,
]);
sleep(2);
// Create transaction with minimum TTL (60 seconds)
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'ttl' => 60
]);
$this->assertEquals(201, $transaction['headers']['status-code']);
$transactionId = $transaction['body']['$id'];
// Add operation
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['data' => 'Should expire']
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Verify transaction was created with correct expiration
$txnDetails = $this->client->call(Client::METHOD_GET, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(200, $txnDetails['headers']['status-code']);
$this->assertEquals('pending', $txnDetails['body']['status']);
// Verify expiration time is approximately 60 seconds from now
$expiresAt = new \DateTime($txnDetails['body']['expiresAt']);
$now = new \DateTime();
$diff = $expiresAt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThan(55, $diff);
$this->assertLessThan(65, $diff);
}
/**
* Test maximum operations per transaction
*/
public function testTransactionSizeLimit(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'SizeLimitTestDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [Permission::create(Role::any())],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'value',
'size' => 256,
'required' => false,
]);
sleep(2);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Try to add operations exceeding the limit (assuming limit is 100)
// We'll add 50 operations twice to test incremental limit
$operations = [];
for ($i = 0; $i < 50; $i++) {
$operations[] = [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => 'doc_' . $i,
'data' => ['value' => 'Test ' . $i]
];
}
// First batch should succeed
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => $operations
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals(50, $response['body']['operations']);
// Second batch of 50 more operations
$operations = [];
for ($i = 50; $i < 100; $i++) {
$operations[] = [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'documentId' => 'doc_' . $i,
'action' => 'create',
'data' => ['value' => 'Test ' . $i]
];
}
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => $operations
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals(100, $response['body']['operations']);
// Try to add one more operation - should fail
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => 'doc_overflow',
'data' => ['value' => 'This should fail']
]
]
]);
$this->assertEquals(400, $response['headers']['status-code']);
}
/**
* Test concurrent transactions with conflicting operations
*/
public function testConcurrentTransactionConflicts(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'ConflictTestDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'counter',
'required' => true,
'min' => 0,
'max' => 1000000,
]);
sleep(2);
// Create initial document
$doc = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documentId' => 'shared_doc',
'data' => ['counter' => 100]
]);
$this->assertEquals(201, $doc['headers']['status-code']);
// Create two transactions
$txn1 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$txn2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId1 = $txn1['body']['$id'];
$transactionId2 = $txn2['body']['$id'];
// Both transactions try to update the same document
$this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId1}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'shared_doc',
'data' => ['counter' => 200]
]
]
]);
$this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId2}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'shared_doc',
'data' => ['counter' => 300]
]
]
]);
// Commit first transaction
$response1 = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId1}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response1['headers']['status-code']);
// Commit second transaction - should fail with conflict
$response2 = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(409, $response2['headers']['status-code']); // Conflict
// Verify the document has the value from first transaction
$doc = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $doc['body']['counter']);
}
/**
* Test deleting a document that's being updated in a transaction
*/
public function testDeleteDocumentDuringTransaction(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'DeleteConflictDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'data',
'size' => 256,
'required' => false,
]);
sleep(2);
// Create document
$doc = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documentId' => 'target_doc',
'data' => ['data' => 'Original']
]);
$this->assertEquals(201, $doc['headers']['status-code']);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Add update operation to transaction
$this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'target_doc',
'data' => ['data' => 'Updated in transaction']
]
]
]);
// Delete the document outside of transaction
$response = $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/target_doc", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$this->assertEquals(204, $response['headers']['status-code']);
// Try to commit transaction - should fail because document no longer exists
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(409, $response['headers']['status-code']); // Conflict
}
/**
* Test bulk operations in transactions
*/
public function testBulkOperations(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'BulkOpsDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attributes
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'category',
'size' => 256,
'required' => true,
]);
sleep(3);
// Create some initial documents
for ($i = 1; $i <= 5; $i++) {
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documentId' => 'existing_' . $i,
'data' => [
'name' => 'Existing ' . $i,
'category' => 'old'
]
]);
}
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Add bulk operations
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
// Bulk create
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'bulkCreate',
'data' => [
['$id' => 'bulk_1', 'name' => 'Bulk 1', 'category' => 'new'],
['$id' => 'bulk_2', 'name' => 'Bulk 2', 'category' => 'new'],
['$id' => 'bulk_3', 'name' => 'Bulk 3', 'category' => 'new'],
]
],
// Bulk update
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'bulkUpdate',
'data' => [
'queries' => [Query::equal('category', ['old'])->toString()],
'data' => ['category' => 'updated']
]
],
// Bulk delete
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'bulkDelete',
'data' => [
'queries' => [Query::equal('name', ['Existing 5'])->toString()]
]
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Commit transaction
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Verify results
$documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
// Should have 7 documents (5 existing - 1 deleted + 3 new)
$this->assertEquals(7, $documents['body']['total']);
// Check categories were updated
$oldCategoryCount = 0;
$updatedCategoryCount = 0;
$newCategoryCount = 0;
foreach ($documents['body']['documents'] as $doc) {
switch ($doc['category']) {
case 'old':
$oldCategoryCount++;
break;
case 'updated':
$updatedCategoryCount++;
break;
case 'new':
$newCategoryCount++;
break;
}
}
$this->assertEquals(0, $oldCategoryCount);
$this->assertEquals(4, $updatedCategoryCount); // 4 existing docs updated
$this->assertEquals(3, $newCategoryCount); // 3 new docs
}
/**
* Test transaction with mixed success and failure operations
*/
public function testPartialFailureRollback(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'PartialFailureDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attributes with constraints
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'email',
'size' => 256,
'required' => true,
]);
sleep(2);
// Create unique index on email
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/indexes", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'unique_email',
'type' => 'unique',
'attributes' => ['email'],
]);
sleep(2);
// Create an existing document
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'documentId' => ID::unique(),
'data' => ['email' => 'existing@example.com']
]);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Add operations - mix of valid and invalid
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['email' => 'valid1@example.com'] // Valid
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['email' => 'valid2@example.com'] // Valid
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['email' => 'existing@example.com'] // Will fail - duplicate
],
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['email' => 'valid3@example.com'] // Would be valid but should rollback
],
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Try to commit - should fail and rollback all operations
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(409, $response['headers']['status-code']); // Conflict due to duplicate
// Verify NO new documents were created (atomicity)
$documents = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(1, $documents['body']['total']); // Only the original document
$this->assertEquals('existing@example.com', $documents['body']['documents'][0]['email']);
}
/**
* Test double commit/rollback attempts
*/
public function testDoubleCommitRollback(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'DoubleCommitDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [Permission::create(Role::any())],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'data',
'size' => 256,
'required' => false,
]);
sleep(2);
// Test double commit
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Add operation
$this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'create',
'documentId' => ID::unique(),
'data' => ['data' => 'Test']
]
]
]);
// First commit
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Second commit attempt - should fail
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(400, $response['headers']['status-code']); // Bad request - already committed
// Test double rollback
$transaction2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId2 = $transaction2['body']['$id'];
// First rollback
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rollback' => true
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Second rollback attempt - should fail
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rollback' => true
]);
$this->assertEquals(400, $response['headers']['status-code']); // Bad request - already rolled back
}
/**
* Test operations on non-existent documents
*/
public function testOperationsOnNonExistentDocuments(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'NonExistentDocDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCollection',
'permissions' => [
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Create attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/string", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'data',
'size' => 256,
'required' => false,
]);
sleep(2);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId = $transaction['body']['$id'];
// Try to update non-existent document
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'update',
'documentId' => 'non_existent_doc',
'data' => ['data' => 'Should fail']
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']); // Operation added
// Commit should fail
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(404, $response['headers']['status-code']); // Document not found
// Test delete non-existent document
$transaction2 = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$transactionId2 = $transaction2['body']['$id'];
$response = $this->client->call(Client::METHOD_POST, "/databases/transactions/{$transactionId2}/operations", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'operations' => [
[
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'action' => 'delete',
'documentId' => 'non_existent_doc',
'data' => []
]
]
]);
$this->assertEquals(201, $response['headers']['status-code']);
// Commit should fail
$response = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId2}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'commit' => true
]);
$this->assertEquals(404, $response['headers']['status-code']); // Document not found
}
}