From c2a16fb41f6949a90cab2b9ae97f79e4bbfcec79 Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Fri, 20 Mar 2026 13:27:42 +0000 Subject: [PATCH 01/64] insert and get contexts perform --- .../ServiceDefinition/storage-v1.json | 33 +++++++ Storage/tests/System/ManageObjectsTest.php | 96 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 52aa9050a729..7c9f331868e2 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1490,6 +1490,39 @@ "description": "The modification time of the object metadata in RFC 3339 format. Set initially to object creation time and then updated whenever any metadata of the object changes. This includes changes made by a requester, such as modifying custom metadata, as well as changes made by Cloud Storage on behalf of a requester, such as changing the storage class based on an Object Lifecycle Configuration.", "format": "date-time" } + }, + "contexts" : { + "type": "object", + "description": "Object Contexts provide key-value pair metadata for the object.", + "properties" : { + "custom" : { + "type": "object", + "description": "Custom user-defined contexts, where keys are user-defined strings.", + "additionalProperties": { + "type": "object", + "description": "A single custom context entry.", + "properties": { + "value": { + "type": "string", + "description": "The value associated with the context key." + }, + "createTime": { + "type": "string", + "format": "date-time", + "description": "The creation time of the context. This field is output only." + }, + "updateTime": { + "type": "string", + "format": "date-time", + "description": "The last update time of the context. This field is output only." + } + }, + "required": [ + "value" + ] + } + } + } } }, "ObjectAccessControl": { diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 22152334b876..64c1fd9031f5 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -215,6 +215,102 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } + public function testObjectWithContexts() + { + $objectName = 'test-context-' . uniqid() . '.txt'; + $bucket = self::$bucket; + $content = 'Context Object'; + $contextKey = 'system-test-key'; + $contextValue = 'system-test-value'; + + // 1. Upload Object with Contexts (Insert Operation) + // Document says: Only 'value' is required for insert; timestamps are system-generated. + $object = $bucket->upload($content, [ + 'name' => $objectName, + 'metadata' => [ + 'contexts' => [ + 'custom' => [ + $contextKey => [ + 'value' => $contextValue + ] + ] + ] + ] + ]); + + // 2. Refresh info to get the full metadata from the server + $info = $object->info(); + + // 3. ASSERTIONS: Structure Validation + $this->assertArrayHasKey('contexts', $info, 'Backend response is missing the "contexts" key.'); + $this->assertArrayHasKey('custom', $info['contexts'], 'Contexts structure is missing "custom" grouping.'); + $this->assertArrayHasKey($contextKey, $info['contexts']['custom'], "The key '$contextKey' was not found in the response."); + + $contextData = $info['contexts']['custom'][$contextKey]; + + // 4. ASSERTIONS: Data Integrity + $this->assertEquals( + $contextValue, + $contextData['value'], + 'The retrieved context value does not match the uploaded value.' + ); + + // 5. ASSERTIONS: System Generated Fields (As per Document) + // Document mentions createTime and updateTime are added by GCS. + $this->assertArrayHasKey('createTime', $contextData, 'Server failed to generate createTime.'); + $this->assertArrayHasKey('updateTime', $contextData, 'Server failed to generate updateTime.'); + + // Optional: Validate that timestamps are in the correct ISO 8601 format + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/', + $contextData['createTime'], + 'createTime is not a valid RFC 3339 timestamp.' + ); + + // 6. Cleanup + $object->delete(); + } + + public function testGetContexts() + { + $objectName = 'get-contexts-test-' . uniqid() . '.txt'; + $bucket = self::$bucket; + $content = 'data or get test'; + $contextKey = 'info-key'; + $contextValue = 'info-value'; + + // 1. First we can upload the contexts with the object. Contexts are added as metadata in the upload request. + $object = $bucket->upload($content, [ + 'name' => $objectName, + 'metadata' => [ + 'contexts' => [ + 'custom' => [ + $contextKey => ['value' => $contextValue] + ] + ] + ] + ]); + + // 2. Now 'GET' the object and check if the contexts are present in the response and have the expected values. + $info = $object->info(); + + // 3. ASSERTIONS: Structure Validation + $this->assertArrayHasKey('contexts', $info, 'GET response does not contain contexts.'); + $this->assertArrayHasKey('custom', $info['contexts'], 'Contexts structure is missing "custom" grouping.'); + + $retrievedContext = $info['contexts']['custom'][$contextKey]; + + // 4. ASSERTIONS: Data Integrity + // Now check the Value + $this->assertEquals($contextValue, $retrievedContext['value']); + + // Server-side timestamps check karein (Must exist in GET response) + $this->assertArrayHasKey('createTime', $retrievedContext); + $this->assertArrayHasKey('updateTime', $retrievedContext); + // Cleanup + $object->delete(); + } + public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); From b941e8141e52c4e24010be70a3c8b567906a2fc9 Mon Sep 17 00:00:00 2001 From: Salil Gulati Date: Tue, 24 Mar 2026 09:48:50 +0000 Subject: [PATCH 02/64] Unit Test Cases --- Storage/src/Bucket.php | 40 ++++ Storage/tests/Unit/BucketTest.php | 379 +++++++++++++++++++++++++++++- 2 files changed, 418 insertions(+), 1 deletion(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 4f6b30024980..61da4e0e66d7 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -294,6 +294,11 @@ public function upload($data, array $options = []) throw new \InvalidArgumentException('A name is required when data is of type string or null.'); } + // Validate object contexts if provided in options. This will ensure that the object is not rejected by the server after upload. + if (isset($options['contexts']['custom'])) { + $this->validateContexts($options['contexts']); + } + $encryptionKey = $options['encryptionKey'] ?? null; $encryptionKeySHA256 = $options['encryptionKeySHA256'] ?? null; @@ -314,6 +319,41 @@ public function upload($data, array $options = []) ); } + /** + * Validates object contexts based on storage rules. + * + * @param array $contexts The contexts array to validate. + * @throws \InvalidArgumentException + */ + private function validateContexts(array $contexts) + { + if (!isset($contexts['custom']) || !is_array($contexts['custom'])) { + return; + } + + foreach ($contexts['custom'] as $key => $data) { + // Validate Key + if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { + throw new \InvalidArgumentException('Object context key must start with an alphanumeric character.'); + } + if (strpos($key, '"') !== false) { + throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); + } + + // Validate Value + if (isset($data['value'])) { + $val = (string) $data['value']; + + if (!preg_match('/^[a-zA-Z0-9]/', $val)) { + throw new \InvalidArgumentException('Object context value must start with an alphanumeric character.'); + } + if (strpos($val, '/') !== false || strpos($val, '"') !== false) { + throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); + } + } + } + } + /** * Asynchronously uploads an object. * diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 5ac38e70763f..98b1844472de 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -117,7 +117,7 @@ public function testUploadData() { $this->resumableUploader->upload()->willReturn([ 'name' => 'data.txt', - 'generation' => 123 + 'generation' => 123, ]); $this->connection->insertObject(Argument::any())->willReturn($this->resumableUploader); $bucket = $this->getBucket(); @@ -554,6 +554,383 @@ public function testIsWritableServerException() $bucket->isWritable(); // raises exception } + /** + * ------------------------------------------------------------------------- + * CONTEXT OBJECT SCENARIOS + * ------------------------------------------------------------------------- + * The following methods handle logic related to Context Object workflows. + */ + + public function testCreateWithValidContexts() + { + // 1. Define Valid Data Contexts + $contexts = [ + 'custom' => [ + 'test-key' => ['value' => 'test-value'] + ] + ]; + + // 2. Mock for resumable uploader to return the contexts in the response, simulating a successful upload with contexts. + $this->resumableUploader->upload()->willReturn([ + 'name' => 'data.txt', + 'generation' => 123, + 'contexts' => $contexts // Need to return contexts here to simulate that they are included in the object info after upload + ]); + + // 3. Mock the connection to expect 'contexts' in the insertObject call and return the mocked resumable uploader. + $this->connection->insertObject(Argument::that(function ($args) use ($contexts) { + return isset($args['contexts']) && $args['contexts'] === $contexts; + }))->willReturn($this->resumableUploader->reveal()); + + $bucket = $this->getBucket(); + + // 4. Call the upload method with the defined contexts and verify that the returned object has the contexts in its info. + $object = $bucket->upload('some data to upload', [ + 'name' => 'data.txt', + 'contexts' => $contexts + ]); + + $this->assertInstanceOf(StorageObject::class, $object); + + // 5. Verify context object is avaiable in the Object + $this->assertEquals($contexts, $object->info()['contexts']); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Object context value cannot contain forbidden characters. + */ + public function testCreateWithInvalidContexts() + { + // 1. Expecting an exception. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Object context value cannot contain forbidden characters.'); + + $invalidContexts = [ + 'custom' => [ + 'valid-key' => ['value' => 'invalid/value'] + ] + ]; + + $bucket = $this->getBucket(); + + // 2. Call here. If got exception then case will be pass. + $bucket->upload('data', [ + 'name' => 'test.txt', + 'contexts' => $invalidContexts + ]); + } + + /** + * Test that the library rejects values that do not start with an alphanumeric character. + */ + public function testRejectInvalidLeadingUnicodeValueInContexts() + { + $bucket = $this->getBucket(); + + // CASE 2: Value starts with an emoji (invalid) + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Object context value must start with an alphanumeric character.'); + + $bucket->upload('test data', [ + 'name' => 'test.txt', + 'contexts' => [ + 'custom' => [ + 'my-custom-key' => ['value' => '✨-sparkle'] + ] + ] + ]); + } + + /** + * Test modifying an existing custom context key and value + * This simulates the "Replace all" behaviour. + */ + public function testUpdateAndReplaceContexts() + { + // 1. Define the updated data + $contextKey = 'context-key-1'; + $updatedValue = 'updated-value'; + $newContexts = [ + 'custom' => [ + $contextKey => ['value' => $updatedValue] + ] + ]; + + // 2. Mock the Connection + // We expect patchObject to be called with the new contexts in the arguments + $this->connection->patchObject(Argument::that(function ($args) use ($newContexts) { + return isset($args['contexts']) && $args['contexts'] === $newContexts; + }))->shouldBeCalled()->willReturn([ + 'name' => 'test.txt', + 'contexts' => $newContexts + ]); + + // 3. Initialize the StorageObject with the Mock Connection + // Note: Assuming $this->connection is already a prophesize(Rest::class) in your setUp + $object = new StorageObject( + $this->connection->reveal(), + 'test.txt', + 'my-bucket' + ); + + // 4. Execute the Update + $object->update([ + 'contexts' => $newContexts + ]); + + // 5. Assertions: Check if the internal state of the object was updated + $info = $object->info(); + $this->assertArrayHasKey('contexts', $info); + $this->assertEquals( + $updatedValue, + $info['contexts']['custom'][$contextKey]['value'], + 'The local object info was not updated with the new context value.' + ); + } + + /** + * Test individual patching behaviors: Add, Modify, Remove, and Clear. + * This covers the "Patch an existing object" requirements. + */ + public function testPatchIndividualContexts() + { + $objectName = 'patch-test.txt'; + $bucketName = 'my-bucket'; + + $object = new StorageObject( + $this->connection->reveal(), + $objectName, + $bucketName + ); + + // --- 1. ADDING / MODIFYING INDIVIDUAL CONTEXTS --- + $patchData = [ + 'contexts' => [ + 'custom' => [ + 'new-key' => ['value' => 'brand-new-val'] + ] + ] + ]; + + $this->connection->patchObject(Argument::that(function ($args) use ($patchData) { + return isset($args['contexts']['custom']) && + $args['contexts']['custom'] === $patchData['contexts']['custom']; + }))->shouldBeCalledTimes(1)->willReturn([ + 'name' => $objectName, + 'contexts' => $patchData['contexts'] + ]); + + $object->update($patchData); + + // --- 2. REMOVING INDIVIDUAL CONTEXTS --- + $removeData = [ + 'contexts' => [ + 'custom' => [ + 'key-to-delete' => null + ] + ] + ]; + + $this->connection->patchObject(Argument::that(function ($args) { + // Fix: Use isset() and array_key_exists to prevent "offset on null" + return isset($args['contexts']['custom']) && + array_key_exists('key-to-delete', $args['contexts']['custom']) && + $args['contexts']['custom']['key-to-delete'] === null; + }))->shouldBeCalledTimes(1)->willReturn([ + 'name' => $objectName, + 'contexts' => ['custom' => ['remaining-key' => ['value' => 'stays']]] + ]); + + $object->update($removeData); + + // --- 3. CLEARING ALL CONTEXTS --- + $clearData = [ + 'contexts' => null + ]; + + $this->connection->patchObject(Argument::that(function ($args) { + // For clearing, contexts is explicitly null + return array_key_exists('contexts', $args) && $args['contexts'] === null; + }))->shouldBeCalledTimes(1)->willReturn([ + 'name' => $objectName + ]); + + $object->update($clearData); + } + + /** + * Test rewriting an object with context inheritance and overrides. + */ + public function testRewriteObjectWithContexts() + { + $sourceName = 'source.txt'; + $destName = 'destination.txt'; + $bucketName = 'my-bucket'; + + $object = new StorageObject( + $this->connection->reveal(), + $sourceName, + $bucketName + ); + + // Mocking the "Fake" Server Response + $this->connection->rewriteObject(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'resource' => [ + 'name' => $destName, + 'bucket' => $bucketName, // Essential for the library to create the new object + 'generation' => 1, // Good practice to include + 'contexts' => [ + 'custom' => ['key' => ['value' => 'val']] + ] + ], + 'done' => true + ]); + + // This call stays within your local machine (Unit Test) + $newObject = $object->rewrite($bucketName, ['name' => $destName]); + + $this->assertInstanceOf(StorageObject::class, $newObject); + $this->assertEquals($destName, $newObject->name()); + } + + /** + * Test composing objects with context inheritance and overrides. + * @dataProvider composeContextDataProvider + */ + public function testComposeObjectWithContexts(array $options, array $expectedContexts) + { + $destName = 'composed.txt'; + $bucketName = 'my-bucket'; + $sources = ['source1.txt', 'source2.txt']; + + $bucket = new Bucket($this->connection->reveal(), $bucketName); + + // Mocking the Compose API call + $this->connection->composeObject(Argument::that(function ($args) use ($options) { + // If 'contexts' is in options, it must be in the API args. + // If not, it shouldn't be present at all. + if (isset($options['contexts'])) { + return isset($args['contexts']) && $args['contexts'] === $options['contexts']; + } + return !isset($args['contexts']); + }))->shouldBeCalled()->willReturn([ + 'name' => $destName, + 'bucket' => $bucketName, + 'generation' => 12345, // <--- ADDED THIS TO FIX THE ERROR + 'contexts' => $expectedContexts + ]); + + $composedObject = $bucket->compose($sources, $destName, $options); + + $this->assertInstanceOf(StorageObject::class, $composedObject); + $this->assertEquals($expectedContexts, $composedObject->info()['contexts']); + } + + /** + * Data provider for Inherit and Override scenarios. + */ + public function composeContextDataProvider() + { + $sourceContexts = ['custom' => ['s1-key' => ['value' => 's1-val']]]; + $overrideContexts = ['custom' => ['new-key' => ['value' => 'new-val']]]; + + return [ + 'Inherit from Source' => [[], $sourceContexts], + 'Override with New' => [['contexts' => $overrideContexts], $overrideContexts] + ]; + } + + /** + * Test that getting an object's metadata includes the contexts. + * Fixed: Added projectId() mock call to prevent UnexpectedCallException. + */ + public function testGetMetadataIncludesContexts() + { + $objectName = 'metadata-test.txt'; + $bucketName = 'my-bucket'; + $projectId = 'test-project'; // Dummy project ID + + // 1. Mock the 'projectId' call (Required by the Bucket/Object constructor) + $this->connection->projectId()->willReturn($projectId); + + // 2. Define the metadata response + $metadataResponse = [ + 'name' => $objectName, + 'bucket' => $bucketName, + 'generation' => 12345, + 'contexts' => [ + 'custom' => [ + 'existing-key' => ['value' => 'existing-val'] + ] + ] + ]; + + // 3. Mock the 'getObject' call + $this->connection->getObject(Argument::withEntry('object', $objectName)) + ->shouldBeCalled() + ->willReturn($metadataResponse); + + $bucket = new Bucket($this->connection->reveal(), $bucketName); + + // 4. Action: Retrieve the object + $object = $bucket->object($objectName); + + // 5. Assertions + $info = $object->info(); + + $this->assertArrayHasKey('contexts', $info); + $this->assertEquals( + 'existing-val', + $info['contexts']['custom']['existing-key']['value'] + ); + } + + /** + * Test listing objects with contexts and filtering. + */ + public function testListObjectsWithContextsAndFiltering() + { + $bucketName = 'my-bucket'; + $prefix = 'folder/'; + + // 1. Mock the Connection (Consolidated) + $this->connection->projectId()->willReturn('test-project'); + + // We mock the API to return two objects, each with their own contexts + $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) + ->shouldBeCalled() + ->willReturn([ + 'items' => [ + ['name' => 'file1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], + ['name' => 'file2.txt', 'contexts' => ['custom' => ['k2' => ['value' => 'v2']]]] + ] + ]); + + $bucket = new Bucket($this->connection->reveal(), $bucketName); + + // 2. Action & Assertions (Using foreach for brevity) + $objects = $bucket->objects(['prefix' => $prefix]); + + $count = 0; + foreach ($objects as $index => $object) { + $count++; + $expectedVal = 'v' . $count; + $expectedKey = 'k' . $count; + + // Verify contexts are included in the response for each item + $this->assertEquals( + $expectedVal, + $object->info()['contexts']['custom'][$expectedKey]['value'] + ); + } + + $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); + } + + public function testIam() { $bucketInfo = [ From fa985a803fb66a6446c51dd4676b2fbbbe3fc60e Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 24 Mar 2026 09:57:19 +0000 Subject: [PATCH 03/64] Unit Test Cases --- Storage/tests/Unit/BucketTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 98b1844472de..8d2f2d2d3fa0 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -117,7 +117,7 @@ public function testUploadData() { $this->resumableUploader->upload()->willReturn([ 'name' => 'data.txt', - 'generation' => 123, + 'generation' => 123 ]); $this->connection->insertObject(Argument::any())->willReturn($this->resumableUploader); $bucket = $this->getBucket(); From 409e321c9eddbcdfc925326110f318a9e0e5be90 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 24 Mar 2026 13:55:45 +0000 Subject: [PATCH 04/64] Resolve comments --- .../ServiceDefinition/storage-v1.json | 12 +- Storage/tests/System/ManageObjectsTest.php | 117 +++++++----------- Storage/tests/Unit/BucketTest.php | 28 +---- 3 files changed, 55 insertions(+), 102 deletions(-) diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 7c9f331868e2..93fc39e705f0 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1493,28 +1493,28 @@ }, "contexts" : { "type": "object", - "description": "Object Contexts provide key-value pair metadata for the object.", + "description": "A collection of key-payload pairs attached to an object for metadata and identification purposes.", "properties" : { "custom" : { "type": "object", - "description": "Custom user-defined contexts, where keys are user-defined strings.", + "description": "User-provided object contexts where each entry consists of a unique key and a corresponding payload.", "additionalProperties": { "type": "object", - "description": "A single custom context entry.", + "description": "An individual context entry. Keys and values must start with an alphanumeric character and cannot contain single quotes ('), double quotes (\"), backslashes (\\), or forward slashes (/). Duplicate keys are strictly prohibited within the same object.", "properties": { "value": { "type": "string", - "description": "The value associated with the context key." + "description": "The primary data associated with the context key. Must comply with alphanumeric start and character restriction rules." }, "createTime": { "type": "string", "format": "date-time", - "description": "The creation time of the context. This field is output only." + "description": "The timestamp (RFC 3339) indicating when this specific context was created. This is a read-only system field." }, "updateTime": { "type": "string", "format": "date-time", - "description": "The last update time of the context. This field is output only." + "description": "The timestamp (RFC 3339) indicating the last time this context was modified. This is a read-only system field." } }, "required": [ diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 64c1fd9031f5..978230fc2880 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -215,99 +215,74 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } + /** + * * ------------------------------------------------------------------------- + * CONTEXT OBJECT SCENARIOS + * ------------------------------------------------------------------------- + * The following methods handle logic related to Context Object workflows. + * + * @testObjectWithContexts For insertion of objects with contexts and retrieval of contexts via info() method. + * */ + public function testObjectWithContexts() { - $objectName = 'test-context-' . uniqid() . '.txt'; - $bucket = self::$bucket; - $content = 'Context Object'; - $contextKey = 'system-test-key'; - $contextValue = 'system-test-value'; - - // 1. Upload Object with Contexts (Insert Operation) - // Document says: Only 'value' is required for insert; timestamps are system-generated. - $object = $bucket->upload($content, [ - 'name' => $objectName, - 'metadata' => [ + $objectName = 'test-' . uniqid() . '.txt'; + $object = null; + // Define these as variables so you don't make a typo in the assertion + $testKey = 'insert-test-key'; + $testValue = 'insert-test-value'; + + try { + $object = self::$bucket->upload('content', [ + 'name' => $objectName, + 'metadata' => [ 'contexts' => [ 'custom' => [ - $contextKey => [ - 'value' => $contextValue - ] + $testKey => ['value' => $testValue] ] ] ] - ]); - - // 2. Refresh info to get the full metadata from the server - $info = $object->info(); - - // 3. ASSERTIONS: Structure Validation - $this->assertArrayHasKey('contexts', $info, 'Backend response is missing the "contexts" key.'); - $this->assertArrayHasKey('custom', $info['contexts'], 'Contexts structure is missing "custom" grouping.'); - $this->assertArrayHasKey($contextKey, $info['contexts']['custom'], "The key '$contextKey' was not found in the response."); - - $contextData = $info['contexts']['custom'][$contextKey]; - - // 4. ASSERTIONS: Data Integrity - $this->assertEquals( - $contextValue, - $contextData['value'], - 'The retrieved context value does not match the uploaded value.' - ); - - // 5. ASSERTIONS: System Generated Fields (As per Document) - // Document mentions createTime and updateTime are added by GCS. - $this->assertArrayHasKey('createTime', $contextData, 'Server failed to generate createTime.'); - $this->assertArrayHasKey('updateTime', $contextData, 'Server failed to generate updateTime.'); - - // Optional: Validate that timestamps are in the correct ISO 8601 format - $this->assertMatchesRegularExpression( - '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/', - $contextData['createTime'], - 'createTime is not a valid RFC 3339 timestamp.' - ); - - // 6. Cleanup - $object->delete(); + ]); + $info = $object->info(); + $this->assertEquals( + $testValue, + $info['contexts']['custom'][$testKey]['value'] + ); + } finally { + // This runs even if the assertEquals fails! + if ($object && $object->exists()) { + $object->delete(); + } + } } + /** + * + * @testGetContexts For retrieval of contexts via info() method. + */ + public function testGetContexts() { - $objectName = 'get-contexts-test-' . uniqid() . '.txt'; - $bucket = self::$bucket; - $content = 'data or get test'; + $objectName = 'get-test-' . uniqid() . '.txt'; $contextKey = 'info-key'; $contextValue = 'info-value'; - // 1. First we can upload the contexts with the object. Contexts are added as metadata in the upload request. - $object = $bucket->upload($content, [ + self::$bucket->upload('data', [ 'name' => $objectName, 'metadata' => [ - 'contexts' => [ - 'custom' => [ - $contextKey => ['value' => $contextValue] - ] - ] + 'contexts' => ['custom' => [$contextKey => ['value' => $contextValue]]] ] ]); - // 2. Now 'GET' the object and check if the contexts are present in the response and have the expected values. + // Instead of using the $object from upload, we look it up by name + $object = self::$bucket->object($objectName); $info = $object->info(); - // 3. ASSERTIONS: Structure Validation - $this->assertArrayHasKey('contexts', $info, 'GET response does not contain contexts.'); - $this->assertArrayHasKey('custom', $info['contexts'], 'Contexts structure is missing "custom" grouping.'); - - $retrievedContext = $info['contexts']['custom'][$contextKey]; + $this->assertEquals( + $contextValue, + $info['contexts']['custom'][$contextKey]['value'] + ); - // 4. ASSERTIONS: Data Integrity - // Now check the Value - $this->assertEquals($contextValue, $retrievedContext['value']); - - // Server-side timestamps check karein (Must exist in GET response) - $this->assertArrayHasKey('createTime', $retrievedContext); - $this->assertArrayHasKey('updateTime', $retrievedContext); - // Cleanup $object->delete(); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 8d2f2d2d3fa0..22bb13ff3ffc 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -559,32 +559,30 @@ public function testIsWritableServerException() * CONTEXT OBJECT SCENARIOS * ------------------------------------------------------------------------- * The following methods handle logic related to Context Object workflows. + * + * First test covers creating objects with valid contexts, second test covers creating objects with invalid contexts, */ public function testCreateWithValidContexts() { - // 1. Define Valid Data Contexts $contexts = [ 'custom' => [ 'test-key' => ['value' => 'test-value'] ] ]; - // 2. Mock for resumable uploader to return the contexts in the response, simulating a successful upload with contexts. $this->resumableUploader->upload()->willReturn([ 'name' => 'data.txt', 'generation' => 123, 'contexts' => $contexts // Need to return contexts here to simulate that they are included in the object info after upload ]); - // 3. Mock the connection to expect 'contexts' in the insertObject call and return the mocked resumable uploader. $this->connection->insertObject(Argument::that(function ($args) use ($contexts) { return isset($args['contexts']) && $args['contexts'] === $contexts; }))->willReturn($this->resumableUploader->reveal()); $bucket = $this->getBucket(); - // 4. Call the upload method with the defined contexts and verify that the returned object has the contexts in its info. $object = $bucket->upload('some data to upload', [ 'name' => 'data.txt', 'contexts' => $contexts @@ -592,7 +590,6 @@ public function testCreateWithValidContexts() $this->assertInstanceOf(StorageObject::class, $object); - // 5. Verify context object is avaiable in the Object $this->assertEquals($contexts, $object->info()['contexts']); } @@ -602,7 +599,6 @@ public function testCreateWithValidContexts() */ public function testCreateWithInvalidContexts() { - // 1. Expecting an exception. $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value cannot contain forbidden characters.'); @@ -614,7 +610,6 @@ public function testCreateWithInvalidContexts() $bucket = $this->getBucket(); - // 2. Call here. If got exception then case will be pass. $bucket->upload('data', [ 'name' => 'test.txt', 'contexts' => $invalidContexts @@ -628,7 +623,6 @@ public function testRejectInvalidLeadingUnicodeValueInContexts() { $bucket = $this->getBucket(); - // CASE 2: Value starts with an emoji (invalid) $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value must start with an alphanumeric character.'); @@ -648,7 +642,6 @@ public function testRejectInvalidLeadingUnicodeValueInContexts() */ public function testUpdateAndReplaceContexts() { - // 1. Define the updated data $contextKey = 'context-key-1'; $updatedValue = 'updated-value'; $newContexts = [ @@ -657,7 +650,6 @@ public function testUpdateAndReplaceContexts() ] ]; - // 2. Mock the Connection // We expect patchObject to be called with the new contexts in the arguments $this->connection->patchObject(Argument::that(function ($args) use ($newContexts) { return isset($args['contexts']) && $args['contexts'] === $newContexts; @@ -666,7 +658,6 @@ public function testUpdateAndReplaceContexts() 'contexts' => $newContexts ]); - // 3. Initialize the StorageObject with the Mock Connection // Note: Assuming $this->connection is already a prophesize(Rest::class) in your setUp $object = new StorageObject( $this->connection->reveal(), @@ -674,12 +665,10 @@ public function testUpdateAndReplaceContexts() 'my-bucket' ); - // 4. Execute the Update $object->update([ 'contexts' => $newContexts ]); - // 5. Assertions: Check if the internal state of the object was updated $info = $object->info(); $this->assertArrayHasKey('contexts', $info); $this->assertEquals( @@ -704,7 +693,6 @@ public function testPatchIndividualContexts() $bucketName ); - // --- 1. ADDING / MODIFYING INDIVIDUAL CONTEXTS --- $patchData = [ 'contexts' => [ 'custom' => [ @@ -723,7 +711,6 @@ public function testPatchIndividualContexts() $object->update($patchData); - // --- 2. REMOVING INDIVIDUAL CONTEXTS --- $removeData = [ 'contexts' => [ 'custom' => [ @@ -744,7 +731,6 @@ public function testPatchIndividualContexts() $object->update($removeData); - // --- 3. CLEARING ALL CONTEXTS --- $clearData = [ 'contexts' => null ]; @@ -851,12 +837,10 @@ public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; $bucketName = 'my-bucket'; - $projectId = 'test-project'; // Dummy project ID + $projectId = 'test-project'; - // 1. Mock the 'projectId' call (Required by the Bucket/Object constructor) $this->connection->projectId()->willReturn($projectId); - // 2. Define the metadata response $metadataResponse = [ 'name' => $objectName, 'bucket' => $bucketName, @@ -868,17 +852,14 @@ public function testGetMetadataIncludesContexts() ] ]; - // 3. Mock the 'getObject' call $this->connection->getObject(Argument::withEntry('object', $objectName)) ->shouldBeCalled() ->willReturn($metadataResponse); $bucket = new Bucket($this->connection->reveal(), $bucketName); - // 4. Action: Retrieve the object $object = $bucket->object($objectName); - // 5. Assertions $info = $object->info(); $this->assertArrayHasKey('contexts', $info); @@ -896,10 +877,8 @@ public function testListObjectsWithContextsAndFiltering() $bucketName = 'my-bucket'; $prefix = 'folder/'; - // 1. Mock the Connection (Consolidated) $this->connection->projectId()->willReturn('test-project'); - // We mock the API to return two objects, each with their own contexts $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) ->shouldBeCalled() ->willReturn([ @@ -911,7 +890,6 @@ public function testListObjectsWithContextsAndFiltering() $bucket = new Bucket($this->connection->reveal(), $bucketName); - // 2. Action & Assertions (Using foreach for brevity) $objects = $bucket->objects(['prefix' => $prefix]); $count = 0; From 8f5051f9f14a47d3207a9a8ed23b1df201787650 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 10:01:39 +0000 Subject: [PATCH 05/64] Follow the checklist and resolved comments --- Storage/src/Bucket.php | 32 +++- .../ServiceDefinition/storage-v1.json | 23 +-- Storage/tests/System/ManageObjectsTest.php | 82 +++------- Storage/tests/Unit/BucketTest.php | 150 ++++++------------ 4 files changed, 115 insertions(+), 172 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 61da4e0e66d7..aacf3b97fc57 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -274,6 +274,14 @@ public function exists(array $options = []) * @type array $metadata The full list of available options are outlined * at the [JSON API docs](https://cloud.google.com/storage/docs/json_api/v1/objects/insert#request-body). * @type array $metadata.metadata User-provided metadata, in key/value pairs. + * @type array $metadata.contexts User-defined or system-defined object contexts. + * Each object context is a key-payload pair, where the key provides the + * identification and the payload holds the associated value and additional metadata. + * @type array $metadata.contexts.custom Custom user-defined contexts. Keys must start + * with an alphanumeric character and cannot contain double quotes (`"`). + * @type string $metadata.contexts.custom[].value The value associated with the context. + * Must start with an alphanumeric character and cannot contain double quotes (`"`) + * or forward slashes (`/`). * @type string $encryptionKey A base64 encoded AES-256 customer-supplied * encryption key. If you would prefer to manage encryption * utilizing the Cloud Key Management Service (KMS) please use the @@ -294,7 +302,6 @@ public function upload($data, array $options = []) throw new \InvalidArgumentException('A name is required when data is of type string or null.'); } - // Validate object contexts if provided in options. This will ensure that the object is not rejected by the server after upload. if (isset($options['contexts']['custom'])) { $this->validateContexts($options['contexts']); } @@ -324,7 +331,25 @@ public function upload($data, array $options = []) * * @param array $contexts The contexts array to validate. * @throws \InvalidArgumentException - */ + * + * @example + * ``` + * $promise = $bucket->uploadAsync('Async Content', [ + * 'name' => 'async-file.txt', + * 'metadata' => [ + * 'contexts' => [ + * 'custom' => [ + * 'session-id' => ['value' => 'abc12345'] + * ] + * ] + * ] + * ])->then(function (StorageObject $object) { + * echo 'Uploaded with contexts: ' . $object->name(); + * }); + * + * $promise->wait(); + * ``` + */ private function validateContexts(array $contexts) { if (!isset($contexts['custom']) || !is_array($contexts['custom'])) { @@ -339,11 +364,8 @@ private function validateContexts(array $contexts) if (strpos($key, '"') !== false) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); } - - // Validate Value if (isset($data['value'])) { $val = (string) $data['value']; - if (!preg_match('/^[a-zA-Z0-9]/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric character.'); } diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 93fc39e705f0..f9a37adfb532 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1493,33 +1493,38 @@ }, "contexts" : { "type": "object", - "description": "A collection of key-payload pairs attached to an object for metadata and identification purposes.", + "description": "User-defined or system-defined object contexts. Represented as key-payload pairs, where the key identifies the context and the payload contains the associated value and additional metadata.", "properties" : { "custom" : { "type": "object", "description": "User-provided object contexts where each entry consists of a unique key and a corresponding payload.", "additionalProperties": { "type": "object", - "description": "An individual context entry. Keys and values must start with an alphanumeric character and cannot contain single quotes ('), double quotes (\"), backslashes (\\), or forward slashes (/). Duplicate keys are strictly prohibited within the same object.", + "description": "The payload associated with a user-defined context key.", "properties": { "value": { "type": "string", - "description": "The primary data associated with the context key. Must comply with alphanumeric start and character restriction rules." + "description": "The value of the object contexts.", + "required": true, + "annotations": { + "required": [ + "storage.objects.insert", + "storage.objects.patch", + "storage.objects.update" + ] + } }, "createTime": { "type": "string", "format": "date-time", - "description": "The timestamp (RFC 3339) indicating when this specific context was created. This is a read-only system field." + "description": "The time at which the object contexts was created in RFC 3339 format." }, "updateTime": { "type": "string", "format": "date-time", - "description": "The timestamp (RFC 3339) indicating the last time this context was modified. This is a read-only system field." + "description": "The time at which the object context was las updated in RFC 3339 format." } - }, - "required": [ - "value" - ] + } } } } diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 978230fc2880..a5475c335b65 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -215,74 +215,42 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } - /** - * * ------------------------------------------------------------------------- - * CONTEXT OBJECT SCENARIOS - * ------------------------------------------------------------------------- - * The following methods handle logic related to Context Object workflows. - * - * @testObjectWithContexts For insertion of objects with contexts and retrieval of contexts via info() method. - * */ - - public function testObjectWithContexts() + public function testCreateObjectWithContexts() { $objectName = 'test-' . uniqid() . '.txt'; - $object = null; - // Define these as variables so you don't make a typo in the assertion - $testKey = 'insert-test-key'; - $testValue = 'insert-test-value'; - - try { - $object = self::$bucket->upload('content', [ - 'name' => $objectName, - 'metadata' => [ - 'contexts' => [ - 'custom' => [ - $testKey => ['value' => $testValue] - ] - ] - ] - ]); - $info = $object->info(); - $this->assertEquals( - $testValue, - $info['contexts']['custom'][$testKey]['value'] - ); - } finally { - // This runs even if the assertEquals fails! - if ($object && $object->exists()) { - $object->delete(); - } - } - } - - /** - * - * @testGetContexts For retrieval of contexts via info() method. - */ - - public function testGetContexts() - { - $objectName = 'get-test-' . uniqid() . '.txt'; - $contextKey = 'info-key'; - $contextValue = 'info-value'; + $testKey = 'insert-key'; + $testValue = 'insert-val'; - self::$bucket->upload('data', [ + $object = self::$bucket->upload('content', [ 'name' => $objectName, 'metadata' => [ - 'contexts' => ['custom' => [$contextKey => ['value' => $contextValue]]] + 'contexts' => [ + 'custom' => [$testKey => ['value' => $testValue]] + ] ] ]); - // Instead of using the $object from upload, we look it up by name - $object = self::$bucket->object($objectName); - $info = $object->info(); - $this->assertEquals( - $contextValue, - $info['contexts']['custom'][$contextKey]['value'] + $testValue, + $object->info()['contexts']['custom'][$testKey]['value'] ); + return $object; + } + + /** + * Test 2: Getting metadata of the SAME object passed from Test 1. + * @depends testCreateObjectWithContextss + */ + public function testGetObjectWithContexts(StorageObject $object) + { + // We use the $object passed from the previous test. + $info = $object->info(['projection' => 'full']); + // For debugging purposes, to see the full object metadata including contexts. + // Since we know the key from the previous test (or hardcode it for simplicity) + $this->assertArrayHasKey('contexts', $info); + + // CLEANUP: Always delete at the end of the dependency chain $object->delete(); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 22bb13ff3ffc..e5f128621df7 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -554,63 +554,43 @@ public function testIsWritableServerException() $bucket->isWritable(); // raises exception } - /** - * ------------------------------------------------------------------------- - * CONTEXT OBJECT SCENARIOS - * ------------------------------------------------------------------------- - * The following methods handle logic related to Context Object workflows. - * - * First test covers creating objects with valid contexts, second test covers creating objects with invalid contexts, - */ - - public function testCreateWithValidContexts() + public function testCreateWithObjectContexts() { $contexts = [ 'custom' => [ 'test-key' => ['value' => 'test-value'] ] ]; - $this->resumableUploader->upload()->willReturn([ 'name' => 'data.txt', 'generation' => 123, - 'contexts' => $contexts // Need to return contexts here to simulate that they are included in the object info after upload + 'contexts' => $contexts ]); $this->connection->insertObject(Argument::that(function ($args) use ($contexts) { return isset($args['contexts']) && $args['contexts'] === $contexts; }))->willReturn($this->resumableUploader->reveal()); - $bucket = $this->getBucket(); - - $object = $bucket->upload('some data to upload', [ + $object = $this->getBucket()->upload('some data to upload', [ 'name' => 'data.txt', - 'contexts' => $contexts + 'contexts' => $contexts ]); $this->assertInstanceOf(StorageObject::class, $object); - $this->assertEquals($contexts, $object->info()['contexts']); } - /** - * @expectedException \InvalidArgumentException - * @expectedExceptionMessage Object context value cannot contain forbidden characters. - */ public function testCreateWithInvalidContexts() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value cannot contain forbidden characters.'); - $invalidContexts = [ 'custom' => [ 'valid-key' => ['value' => 'invalid/value'] ] ]; - $bucket = $this->getBucket(); - - $bucket->upload('data', [ + $this->getBucket()->upload('data', [ 'name' => 'test.txt', 'contexts' => $invalidContexts ]); @@ -621,12 +601,10 @@ public function testCreateWithInvalidContexts() */ public function testRejectInvalidLeadingUnicodeValueInContexts() { - $bucket = $this->getBucket(); - $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value must start with an alphanumeric character.'); - $bucket->upload('test data', [ + $this->getBucket()->upload('test data', [ 'name' => 'test.txt', 'contexts' => [ 'custom' => [ @@ -649,7 +627,6 @@ public function testUpdateAndReplaceContexts() $contextKey => ['value' => $updatedValue] ] ]; - // We expect patchObject to be called with the new contexts in the arguments $this->connection->patchObject(Argument::that(function ($args) use ($newContexts) { return isset($args['contexts']) && $args['contexts'] === $newContexts; @@ -658,7 +635,6 @@ public function testUpdateAndReplaceContexts() 'contexts' => $newContexts ]); - // Note: Assuming $this->connection is already a prophesize(Rest::class) in your setUp $object = new StorageObject( $this->connection->reveal(), 'test.txt', @@ -679,70 +655,49 @@ public function testUpdateAndReplaceContexts() } /** - * Test individual patching behaviors: Add, Modify, Remove, and Clear. - * This covers the "Patch an existing object" requirements. - */ - public function testPatchIndividualContexts() + * @dataProvider patchContextProvider + */ + public function testPatchContextScenarios($patchData, $expectedMatchFunc, $mockResponse) { $objectName = 'patch-test.txt'; $bucketName = 'my-bucket'; - - $object = new StorageObject( - $this->connection->reveal(), - $objectName, - $bucketName - ); - - $patchData = [ - 'contexts' => [ - 'custom' => [ - 'new-key' => ['value' => 'brand-new-val'] - ] - ] - ]; - - $this->connection->patchObject(Argument::that(function ($args) use ($patchData) { - return isset($args['contexts']['custom']) && - $args['contexts']['custom'] === $patchData['contexts']['custom']; - }))->shouldBeCalledTimes(1)->willReturn([ - 'name' => $objectName, - 'contexts' => $patchData['contexts'] - ]); - - $object->update($patchData); + $object = new StorageObject($this->connection->reveal(), $objectName, $bucketName); + // Mock the connection once using the flexible matcher + $this->connection->patchObject(Argument::that($expectedMatchFunc)) + ->shouldBeCalledTimes(1) + ->willReturn($mockResponse + ['name' => $objectName]); + // Execute the update + $result = $object->update($patchData); + // Verify response + $this->assertEquals($mockResponse['contexts'] ?? null, $result['contexts'] ?? null); + } - $removeData = [ - 'contexts' => [ - 'custom' => [ - 'key-to-delete' => null - ] + public function patchContextProvider() + { + return [ + 'Update/Add Key' => [ + ['contexts' => ['custom' => ['new-key' => ['value' => 'brand-new-val']]]], + function ($args) { + return ($args['contexts']['custom']['new-key']['value'] ?? '') === 'brand-new-val'; + }, + ['contexts' => ['custom' => ['new-key' => ['value' => 'brand-new-val']]]] + ], + 'Delete Specific Key' => [ + ['contexts' => ['custom' => ['key-to-delete' => null]]], + function ($args) { + return array_key_exists('key-to-delete', $args['contexts']['custom'] ?? []) && + $args['contexts']['custom']['key-to-delete'] === null; + }, + ['contexts' => ['custom' => ['remaining-key' => ['value' => 'stays']]]] + ], + 'Clear All Contexts' => [ + ['contexts' => null], + function ($args) { + return array_key_exists('contexts', $args) && $args['contexts'] === null; + }, + ['contexts' => null] ] ]; - - $this->connection->patchObject(Argument::that(function ($args) { - // Fix: Use isset() and array_key_exists to prevent "offset on null" - return isset($args['contexts']['custom']) && - array_key_exists('key-to-delete', $args['contexts']['custom']) && - $args['contexts']['custom']['key-to-delete'] === null; - }))->shouldBeCalledTimes(1)->willReturn([ - 'name' => $objectName, - 'contexts' => ['custom' => ['remaining-key' => ['value' => 'stays']]] - ]); - - $object->update($removeData); - - $clearData = [ - 'contexts' => null - ]; - - $this->connection->patchObject(Argument::that(function ($args) { - // For clearing, contexts is explicitly null - return array_key_exists('contexts', $args) && $args['contexts'] === null; - }))->shouldBeCalledTimes(1)->willReturn([ - 'name' => $objectName - ]); - - $object->update($clearData); } /** @@ -759,7 +714,6 @@ public function testRewriteObjectWithContexts() $sourceName, $bucketName ); - // Mocking the "Fake" Server Response $this->connection->rewriteObject(Argument::any()) ->shouldBeCalled() @@ -793,10 +747,9 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon $sources = ['source1.txt', 'source2.txt']; $bucket = new Bucket($this->connection->reveal(), $bucketName); - // Mocking the Compose API call $this->connection->composeObject(Argument::that(function ($args) use ($options) { - // If 'contexts' is in options, it must be in the API args. + // If 'contexts' is in options, it must be in the API args. // If not, it shouldn't be present at all. if (isset($options['contexts'])) { return isset($args['contexts']) && $args['contexts'] === $options['contexts']; @@ -837,7 +790,7 @@ public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; $bucketName = 'my-bucket'; - $projectId = 'test-project'; + $projectId = 'test-project'; $this->connection->projectId()->willReturn($projectId); @@ -857,14 +810,11 @@ public function testGetMetadataIncludesContexts() ->willReturn($metadataResponse); $bucket = new Bucket($this->connection->reveal(), $bucketName); - - $object = $bucket->object($objectName); - - $info = $object->info(); + $info = $bucket->object($objectName)->info(); $this->assertArrayHasKey('contexts', $info); $this->assertEquals( - 'existing-val', + 'existing-val', $info['contexts']['custom']['existing-key']['value'] ); } @@ -889,9 +839,7 @@ public function testListObjectsWithContextsAndFiltering() ]); $bucket = new Bucket($this->connection->reveal(), $bucketName); - $objects = $bucket->objects(['prefix' => $prefix]); - $count = 0; foreach ($objects as $index => $object) { $count++; @@ -900,11 +848,11 @@ public function testListObjectsWithContextsAndFiltering() // Verify contexts are included in the response for each item $this->assertEquals( - $expectedVal, + $expectedVal, $object->info()['contexts']['custom'][$expectedKey]['value'] ); } - + $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); } From 49d60ad34a58307e7d809d5b8a66c127852ca730 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 10:16:49 +0000 Subject: [PATCH 06/64] Follow the checklist and resolved comments --- Storage/src/Bucket.php | 9 ++------- .../src/Connection/ServiceDefinition/storage-v1.json | 2 +- Storage/tests/System/ManageObjectsTest.php | 2 +- Storage/tests/Unit/BucketTest.php | 10 +++------- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index aacf3b97fc57..cef447da4529 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -302,7 +302,7 @@ public function upload($data, array $options = []) throw new \InvalidArgumentException('A name is required when data is of type string or null.'); } - if (isset($options['contexts']['custom'])) { + if (isset($options['contexts'])) { $this->validateContexts($options['contexts']); } @@ -328,13 +328,11 @@ public function upload($data, array $options = []) /** * Validates object contexts based on storage rules. - * * @param array $contexts The contexts array to validate. * @throws \InvalidArgumentException - * * @example * ``` - * $promise = $bucket->uploadAsync('Async Content', [ + * $promise = $bucket->upload('Async Content', [ * 'name' => 'async-file.txt', * 'metadata' => [ * 'contexts' => [ @@ -346,9 +344,6 @@ public function upload($data, array $options = []) * ])->then(function (StorageObject $object) { * echo 'Uploaded with contexts: ' . $object->name(); * }); - * - * $promise->wait(); - * ``` */ private function validateContexts(array $contexts) { diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index f9a37adfb532..14dccc860701 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1522,7 +1522,7 @@ "updateTime": { "type": "string", "format": "date-time", - "description": "The time at which the object context was las updated in RFC 3339 format." + "description": "The time at which the object context was last updated in RFC 3339 format." } } } diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index a5475c335b65..47b28b5cad5e 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -240,7 +240,7 @@ public function testCreateObjectWithContexts() /** * Test 2: Getting metadata of the SAME object passed from Test 1. - * @depends testCreateObjectWithContextss + * @depends testCreateObjectWithContexts */ public function testGetObjectWithContexts(StorageObject $object) { diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index e5f128621df7..c8d5501e51d2 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -758,7 +758,7 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon }))->shouldBeCalled()->willReturn([ 'name' => $destName, 'bucket' => $bucketName, - 'generation' => 12345, // <--- ADDED THIS TO FIX THE ERROR + 'generation' => 12345, 'contexts' => $expectedContexts ]); @@ -781,11 +781,7 @@ public function composeContextDataProvider() 'Override with New' => [['contexts' => $overrideContexts], $overrideContexts] ]; } - - /** - * Test that getting an object's metadata includes the contexts. - * Fixed: Added projectId() mock call to prevent UnexpectedCallException. - */ + public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; @@ -852,7 +848,7 @@ public function testListObjectsWithContextsAndFiltering() $object->info()['contexts']['custom'][$expectedKey]['value'] ); } - + $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); } From 59732eda43c4a14eed7ee50b43a49cdbbb2274bc Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 10:55:31 +0000 Subject: [PATCH 07/64] Follow the checklist and resolved comments --- Storage/src/Bucket.php | 6 +++--- Storage/tests/System/ManageObjectsTest.php | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index cef447da4529..9d1b7ca7cddd 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -274,12 +274,12 @@ public function exists(array $options = []) * @type array $metadata The full list of available options are outlined * at the [JSON API docs](https://cloud.google.com/storage/docs/json_api/v1/objects/insert#request-body). * @type array $metadata.metadata User-provided metadata, in key/value pairs. - * @type array $metadata.contexts User-defined or system-defined object contexts. + * @type array $contexts User-defined or system-defined object contexts. * Each object context is a key-payload pair, where the key provides the * identification and the payload holds the associated value and additional metadata. - * @type array $metadata.contexts.custom Custom user-defined contexts. Keys must start + * @type array $contexts.custom Custom user-defined contexts. Keys must start * with an alphanumeric character and cannot contain double quotes (`"`). - * @type string $metadata.contexts.custom[].value The value associated with the context. + * @type string $contexts.custom[].value The value associated with the context. * Must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). * @type string $encryptionKey A base64 encoded AES-256 customer-supplied diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 47b28b5cad5e..9384245135ff 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -249,7 +249,8 @@ public function testGetObjectWithContexts(StorageObject $object) // For debugging purposes, to see the full object metadata including contexts. // Since we know the key from the previous test (or hardcode it for simplicity) $this->assertArrayHasKey('contexts', $info); - + $this->assertArrayHasKey('custom', $info['contexts']); + $this->assertEquals('insert-val', $info['contexts']['custom']['insert-key']['value']); // CLEANUP: Always delete at the end of the dependency chain $object->delete(); } From ffc8b008bc2caf2307de475ab54eba54a22d58aa Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 14:51:55 +0000 Subject: [PATCH 08/64] Fixed gemini related comments --- Storage/src/Bucket.php | 15 -------- Storage/src/Connection/Rest.php | 6 ++++ Storage/src/StorageObject.php | 8 +++++ Storage/tests/System/ManageObjectsTest.php | 12 ++----- Storage/tests/Unit/BucketTest.php | 41 ++++------------------ 5 files changed, 23 insertions(+), 59 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 9d1b7ca7cddd..bbfe34e74b9f 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -329,21 +329,6 @@ public function upload($data, array $options = []) /** * Validates object contexts based on storage rules. * @param array $contexts The contexts array to validate. - * @throws \InvalidArgumentException - * @example - * ``` - * $promise = $bucket->upload('Async Content', [ - * 'name' => 'async-file.txt', - * 'metadata' => [ - * 'contexts' => [ - * 'custom' => [ - * 'session-id' => ['value' => 'abc12345'] - * ] - * ] - * ] - * ])->then(function (StorageObject $object) { - * echo 'Uploaded with contexts: ' . $object->name(); - * }); */ private function validateContexts(array $contexts) { diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index 4766c0ac2ee8..f8fc63e5c76a 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -513,6 +513,12 @@ private function resolveUploadOptions(array $args) $args['metadata']['retention'] = $args['retention']; unset($args['retention']); } + if (isset($args['contexts'])) { + // during object creation context properties go into metadata + // but not into request body + $args['metadata']['contexts'] = $args['contexts']; + unset($args['contexts']); + } unset($args['name']); $args['contentType'] = $args['metadata']['contentType'] ?? MimeType::fromFilename($args['metadata']['name']); diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index dd11ac358bcd..80d1430af416 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -231,6 +231,14 @@ public function delete(array $options = []) * This is the retention configuration set for this object. * @type string $retention.mode The mode of the retention configuration, * which can be either `"Unlocked"` or `"Locked"`. + * @type array $contexts User-defined or system-defined object contexts. + * Each object context is a key-payload pair, where the key provides the + * identification and the payload holds the associated value and additional metadata. + * @type array $contexts.custom Custom user-defined contexts. Keys must start + * with an alphanumeric character and cannot contain double quotes (`"`). + * @type string $contexts.custom[].value The value associated with the context. + * Must start with an alphanumeric character and cannot contain double quotes (`"`) + * or forward slashes (`/`). * @type bool $overrideUnlockedRetention Applicable for objects that * have an unlocked retention configuration. Required to be set to * `true` if the operation includes a retention property that diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 9384245135ff..e60415f0fc2a 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -223,18 +223,14 @@ public function testCreateObjectWithContexts() $object = self::$bucket->upload('content', [ 'name' => $objectName, - 'metadata' => [ - 'contexts' => [ - 'custom' => [$testKey => ['value' => $testValue]] - ] + 'contexts' => [ + 'custom' => [$testKey => ['value' => $testValue]] ] ]); - $this->assertEquals( $testValue, $object->info()['contexts']['custom'][$testKey]['value'] ); - return $object; } @@ -244,14 +240,10 @@ public function testCreateObjectWithContexts() */ public function testGetObjectWithContexts(StorageObject $object) { - // We use the $object passed from the previous test. $info = $object->info(['projection' => 'full']); - // For debugging purposes, to see the full object metadata including contexts. - // Since we know the key from the previous test (or hardcode it for simplicity) $this->assertArrayHasKey('contexts', $info); $this->assertArrayHasKey('custom', $info['contexts']); $this->assertEquals('insert-val', $info['contexts']['custom']['insert-key']['value']); - // CLEANUP: Always delete at the end of the dependency chain $object->delete(); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index c8d5501e51d2..061d86a127cf 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -567,15 +567,12 @@ public function testCreateWithObjectContexts() 'contexts' => $contexts ]); - $this->connection->insertObject(Argument::that(function ($args) use ($contexts) { - return isset($args['contexts']) && $args['contexts'] === $contexts; - }))->willReturn($this->resumableUploader->reveal()); - + $this->connection->insertObject(Argument::any()) + ->willReturn($this->resumableUploader->reveal()); $object = $this->getBucket()->upload('some data to upload', [ 'name' => 'data.txt', 'contexts' => $contexts ]); - $this->assertInstanceOf(StorageObject::class, $object); $this->assertEquals($contexts, $object->info()['contexts']); } @@ -597,13 +594,12 @@ public function testCreateWithInvalidContexts() } /** - * Test that the library rejects values that do not start with an alphanumeric character. + * Test that the library rejects values that do not start with an alphanumeric character. */ public function testRejectInvalidLeadingUnicodeValueInContexts() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value must start with an alphanumeric character.'); - $this->getBucket()->upload('test data', [ 'name' => 'test.txt', 'contexts' => [ @@ -627,7 +623,6 @@ public function testUpdateAndReplaceContexts() $contextKey => ['value' => $updatedValue] ] ]; - // We expect patchObject to be called with the new contexts in the arguments $this->connection->patchObject(Argument::that(function ($args) use ($newContexts) { return isset($args['contexts']) && $args['contexts'] === $newContexts; }))->shouldBeCalled()->willReturn([ @@ -640,11 +635,9 @@ public function testUpdateAndReplaceContexts() 'test.txt', 'my-bucket' ); - $object->update([ 'contexts' => $newContexts ]); - $info = $object->info(); $this->assertArrayHasKey('contexts', $info); $this->assertEquals( @@ -662,13 +655,10 @@ public function testPatchContextScenarios($patchData, $expectedMatchFunc, $mockR $objectName = 'patch-test.txt'; $bucketName = 'my-bucket'; $object = new StorageObject($this->connection->reveal(), $objectName, $bucketName); - // Mock the connection once using the flexible matcher $this->connection->patchObject(Argument::that($expectedMatchFunc)) ->shouldBeCalledTimes(1) ->willReturn($mockResponse + ['name' => $objectName]); - // Execute the update $result = $object->update($patchData); - // Verify response $this->assertEquals($mockResponse['contexts'] ?? null, $result['contexts'] ?? null); } @@ -714,24 +704,20 @@ public function testRewriteObjectWithContexts() $sourceName, $bucketName ); - // Mocking the "Fake" Server Response $this->connection->rewriteObject(Argument::any()) ->shouldBeCalled() ->willReturn([ 'resource' => [ 'name' => $destName, - 'bucket' => $bucketName, // Essential for the library to create the new object - 'generation' => 1, // Good practice to include + 'bucket' => $bucketName, + 'generation' => 1, 'contexts' => [ 'custom' => ['key' => ['value' => 'val']] ] ], 'done' => true ]); - - // This call stays within your local machine (Unit Test) $newObject = $object->rewrite($bucketName, ['name' => $destName]); - $this->assertInstanceOf(StorageObject::class, $newObject); $this->assertEquals($destName, $newObject->name()); } @@ -747,10 +733,7 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon $sources = ['source1.txt', 'source2.txt']; $bucket = new Bucket($this->connection->reveal(), $bucketName); - // Mocking the Compose API call $this->connection->composeObject(Argument::that(function ($args) use ($options) { - // If 'contexts' is in options, it must be in the API args. - // If not, it shouldn't be present at all. if (isset($options['contexts'])) { return isset($args['contexts']) && $args['contexts'] === $options['contexts']; } @@ -758,12 +741,11 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon }))->shouldBeCalled()->willReturn([ 'name' => $destName, 'bucket' => $bucketName, - 'generation' => 12345, + 'generation' => 12345, 'contexts' => $expectedContexts ]); $composedObject = $bucket->compose($sources, $destName, $options); - $this->assertInstanceOf(StorageObject::class, $composedObject); $this->assertEquals($expectedContexts, $composedObject->info()['contexts']); } @@ -781,7 +763,7 @@ public function composeContextDataProvider() 'Override with New' => [['contexts' => $overrideContexts], $overrideContexts] ]; } - + public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; @@ -789,7 +771,6 @@ public function testGetMetadataIncludesContexts() $projectId = 'test-project'; $this->connection->projectId()->willReturn($projectId); - $metadataResponse = [ 'name' => $objectName, 'bucket' => $bucketName, @@ -807,7 +788,6 @@ public function testGetMetadataIncludesContexts() $bucket = new Bucket($this->connection->reveal(), $bucketName); $info = $bucket->object($objectName)->info(); - $this->assertArrayHasKey('contexts', $info); $this->assertEquals( 'existing-val', @@ -824,7 +804,6 @@ public function testListObjectsWithContextsAndFiltering() $prefix = 'folder/'; $this->connection->projectId()->willReturn('test-project'); - $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) ->shouldBeCalled() ->willReturn([ @@ -841,18 +820,13 @@ public function testListObjectsWithContextsAndFiltering() $count++; $expectedVal = 'v' . $count; $expectedKey = 'k' . $count; - - // Verify contexts are included in the response for each item $this->assertEquals( $expectedVal, $object->info()['contexts']['custom'][$expectedKey]['value'] ); } - $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); } - - public function testIam() { $bucketInfo = [ @@ -864,7 +838,6 @@ public function testIam() $this->assertInstanceOf(Iam::class, $bucket->iam()); } - public function testRequesterPays() { $this->connection->getBucket(Argument::withEntry('userProject', 'foo')) From fbf594e23f1b0064d86a98e311f594a3c4599f29 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 15:23:32 +0000 Subject: [PATCH 09/64] Fixed gemini related comments --- Storage/tests/Unit/BucketTest.php | 58 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 061d86a127cf..316474db7186 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -689,37 +689,43 @@ function ($args) { ] ]; } - - /** - * Test rewriting an object with context inheritance and overrides. - */ public function testRewriteObjectWithContexts() { - $sourceName = 'source.txt'; - $destName = 'destination.txt'; - $bucketName = 'my-bucket'; + $contexts = [ + 'custom' => [ + 'rewrite-key' => ['value' => 'rewrite-val'] + ] + ]; + $destBucket = 'other-bucket'; + $destName = 'rewritten-data.txt'; + + $this->connection->rewriteObject(Argument::that(function ($args) use ($contexts) { + }))->willReturn([ + 'rewriteToken' => null, + 'resource' => [ + 'name' => $destName, + 'bucket' => $destBucket, + 'generation' => 456, + 'contexts' => $contexts + ] + ]); - $object = new StorageObject( + $sourceBucket = 'source-bucket'; + $sourceObject = new StorageObject( $this->connection->reveal(), - $sourceName, - $bucketName + 'source-file.txt', + $sourceBucket, + 123, + ['bucket' => $sourceBucket] ); - $this->connection->rewriteObject(Argument::any()) - ->shouldBeCalled() - ->willReturn([ - 'resource' => [ - 'name' => $destName, - 'bucket' => $bucketName, - 'generation' => 1, - 'contexts' => [ - 'custom' => ['key' => ['value' => 'val']] - ] - ], - 'done' => true - ]); - $newObject = $object->rewrite($bucketName, ['name' => $destName]); - $this->assertInstanceOf(StorageObject::class, $newObject); - $this->assertEquals($destName, $newObject->name()); + + $object = $sourceObject->rewrite($destBucket, [ + 'contexts' => $contexts + ]); + $this->assertInstanceOf(StorageObject::class, $object); + $this->assertEquals($destName, $object->name()); + $this->assertArrayHasKey('contexts', $object->info()); + $this->assertEquals($contexts, $object->info()['contexts']); } /** From 37b70515be127c3776fcdbe394bf047ef6ab3eed Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 15:36:46 +0000 Subject: [PATCH 10/64] Style code --- Storage/tests/Unit/BucketTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 316474db7186..767f0618c86b 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -706,7 +706,7 @@ public function testRewriteObjectWithContexts() 'name' => $destName, 'bucket' => $destBucket, 'generation' => 456, - 'contexts' => $contexts + 'contexts' => $contexts ] ]); @@ -716,7 +716,7 @@ public function testRewriteObjectWithContexts() 'source-file.txt', $sourceBucket, 123, - ['bucket' => $sourceBucket] + ['bucket' => $sourceBucket] ); $object = $sourceObject->rewrite($destBucket, [ From e94a00d9d79461fef0cb548bed9f7c55187f3efd Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 25 Mar 2026 15:46:43 +0000 Subject: [PATCH 11/64] Style code --- Storage/src/Bucket.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index bbfe34e74b9f..4cf686f7a627 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -327,17 +327,20 @@ public function upload($data, array $options = []) } /** - * Validates object contexts based on storage rules. - * @param array $contexts The contexts array to validate. - */ + * @param array $contexts The contexts array to validate. + * @return void + */ private function validateContexts(array $contexts) { - if (!isset($contexts['custom']) || !is_array($contexts['custom'])) { + if (!isset($contexts['custom'])) { return; } + if (!is_array($contexts['custom'])) { + throw new \InvalidArgumentException('Object contexts custom field must be an array.'); + } + foreach ($contexts['custom'] as $key => $data) { - // Validate Key if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { throw new \InvalidArgumentException('Object context key must start with an alphanumeric character.'); } From a563bb4b530a625a0c099eca2858eb2dec1085dc Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 05:40:49 +0000 Subject: [PATCH 12/64] Personal Review code --- Storage/src/Bucket.php | 1 - Storage/tests/Unit/BucketTest.php | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 4cf686f7a627..75f07b0ba35b 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -335,7 +335,6 @@ private function validateContexts(array $contexts) if (!isset($contexts['custom'])) { return; } - if (!is_array($contexts['custom'])) { throw new \InvalidArgumentException('Object contexts custom field must be an array.'); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 767f0618c86b..4fefe3190fc5 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -689,6 +689,7 @@ function ($args) { ] ]; } + public function testRewriteObjectWithContexts() { $contexts = [ @@ -700,13 +701,14 @@ public function testRewriteObjectWithContexts() $destName = 'rewritten-data.txt'; $this->connection->rewriteObject(Argument::that(function ($args) use ($contexts) { + return isset($args['contexts']) && $args['contexts'] === $contexts; }))->willReturn([ 'rewriteToken' => null, 'resource' => [ 'name' => $destName, 'bucket' => $destBucket, 'generation' => 456, - 'contexts' => $contexts + 'contexts' => $contexts ] ]); @@ -716,7 +718,7 @@ public function testRewriteObjectWithContexts() 'source-file.txt', $sourceBucket, 123, - ['bucket' => $sourceBucket] + ['bucket' => $sourceBucket] ); $object = $sourceObject->rewrite($destBucket, [ @@ -808,7 +810,6 @@ public function testListObjectsWithContextsAndFiltering() { $bucketName = 'my-bucket'; $prefix = 'folder/'; - $this->connection->projectId()->willReturn('test-project'); $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) ->shouldBeCalled() @@ -833,6 +834,7 @@ public function testListObjectsWithContextsAndFiltering() } $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); } + public function testIam() { $bucketInfo = [ From d258bd94da75f7177383a2afaae36633b0c72e87 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 05:42:53 +0000 Subject: [PATCH 13/64] Style set --- Storage/tests/Unit/BucketTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 4fefe3190fc5..2672940abcbe 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -708,7 +708,7 @@ public function testRewriteObjectWithContexts() 'name' => $destName, 'bucket' => $destBucket, 'generation' => 456, - 'contexts' => $contexts + 'contexts' => $contexts ] ]); @@ -718,7 +718,7 @@ public function testRewriteObjectWithContexts() 'source-file.txt', $sourceBucket, 123, - ['bucket' => $sourceBucket] + ['bucket' => $sourceBucket] ); $object = $sourceObject->rewrite($destBucket, [ From 711c11847a4fc4a95c46fced9c8ed14abd4cc667 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 06:11:13 +0000 Subject: [PATCH 14/64] Style set --- Storage/tests/Unit/BucketTest.php | 56 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 2672940abcbe..482176fbb0d5 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -611,40 +611,48 @@ public function testRejectInvalidLeadingUnicodeValueInContexts() } /** - * Test modifying an existing custom context key and value - * This simulates the "Replace all" behaviour. + * @dataProvider contextUpdateProvider */ - public function testUpdateAndReplaceContexts() + public function testUpdateAndReplaceContexts($inputContexts, $expectedInApi) { - $contextKey = 'context-key-1'; - $updatedValue = 'updated-value'; - $newContexts = [ - 'custom' => [ - $contextKey => ['value' => $updatedValue] - ] - ]; - $this->connection->patchObject(Argument::that(function ($args) use ($newContexts) { - return isset($args['contexts']) && $args['contexts'] === $newContexts; + $this->connection->patchObject(Argument::that(function ($args) use ($expectedInApi) { + if ($expectedInApi === null) { + return !isset($args['contexts']) || $args['contexts'] === null; + } + return isset($args['contexts']) && $args['contexts'] === $expectedInApi; }))->shouldBeCalled()->willReturn([ 'name' => 'test.txt', - 'contexts' => $newContexts + 'contexts' => $expectedInApi ]); $object = new StorageObject( $this->connection->reveal(), 'test.txt', - 'my-bucket' + 'my-bucket', + 1, + ['bucket' => 'my-bucket'] ); - $object->update([ - 'contexts' => $newContexts - ]); + $object->update(['contexts' => $inputContexts]); $info = $object->info(); - $this->assertArrayHasKey('contexts', $info); - $this->assertEquals( - $updatedValue, - $info['contexts']['custom'][$contextKey]['value'], - 'The local object info was not updated with the new context value.' - ); + if ($expectedInApi === null) { + $this->assertArrayNotHasKey('contexts', $info); + } else { + $this->assertArrayHasKey('contexts', $info); + $this->assertEquals($expectedInApi, $info['contexts']); + } + } + + /** + * Data Provider for Update scenarios + */ + public function contextUpdateProvider() + { + $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; + + return [ + 'Valid Update' => [$validContexts['contexts'], $validContexts['contexts']], + 'Empty Array' => [[], []] + ]; } /** @@ -834,7 +842,7 @@ public function testListObjectsWithContextsAndFiltering() } $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); } - + public function testIam() { $bucketInfo = [ From d70d66a9c23fa5d770b48ff7101af5aea4408c6e Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 07:14:06 +0000 Subject: [PATCH 15/64] Required Changes --- Storage/src/Bucket.php | 6 +++++- Storage/src/StorageObject.php | 4 ++++ Storage/tests/System/ManageObjectsTest.php | 1 - Storage/tests/Unit/BucketTest.php | 13 ------------- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 75f07b0ba35b..e1cce5d00150 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -282,6 +282,10 @@ public function exists(array $options = []) * @type string $contexts.custom[].value The value associated with the context. * Must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). + * @type string $contexts.custom.{key}.createTime The time the context + * was created in RFC 3339 format. **(read only)** + * @type string $contexts.custom.{key}.updateTime The time the context + * was last updated in RFC 3339 format. **(read only)** * @type string $encryptionKey A base64 encoded AES-256 customer-supplied * encryption key. If you would prefer to manage encryption * utilizing the Cloud Key Management Service (KMS) please use the @@ -349,7 +353,7 @@ private function validateContexts(array $contexts) if (isset($data['value'])) { $val = (string) $data['value']; if (!preg_match('/^[a-zA-Z0-9]/', $val)) { - throw new \InvalidArgumentException('Object context value must start with an alphanumeric character.'); + throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } if (strpos($val, '/') !== false || strpos($val, '"') !== false) { throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index 80d1430af416..39cdb00b417c 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -239,6 +239,10 @@ public function delete(array $options = []) * @type string $contexts.custom[].value The value associated with the context. * Must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). + * @type string $contexts.custom.{key}.createTime The time the context + * was created in RFC 3339 format. **(read only)** + * @type string $contexts.custom.{key}.updateTime The time the context + * was last updated in RFC 3339 format. **(read only)** * @type bool $overrideUnlockedRetention Applicable for objects that * have an unlocked retention configuration. Required to be set to * `true` if the operation includes a retention property that diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index e60415f0fc2a..45afe912e3c8 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -235,7 +235,6 @@ public function testCreateObjectWithContexts() } /** - * Test 2: Getting metadata of the SAME object passed from Test 1. * @depends testCreateObjectWithContexts */ public function testGetObjectWithContexts(StorageObject $object) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 482176fbb0d5..896a54686339 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -593,9 +593,6 @@ public function testCreateWithInvalidContexts() ]); } - /** - * Test that the library rejects values that do not start with an alphanumeric character. - */ public function testRejectInvalidLeadingUnicodeValueInContexts() { $this->expectException(\InvalidArgumentException::class); @@ -642,9 +639,6 @@ public function testUpdateAndReplaceContexts($inputContexts, $expectedInApi) } } - /** - * Data Provider for Update scenarios - */ public function contextUpdateProvider() { $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; @@ -739,7 +733,6 @@ public function testRewriteObjectWithContexts() } /** - * Test composing objects with context inheritance and overrides. * @dataProvider composeContextDataProvider */ public function testComposeObjectWithContexts(array $options, array $expectedContexts) @@ -766,9 +759,6 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon $this->assertEquals($expectedContexts, $composedObject->info()['contexts']); } - /** - * Data provider for Inherit and Override scenarios. - */ public function composeContextDataProvider() { $sourceContexts = ['custom' => ['s1-key' => ['value' => 's1-val']]]; @@ -811,9 +801,6 @@ public function testGetMetadataIncludesContexts() ); } - /** - * Test listing objects with contexts and filtering. - */ public function testListObjectsWithContextsAndFiltering() { $bucketName = 'my-bucket'; From 41aec816b61f9ed5fe8aa951de7dd732aa87cc77 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 07:34:23 +0000 Subject: [PATCH 16/64] Required Changes --- Storage/src/Bucket.php | 2 +- Storage/src/StorageObject.php | 2 +- Storage/tests/System/ManageObjectsTest.php | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index e1cce5d00150..e7d79e4eeb22 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -279,7 +279,7 @@ public function exists(array $options = []) * identification and the payload holds the associated value and additional metadata. * @type array $contexts.custom Custom user-defined contexts. Keys must start * with an alphanumeric character and cannot contain double quotes (`"`). - * @type string $contexts.custom[].value The value associated with the context. + * @type string $contexts.custom.{key}.value The value associated with the context. * Must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). * @type string $contexts.custom.{key}.createTime The time the context diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index 39cdb00b417c..f6292bd22dd0 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -236,7 +236,7 @@ public function delete(array $options = []) * identification and the payload holds the associated value and additional metadata. * @type array $contexts.custom Custom user-defined contexts. Keys must start * with an alphanumeric character and cannot contain double quotes (`"`). - * @type string $contexts.custom[].value The value associated with the context. + * @type string $contexts.custom.{key}.value The value associated with the context. * Must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). * @type string $contexts.custom.{key}.createTime The time the context diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 45afe912e3c8..084d565e6548 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -31,6 +31,9 @@ class ManageObjectsTest extends StorageTestCase { const DATA = 'data'; + private const CONTEXT_KEY = 'insert-key'; + private const CONTEXT_VALUE = 'insert-val'; + public function testListsObjects() { $foundObjects = []; @@ -218,18 +221,15 @@ public function testObjectRetentionUnlockedMode() public function testCreateObjectWithContexts() { $objectName = 'test-' . uniqid() . '.txt'; - $testKey = 'insert-key'; - $testValue = 'insert-val'; - $object = self::$bucket->upload('content', [ 'name' => $objectName, 'contexts' => [ - 'custom' => [$testKey => ['value' => $testValue]] + 'custom' => [self::CONTEXT_KEY => ['value' => self::CONTEXT_VALUE]] ] ]); $this->assertEquals( - $testValue, - $object->info()['contexts']['custom'][$testKey]['value'] + self::CONTEXT_VALUE, + $object->info()['contexts']['custom'][self::CONTEXT_KEY]['value'] ); return $object; } @@ -242,7 +242,7 @@ public function testGetObjectWithContexts(StorageObject $object) $info = $object->info(['projection' => 'full']); $this->assertArrayHasKey('contexts', $info); $this->assertArrayHasKey('custom', $info['contexts']); - $this->assertEquals('insert-val', $info['contexts']['custom']['insert-key']['value']); + $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); $object->delete(); } From 1e863698199017df92ee995d1e1f348ef4f4db34 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 07:46:17 +0000 Subject: [PATCH 17/64] Required Changes --- Storage/tests/Unit/BucketTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 896a54686339..124be38a1ae9 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -596,7 +596,7 @@ public function testCreateWithInvalidContexts() public function testRejectInvalidLeadingUnicodeValueInContexts() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Object context value must start with an alphanumeric character.'); + $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); $this->getBucket()->upload('test data', [ 'name' => 'test.txt', 'contexts' => [ From 3520f8b1386570118b36a991c20ed6118a156a96 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 26 Mar 2026 14:46:31 +0000 Subject: [PATCH 18/64] As per new comments changes --- Storage/tests/System/ManageObjectsTest.php | 4 +-- Storage/tests/Unit/BucketTest.php | 29 +++++++++------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 084d565e6548..0a3c3dd21835 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -30,7 +30,6 @@ class ManageObjectsTest extends StorageTestCase { const DATA = 'data'; - private const CONTEXT_KEY = 'insert-key'; private const CONTEXT_VALUE = 'insert-val'; @@ -221,7 +220,7 @@ public function testObjectRetentionUnlockedMode() public function testCreateObjectWithContexts() { $objectName = 'test-' . uniqid() . '.txt'; - $object = self::$bucket->upload('content', [ + $object = self::$bucket->upload(self::DATA, [ 'name' => $objectName, 'contexts' => [ 'custom' => [self::CONTEXT_KEY => ['value' => self::CONTEXT_VALUE]] @@ -245,7 +244,6 @@ public function testGetObjectWithContexts(StorageObject $object) $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); $object->delete(); } - public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 124be38a1ae9..4062158d9aec 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -54,7 +54,6 @@ class BucketTest extends TestCase const BUCKET_NAME = 'my-bucket'; const PROJECT_ID = 'my-project'; const NOTIFICATION_ID = '1234'; - private $connection; private $resumableUploader; private $multipartUploader; @@ -554,7 +553,7 @@ public function testIsWritableServerException() $bucket->isWritable(); // raises exception } - public function testCreateWithObjectContexts() + public function testCreateObjectWithContexts() { $contexts = [ 'custom' => [ @@ -577,7 +576,7 @@ public function testCreateWithObjectContexts() $this->assertEquals($contexts, $object->info()['contexts']); } - public function testCreateWithInvalidContexts() + public function testCreateObjectWithInvalidContexts() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value cannot contain forbidden characters.'); @@ -593,7 +592,7 @@ public function testCreateWithInvalidContexts() ]); } - public function testRejectInvalidLeadingUnicodeValueInContexts() + public function testRejectInvalidLeadingUnicodeValueInObjectContexts() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); @@ -610,7 +609,7 @@ public function testRejectInvalidLeadingUnicodeValueInContexts() /** * @dataProvider contextUpdateProvider */ - public function testUpdateAndReplaceContexts($inputContexts, $expectedInApi) + public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi) { $this->connection->patchObject(Argument::that(function ($args) use ($expectedInApi) { if ($expectedInApi === null) { @@ -625,9 +624,9 @@ public function testUpdateAndReplaceContexts($inputContexts, $expectedInApi) $object = new StorageObject( $this->connection->reveal(), 'test.txt', - 'my-bucket', + '', 1, - ['bucket' => 'my-bucket'] + ['bucket' => self::BUCKET_NAME] ); $object->update(['contexts' => $inputContexts]); $info = $object->info(); @@ -655,8 +654,7 @@ public function contextUpdateProvider() public function testPatchContextScenarios($patchData, $expectedMatchFunc, $mockResponse) { $objectName = 'patch-test.txt'; - $bucketName = 'my-bucket'; - $object = new StorageObject($this->connection->reveal(), $objectName, $bucketName); + $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); $this->connection->patchObject(Argument::that($expectedMatchFunc)) ->shouldBeCalledTimes(1) ->willReturn($mockResponse + ['name' => $objectName]); @@ -738,10 +736,9 @@ public function testRewriteObjectWithContexts() public function testComposeObjectWithContexts(array $options, array $expectedContexts) { $destName = 'composed.txt'; - $bucketName = 'my-bucket'; $sources = ['source1.txt', 'source2.txt']; - $bucket = new Bucket($this->connection->reveal(), $bucketName); + $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); $this->connection->composeObject(Argument::that(function ($args) use ($options) { if (isset($options['contexts'])) { return isset($args['contexts']) && $args['contexts'] === $options['contexts']; @@ -749,7 +746,7 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon return !isset($args['contexts']); }))->shouldBeCalled()->willReturn([ 'name' => $destName, - 'bucket' => $bucketName, + 'bucket' => self::BUCKET_NAME, 'generation' => 12345, 'contexts' => $expectedContexts ]); @@ -773,13 +770,12 @@ public function composeContextDataProvider() public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; - $bucketName = 'my-bucket'; $projectId = 'test-project'; $this->connection->projectId()->willReturn($projectId); $metadataResponse = [ 'name' => $objectName, - 'bucket' => $bucketName, + 'bucket' => self::BUCKET_NAME, 'generation' => 12345, 'contexts' => [ 'custom' => [ @@ -792,7 +788,7 @@ public function testGetMetadataIncludesContexts() ->shouldBeCalled() ->willReturn($metadataResponse); - $bucket = new Bucket($this->connection->reveal(), $bucketName); + $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); $info = $bucket->object($objectName)->info(); $this->assertArrayHasKey('contexts', $info); $this->assertEquals( @@ -803,7 +799,6 @@ public function testGetMetadataIncludesContexts() public function testListObjectsWithContextsAndFiltering() { - $bucketName = 'my-bucket'; $prefix = 'folder/'; $this->connection->projectId()->willReturn('test-project'); $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) @@ -815,7 +810,7 @@ public function testListObjectsWithContextsAndFiltering() ] ]); - $bucket = new Bucket($this->connection->reveal(), $bucketName); + $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); $objects = $bucket->objects(['prefix' => $prefix]); $count = 0; foreach ($objects as $index => $object) { From 7e433b884804039bf3c021f00cbbf44abd53fbbd Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 05:04:12 +0000 Subject: [PATCH 19/64] Changes --- Storage/tests/System/ManageObjectsTest.php | 48 ++++++++++++++++++++++ Storage/tests/Unit/BucketTest.php | 9 ++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 0a3c3dd21835..46c4abd2a788 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -244,6 +244,54 @@ public function testGetObjectWithContexts(StorageObject $object) $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); $object->delete(); } + + /** + * @depends testCreateObjectWithContexts + */ + public function testReplaceAllContexts(StorageObject $object) + { + $replacementKey = 'replaced-key-' . uniqid(); + $replacementValue = 'replaced-value'; + + // GCS PUT Behavior: + // Hum contexts ke andar 'custom' ko naye keys ke saath bhej rahe hain. + // Server-side par ye purane 'custom' block ko is naye block se replace kar dega. + $object->update([ + 'contexts' => [ + 'custom' => [ + $replacementKey => ['value' => $replacementValue] + ] + ] + ], [ + // Projection full ensures we see the metadata changes immediately + 'projection' => 'full' + ]); + + // IMPORTANT: Reload is required to fetch the newly replaced metadata + $info = $object->reload(['projection' => 'full']); + + // 1. ASSERTION: Check if the new key exists (Verify PUT worked) + $this->assertArrayHasKey( + $replacementKey, + $info['contexts']['custom'], + 'The replacement key was not found after PUT operation.' + ); + + $this->assertEquals( + $replacementValue, + $info['contexts']['custom'][$replacementKey]['value'] + ); + + // 2. ASSERTION: Check if the OLD key is gone (Verify REPLACE, not Merge) + $this->assertArrayNotHasKey( + self::CONTEXT_KEY, + $info['contexts']['custom'], + 'The old context key was NOT replaced. It still exists (Incorrect Merge behavior).' + ); + + return $object; + } + public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 4062158d9aec..b4862873f478 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -651,7 +651,7 @@ public function contextUpdateProvider() /** * @dataProvider patchContextProvider */ - public function testPatchContextScenarios($patchData, $expectedMatchFunc, $mockResponse) + public function testPatchObjectContext($patchData, $expectedMatchFunc, $mockResponse) { $objectName = 'patch-test.txt'; $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); @@ -770,9 +770,8 @@ public function composeContextDataProvider() public function testGetMetadataIncludesContexts() { $objectName = 'metadata-test.txt'; - $projectId = 'test-project'; - $this->connection->projectId()->willReturn($projectId); + $this->connection->projectId()->willReturn(self::PROJECT_ID); $metadataResponse = [ 'name' => $objectName, 'bucket' => self::BUCKET_NAME, @@ -797,7 +796,7 @@ public function testGetMetadataIncludesContexts() ); } - public function testListObjectsWithContextsAndFiltering() + public function testListObjectsContextsWithFilter() { $prefix = 'folder/'; $this->connection->projectId()->willReturn('test-project'); @@ -822,7 +821,7 @@ public function testListObjectsWithContextsAndFiltering() $object->info()['contexts']['custom'][$expectedKey]['value'] ); } - $this->assertEquals(2, $count, 'Should have listed exactly 2 objects.'); + $this->assertEquals(2, $count); } public function testIam() From 248ffe88a5d98ad70534aae513fcaf238ccb9fde Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 05:11:17 +0000 Subject: [PATCH 20/64] Remove old test method --- Storage/tests/System/ManageObjectsTest.php | 47 ---------------------- 1 file changed, 47 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 46c4abd2a788..75b85b252689 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -245,53 +245,6 @@ public function testGetObjectWithContexts(StorageObject $object) $object->delete(); } - /** - * @depends testCreateObjectWithContexts - */ - public function testReplaceAllContexts(StorageObject $object) - { - $replacementKey = 'replaced-key-' . uniqid(); - $replacementValue = 'replaced-value'; - - // GCS PUT Behavior: - // Hum contexts ke andar 'custom' ko naye keys ke saath bhej rahe hain. - // Server-side par ye purane 'custom' block ko is naye block se replace kar dega. - $object->update([ - 'contexts' => [ - 'custom' => [ - $replacementKey => ['value' => $replacementValue] - ] - ] - ], [ - // Projection full ensures we see the metadata changes immediately - 'projection' => 'full' - ]); - - // IMPORTANT: Reload is required to fetch the newly replaced metadata - $info = $object->reload(['projection' => 'full']); - - // 1. ASSERTION: Check if the new key exists (Verify PUT worked) - $this->assertArrayHasKey( - $replacementKey, - $info['contexts']['custom'], - 'The replacement key was not found after PUT operation.' - ); - - $this->assertEquals( - $replacementValue, - $info['contexts']['custom'][$replacementKey]['value'] - ); - - // 2. ASSERTION: Check if the OLD key is gone (Verify REPLACE, not Merge) - $this->assertArrayNotHasKey( - self::CONTEXT_KEY, - $info['contexts']['custom'], - 'The old context key was NOT replaced. It still exists (Incorrect Merge behavior).' - ); - - return $object; - } - public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); From fdd1e97438ecd866ee49d00f6ab62abac7ba1318 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 10:18:53 +0000 Subject: [PATCH 21/64] Handled System Test cases --- .../ServiceDefinition/storage-v1.json | 61 +++---- Storage/src/StorageClient.php | 12 ++ Storage/tests/System/ManageObjectsTest.php | 162 +++++++++++++++++- 3 files changed, 198 insertions(+), 37 deletions(-) diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 14dccc860701..0874455fb51a 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -1489,44 +1489,37 @@ "type": "string", "description": "The modification time of the object metadata in RFC 3339 format. Set initially to object creation time and then updated whenever any metadata of the object changes. This includes changes made by a requester, such as modifying custom metadata, as well as changes made by Cloud Storage on behalf of a requester, such as changing the storage class based on an Object Lifecycle Configuration.", "format": "date-time" - } - }, - "contexts" : { - "type": "object", - "description": "User-defined or system-defined object contexts. Represented as key-payload pairs, where the key identifies the context and the payload contains the associated value and additional metadata.", - "properties" : { - "custom" : { - "type": "object", - "description": "User-provided object contexts where each entry consists of a unique key and a corresponding payload.", - "additionalProperties": { + }, + "contexts" : { + "type": "object", + "description": "User-defined or system-defined object contexts. Represented as key-payload pairs, where the key identifies the context and the payload contains the associated value and additional metadata.", + "properties" : { + "custom" : { "type": "object", - "description": "The payload associated with a user-defined context key.", - "properties": { - "value": { - "type": "string", - "description": "The value of the object contexts.", - "required": true, - "annotations": { - "required": [ - "storage.objects.insert", - "storage.objects.patch", - "storage.objects.update" - ] + "description": "User-provided object contexts where each entry consists of a unique key and a corresponding payload.", + "additionalProperties": { + "type": "object", + "description": "The payload associated with a user-defined context key.", + "properties": { + "value": { + "type": "string", + "description": "The value of the object contexts.", + "required": true + }, + "createTime": { + "type": "string", + "format": "date-time", + "description": "The time at which the object contexts was created in RFC 3339 format." + }, + "updateTime": { + "type": "string", + "format": "date-time", + "description": "The time at which the object context was last updated in RFC 3339 format." } - }, - "createTime": { - "type": "string", - "format": "date-time", - "description": "The time at which the object contexts was created in RFC 3339 format." - }, - "updateTime": { - "type": "string", - "format": "date-time", - "description": "The time at which the object context was last updated in RFC 3339 format." } } - } - } + } + } } } }, diff --git a/Storage/src/StorageClient.php b/Storage/src/StorageClient.php index 9e9e1c0a32c3..1fdaa7c7d2df 100644 --- a/Storage/src/StorageClient.php +++ b/Storage/src/StorageClient.php @@ -464,6 +464,18 @@ public function restore(string $name, string $generation, array $options = []) * period for objects in seconds. During the retention period an * object cannot be overwritten or deleted. Retention period must * be greater than zero and less than 100 years. + * @type array $contexts User-defined or system-defined object contexts. + * Each object context is a key-payload pair, where the key provides the + * identification and the payload holds the associated value and additional metadata. + * @type array $contexts.custom Custom user-defined contexts. Keys must start + * with an alphanumeric character and cannot contain double quotes (`"`). + * @type string $contexts.custom.{key}.value The value associated with the context. + * Must start with an alphanumeric character and cannot contain double quotes (`"`) + * or forward slashes (`/`). + * @type string $contexts.custom.{key}.createTime The time the context + * was created in RFC 3339 format. **(read only)** + * @type string $contexts.custom.{key}.updateTime The time the context + * was last updated in RFC 3339 format. **(read only)** * @type array $iamConfiguration The bucket's IAM configuration. * @type bool $iamConfiguration.bucketPolicyOnly.enabled this is an alias * for $iamConfiguration.uniformBucketLevelAccess. diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 75b85b252689..95f8a0a307fa 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -32,6 +32,7 @@ class ManageObjectsTest extends StorageTestCase const DATA = 'data'; private const CONTEXT_KEY = 'insert-key'; private const CONTEXT_VALUE = 'insert-val'; + private const PREFIX = 'object-contexts-'; public function testListsObjects() { @@ -219,9 +220,9 @@ public function testObjectRetentionUnlockedMode() public function testCreateObjectWithContexts() { - $objectName = 'test-' . uniqid() . '.txt'; - $object = self::$bucket->upload(self::DATA, [ - 'name' => $objectName, + $bucket = self::$client->createBucket(uniqid('object-contexts-')); + $object = $bucket->upload(self::DATA, [ + 'name' => self::PREFIX . uniqid(), 'contexts' => [ 'custom' => [self::CONTEXT_KEY => ['value' => self::CONTEXT_VALUE]] ] @@ -245,6 +246,161 @@ public function testGetObjectWithContexts(StorageObject $object) $object->delete(); } + /** + * @depends testCreateObjectWithContexts + */ + public function testRemoveEmptyObjectWithContexts(StorageObject $object) + { + $object->update([ + 'contexts' => [ + 'custom' => [ + self::CONTEXT_KEY => (object) [] + ] + ] + ]); + + $newInfo = $object->reload(); + if (isset($newInfo['contexts']['custom'])) { + $this->assertArrayNotHasKey(self::CONTEXT_KEY, $newInfo['contexts']['custom']); + } else { + $this->assertArrayNotHasKey('contexts', $newInfo); + } + $object->delete(); + } + + /** + * @depends testCreateObjectWithContexts + */ + public function testPatchObjectWithContext(StorageObject $object) + { + $newKey = 'added-key-' . uniqid(); + $newValue = 'added-value'; + $modifiedValue = 'modified-value'; + $info = $object->update([ + 'contexts' => [ + 'custom' => [ + self::CONTEXT_KEY => ['value' => $modifiedValue], + $newKey => ['value' => $newValue] + ] + ] + ]); + + $this->assertEquals($modifiedValue, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); + $this->assertEquals($newValue, $info['contexts']['custom'][$newKey]['value']); + + $info = $object->update([ + 'contexts' => [ + 'custom' => [ + $newKey => (object) [] + ] + ] + ]); + $this->assertArrayNotHasKey($newKey, $info['contexts']['custom']); + $this->assertArrayHasKey(self::CONTEXT_KEY, $info['contexts']['custom'], 'Original key should still exist.'); + + $info = $object->update([ + 'contexts' => [ + 'custom' => (object) [] + ] + ]); + $hasContexts = isset($info['contexts']['custom']) && !empty($info['contexts']['custom']); + $this->assertFalse($hasContexts, 'All contexts should have been cleared.'); + $object->delete(); + } + + /** + * @depends testCreateObjectWithContexts + */ + public function testRewriteObjectWithContexts(StorageObject $source) + { + $inherited = $source->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); + $info = $inherited->info(); + + $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); + + $overrideKey = 'override-key'; + $overrideVal = 'override-val'; + $overridden = $source->rewrite(self::$bucket, [ + 'name' => 'override-' . uniqid(), + 'contexts' => ['custom' => [$overrideKey => ['value' => $overrideVal]]] + ]); + + $info = $overridden->info(); + $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); + $this->assertArrayNotHasKey(self::CONTEXT_KEY, $info['contexts']['custom']); + + $inherited->delete(); + $overridden->delete(); + $source->delete(); + } + + /** + * @depends testCreateObjectWithContexts + */ + public function testComposeObjectWithContexts(StorageObject $source1) + { + $bucket = self::$client->bucket($source1->info()['bucket']); + $s2Key = 's2-key'; + + $source2 = $bucket->upload(self::DATA, [ + 'name' => self::PREFIX . 's2-' . uniqid(), + 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] + ]); + $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); + $this->assertEquals(self::CONTEXT_VALUE, $inherit->info()['contexts']['custom'][self::CONTEXT_KEY]['value']); + + $oKey = 'c-override'; + $oVal = 'c-val'; + $override = $bucket->compose([$source1, $source2], 'c-ovr-' . uniqid() . '.txt'); + $info = $override->update([ + 'contexts' => [ + 'custom' => [ + $oKey => ['value' => $oVal], + self::CONTEXT_KEY => (object) [] + ] + ] + ]); + + $this->assertEquals($oVal, $info['contexts']['custom'][$oKey]['value']); + $this->assertArrayNotHasKey(self::CONTEXT_KEY, $info['contexts']['custom']); + array_map(fn($o) => $o->delete(), [$inherit, $override, $source1, $source2]); + } + + /** + * @depends testCreateObjectWithContexts + */ + public function testListObjectsWithContextFilter(StorageObject $object) + { + $bucket = self::$client->bucket($object->info()['bucket']); + $prefix = self::PREFIX . 'flt-' . uniqid(); + $key = 'key-' . uniqid(); + $val = 'val-' . uniqid(); + + $other = $bucket->upload(self::DATA, [ + 'name' => "$prefix.txt", + 'contentType' => 'text/plain', + 'contexts' => ['custom' => [$key => ['value' => $val]]] + ]); + + $filter = sprintf('contexts.custom.%s.value="%s"', $key, $val); + $objects = []; + for ($i = 0; $i < 3 && count($objects) !== 1; $i++) { + if ($i > 0) { + sleep(2); + } + $objects = iterator_to_array($bucket->objects(['filter' => $filter, 'prefix' => $prefix])); + } + + $this->assertCount(1, $objects); + $this->assertEquals($other->name(), $objects[0]->name()); + $presence = iterator_to_array($bucket->objects([ + 'filter' => "contexts.custom.$key:*", + 'prefix' => $prefix + ])); + $this->assertCount(1, $presence); + $other->delete(); + } + public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); From 902eb867f70e2d998d70365a66803ab084b011a1 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 10:54:21 +0000 Subject: [PATCH 22/64] Gemini review and style check --- Storage/src/Bucket.php | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index e7d79e4eeb22..a8ff23627480 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -347,17 +347,32 @@ private function validateContexts(array $contexts) if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { throw new \InvalidArgumentException('Object context key must start with an alphanumeric character.'); } - if (strpos($key, '"') !== false) { + if (strpos((string) $key, '"') !== false) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); } - if (isset($data['value'])) { - $val = (string) $data['value']; - if (!preg_match('/^[a-zA-Z0-9]/', $val)) { - throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); - } - if (strpos($val, '/') !== false || strpos($val, '"') !== false) { - throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); - } + + if (!is_array($data)) { + throw new \InvalidArgumentException(sprintf( + 'Context data for key "%s" must be an array.', + $key + )); + } + + if (!isset($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context for key "%s" must have a \'value\' property.', + $key + )); + } + + $val = (string) $data['value']; + if (!preg_match('/^[a-zA-Z0-9]/', $val) || preg_match('/[\/"]/', $val)) { + throw new \InvalidArgumentException(sprintf( + 'Context value "%s" for key "%s" is invalid. Values must start with an ' . + 'alphanumeric character and cannot contain forward slashes (/) or double quotes (").', + $val, + $key + )); } } } From e87efaf12da10a9fa6855e22ed3daf42f83c21d6 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 11:08:28 +0000 Subject: [PATCH 23/64] Style Check --- Storage/src/Bucket.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index a8ff23627480..7297d3cef50d 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -345,7 +345,7 @@ private function validateContexts(array $contexts) foreach ($contexts['custom'] as $key => $data) { if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { - throw new \InvalidArgumentException('Object context key must start with an alphanumeric character.'); + throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); } if (strpos((string) $key, '"') !== false) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); @@ -368,8 +368,7 @@ private function validateContexts(array $contexts) $val = (string) $data['value']; if (!preg_match('/^[a-zA-Z0-9]/', $val) || preg_match('/[\/"]/', $val)) { throw new \InvalidArgumentException(sprintf( - 'Context value "%s" for key "%s" is invalid. Values must start with an ' . - 'alphanumeric character and cannot contain forward slashes (/) or double quotes (").', + 'Object context value cannot contain forbidden characters.', $val, $key )); From e78441dcb3856a9b0adb4dd02d726645421a09d0 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 11:20:33 +0000 Subject: [PATCH 24/64] Style Check --- Storage/src/Bucket.php | 23 +++++++++-------------- Storage/tests/Unit/BucketTest.php | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 7297d3cef50d..020559c8f7f0 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -344,20 +344,12 @@ private function validateContexts(array $contexts) } foreach ($contexts['custom'] as $key => $data) { - if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { - throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); - } - if (strpos((string) $key, '"') !== false) { - throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); - } - if (!is_array($data)) { throw new \InvalidArgumentException(sprintf( 'Context data for key "%s" must be an array.', $key )); } - if (!isset($data['value'])) { throw new \InvalidArgumentException(sprintf( 'Context for key "%s" must have a \'value\' property.', @@ -366,12 +358,15 @@ private function validateContexts(array $contexts) } $val = (string) $data['value']; - if (!preg_match('/^[a-zA-Z0-9]/', $val) || preg_match('/[\/"]/', $val)) { - throw new \InvalidArgumentException(sprintf( - 'Object context value cannot contain forbidden characters.', - $val, - $key - )); + if (!preg_match('/^[a-zA-Z0-9]/', $val)) { + throw new \InvalidArgumentException( + 'Object context value must start with an alphanumeric.' + ); + } + if (strpos($val, '/') !== false || strpos($val, '"') !== false) { + throw new \InvalidArgumentException( + 'Object context value cannot contain forbidden characters.' + ); } } } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index b4862873f478..615d0e503652 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -595,7 +595,7 @@ public function testCreateObjectWithInvalidContexts() public function testRejectInvalidLeadingUnicodeValueInObjectContexts() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); + $this->expectExceptionMessage('Object context value must start with an alphanumeric.');s $this->getBucket()->upload('test data', [ 'name' => 'test.txt', 'contexts' => [ From 5bb84fbab59be1fa56b0f4190a1396f8e1d680d3 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 12:05:50 +0000 Subject: [PATCH 25/64] Pending scenario covers --- Storage/src/Bucket.php | 16 ++--- Storage/tests/System/ManageObjectsTest.php | 75 +++++++++++++--------- Storage/tests/Unit/BucketTest.php | 59 ++++++++++++----- 3 files changed, 93 insertions(+), 57 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 020559c8f7f0..0f504580a1d6 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -342,8 +342,13 @@ private function validateContexts(array $contexts) if (!is_array($contexts['custom'])) { throw new \InvalidArgumentException('Object contexts custom field must be an array.'); } - foreach ($contexts['custom'] as $key => $data) { + if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { + throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); + } + if (strpos((string) $key, '"') !== false) { + throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); + } if (!is_array($data)) { throw new \InvalidArgumentException(sprintf( 'Context data for key "%s" must be an array.', @@ -356,17 +361,12 @@ private function validateContexts(array $contexts) $key )); } - $val = (string) $data['value']; if (!preg_match('/^[a-zA-Z0-9]/', $val)) { - throw new \InvalidArgumentException( - 'Object context value must start with an alphanumeric.' - ); + throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } if (strpos($val, '/') !== false || strpos($val, '"') !== false) { - throw new \InvalidArgumentException( - 'Object context value cannot contain forbidden characters.' - ); + throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); } } } diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 95f8a0a307fa..390b27e4fa00 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -30,9 +30,9 @@ class ManageObjectsTest extends StorageTestCase { const DATA = 'data'; - private const CONTEXT_KEY = 'insert-key'; - private const CONTEXT_VALUE = 'insert-val'; - private const PREFIX = 'object-contexts-'; + const CONTEXT_OBJECT_KEY = 'insert-key'; + const CONTEXT_OBJECT_VALUE = 'insert-val'; + const CONTEXT_OBJECT_PREFIX = 'object-contexts-'; public function testListsObjects() { @@ -222,14 +222,14 @@ public function testCreateObjectWithContexts() { $bucket = self::$client->createBucket(uniqid('object-contexts-')); $object = $bucket->upload(self::DATA, [ - 'name' => self::PREFIX . uniqid(), + 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), 'contexts' => [ - 'custom' => [self::CONTEXT_KEY => ['value' => self::CONTEXT_VALUE]] + 'custom' => [self::CONTEXT_OBJECT_KEY => ['value' => self::CONTEXT_OBJECT_VALUE]] ] ]); $this->assertEquals( - self::CONTEXT_VALUE, - $object->info()['contexts']['custom'][self::CONTEXT_KEY]['value'] + self::CONTEXT_OBJECT_VALUE, + $object->info()['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value'] ); return $object; } @@ -242,7 +242,7 @@ public function testGetObjectWithContexts(StorageObject $object) $info = $object->info(['projection' => 'full']); $this->assertArrayHasKey('contexts', $info); $this->assertArrayHasKey('custom', $info['contexts']); - $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); $object->delete(); } @@ -254,14 +254,14 @@ public function testRemoveEmptyObjectWithContexts(StorageObject $object) $object->update([ 'contexts' => [ 'custom' => [ - self::CONTEXT_KEY => (object) [] + self::CONTEXT_OBJECT_KEY => (object) [] ] ] ]); $newInfo = $object->reload(); if (isset($newInfo['contexts']['custom'])) { - $this->assertArrayNotHasKey(self::CONTEXT_KEY, $newInfo['contexts']['custom']); + $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $newInfo['contexts']['custom']); } else { $this->assertArrayNotHasKey('contexts', $newInfo); } @@ -279,13 +279,13 @@ public function testPatchObjectWithContext(StorageObject $object) $info = $object->update([ 'contexts' => [ 'custom' => [ - self::CONTEXT_KEY => ['value' => $modifiedValue], + self::CONTEXT_OBJECT_KEY => ['value' => $modifiedValue], $newKey => ['value' => $newValue] ] ] ]); - $this->assertEquals($modifiedValue, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); + $this->assertEquals($modifiedValue, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); $this->assertEquals($newValue, $info['contexts']['custom'][$newKey]['value']); $info = $object->update([ @@ -296,7 +296,7 @@ public function testPatchObjectWithContext(StorageObject $object) ] ]); $this->assertArrayNotHasKey($newKey, $info['contexts']['custom']); - $this->assertArrayHasKey(self::CONTEXT_KEY, $info['contexts']['custom'], 'Original key should still exist.'); + $this->assertArrayHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); $info = $object->update([ 'contexts' => [ @@ -304,7 +304,6 @@ public function testPatchObjectWithContext(StorageObject $object) ] ]); $hasContexts = isset($info['contexts']['custom']) && !empty($info['contexts']['custom']); - $this->assertFalse($hasContexts, 'All contexts should have been cleared.'); $object->delete(); } @@ -316,7 +315,7 @@ public function testRewriteObjectWithContexts(StorageObject $source) $inherited = $source->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); $info = $inherited->info(); - $this->assertEquals(self::CONTEXT_VALUE, $info['contexts']['custom'][self::CONTEXT_KEY]['value']); + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); $overrideKey = 'override-key'; $overrideVal = 'override-val'; @@ -327,7 +326,7 @@ public function testRewriteObjectWithContexts(StorageObject $source) $info = $overridden->info(); $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); - $this->assertArrayNotHasKey(self::CONTEXT_KEY, $info['contexts']['custom']); + $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); $inherited->delete(); $overridden->delete(); @@ -343,11 +342,11 @@ public function testComposeObjectWithContexts(StorageObject $source1) $s2Key = 's2-key'; $source2 = $bucket->upload(self::DATA, [ - 'name' => self::PREFIX . 's2-' . uniqid(), + 'name' => self::CONTEXT_OBJECT_PREFIX . 's2-' . uniqid(), 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] ]); $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); - $this->assertEquals(self::CONTEXT_VALUE, $inherit->info()['contexts']['custom'][self::CONTEXT_KEY]['value']); + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $inherit->info()['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); $oKey = 'c-override'; $oVal = 'c-val'; @@ -356,48 +355,60 @@ public function testComposeObjectWithContexts(StorageObject $source1) 'contexts' => [ 'custom' => [ $oKey => ['value' => $oVal], - self::CONTEXT_KEY => (object) [] + self::CONTEXT_OBJECT_KEY => (object) [] ] ] ]); $this->assertEquals($oVal, $info['contexts']['custom'][$oKey]['value']); - $this->assertArrayNotHasKey(self::CONTEXT_KEY, $info['contexts']['custom']); + $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); array_map(fn($o) => $o->delete(), [$inherit, $override, $source1, $source2]); } /** * @depends testCreateObjectWithContexts */ - public function testListObjectsWithContextFilter(StorageObject $object) + public function testListObjectsWithContextFilters(StorageObject $object) { $bucket = self::$client->bucket($object->info()['bucket']); - $prefix = self::PREFIX . 'flt-' . uniqid(); - $key = 'key-' . uniqid(); - $val = 'val-' . uniqid(); + $prefix = self::CONTEXT_OBJECT_PREFIX . 'flt-' . uniqid(); + $uKey = 'key-✨-' . uniqid(); + $uVal = 'val-🚀-' . uniqid(); $other = $bucket->upload(self::DATA, [ - 'name' => "$prefix.txt", + 'name' => "$prefix-target.txt", 'contentType' => 'text/plain', - 'contexts' => ['custom' => [$key => ['value' => $val]]] + 'contexts' => ['custom' => [$uKey => ['value' => $uVal]]] ]); - $filter = sprintf('contexts.custom.%s.value="%s"', $key, $val); + $filter = sprintf('contexts.custom.%s.value="%s"', $uKey, $uVal); $objects = []; - for ($i = 0; $i < 3 && count($objects) !== 1; $i++) { + for ($i = 0; $i < 4 && count($objects) !== 1; $i++) { if ($i > 0) { sleep(2); } $objects = iterator_to_array($bucket->objects(['filter' => $filter, 'prefix' => $prefix])); } - $this->assertCount(1, $objects); - $this->assertEquals($other->name(), $objects[0]->name()); + $this->assertCount(1, $objects, 'Exact match filter failed.'); + $this->assertEquals($uVal, $objects[0]->info()['contexts']['custom'][$uKey]['value']); + $presence = iterator_to_array($bucket->objects([ - 'filter' => "contexts.custom.$key:*", + 'filter' => "contexts.custom.$uKey:*", + 'prefix' => $prefix + ])); + $this->assertCount(1, $presence, 'Key presence (wildcard) filter failed.'); + $absence = iterator_to_array($bucket->objects([ + 'filter' => "-contexts.custom.$uKey", + 'prefix' => $prefix + ])); + + $this->assertCount(0, $absence, 'Key absence filter should return zero for this prefix.'); + $absenceVal = iterator_to_array($bucket->objects([ + 'filter' => "-contexts.custom.$uKey.value=\"wrong-val\"", 'prefix' => $prefix ])); - $this->assertCount(1, $presence); + $this->assertCount(1, $absenceVal, 'Value absence filter failed.'); $other->delete(); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 615d0e503652..18c9f26f9843 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -595,7 +595,7 @@ public function testCreateObjectWithInvalidContexts() public function testRejectInvalidLeadingUnicodeValueInObjectContexts() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Object context value must start with an alphanumeric.');s + $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); $this->getBucket()->upload('test data', [ 'name' => 'test.txt', 'contexts' => [ @@ -796,32 +796,57 @@ public function testGetMetadataIncludesContexts() ); } - public function testListObjectsContextsWithFilter() + /** + * @dataProvider contextFilterProvider + */ + public function testListObjectsWithContextFilters($filter, $expectedItems) { - $prefix = 'folder/'; $this->connection->projectId()->willReturn('test-project'); - $this->connection->listObjects(Argument::withEntry('prefix', $prefix)) + $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ - 'items' => [ - ['name' => 'file1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], - ['name' => 'file2.txt', 'contexts' => ['custom' => ['k2' => ['value' => 'v2']]]] - ] + 'items' => $expectedItems ]); $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); - $objects = $bucket->objects(['prefix' => $prefix]); - $count = 0; - foreach ($objects as $index => $object) { - $count++; - $expectedVal = 'v' . $count; - $expectedKey = 'k' . $count; + $objects = iterator_to_array($bucket->objects(['filter' => $filter])); + + $this->assertCount(count($expectedItems), $objects); + if (count($objects) > 0 && isset($expectedItems[0]['contexts'])) { $this->assertEquals( - $expectedVal, - $object->info()['contexts']['custom'][$expectedKey]['value'] + $expectedItems[0]['contexts']['custom'], + $objects[0]->info()['contexts']['custom'] ); } - $this->assertEquals(2, $count); + } + + public function contextFilterProvider() + { + $unicodeKey = 'key-✨'; + $unicodeVal = 'val-🚀'; + + return [ + 'Presence of key/value pair' => [ + 'contexts.custom.k1.value="v1"', + [['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]]] + ], + 'Absence of key/value pair' => [ + '-contexts.custom.k1.value="v1"', + [['name' => 'f2.txt']] + ], + 'Presence of key regardless of value' => [ + 'contexts.custom.k1:*', + [['name' => 'f1.txt']] + ], + 'Absence of key regardless of value' => [ + '-contexts.custom.k1', + [] + ], + 'Unicode key/value pair' => [ + sprintf('contexts.custom.%s.value="%s"', $unicodeKey, $unicodeVal), + [['name' => 'unicode.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]]] + ] + ]; } public function testIam() From f31760e84d0b632fdece0c56a7b2249d025475a8 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 12:21:24 +0000 Subject: [PATCH 26/64] Pending scenario covers --- Storage/tests/System/ManageObjectsTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 390b27e4fa00..9d2cd9e7649a 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -346,7 +346,9 @@ public function testComposeObjectWithContexts(StorageObject $source1) 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] ]); $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $inherit->info()['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, + $inherit->info()['contexts']['custom'] + [self::CONTEXT_OBJECT_KEY]['value']); $oKey = 'c-override'; $oVal = 'c-val'; From d1fb39c93faf94e2292c52a06b5b06607133f582 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 12:26:32 +0000 Subject: [PATCH 27/64] Pending scenario covers --- Storage/tests/System/ManageObjectsTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 9d2cd9e7649a..2fc83e5a474e 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -346,9 +346,8 @@ public function testComposeObjectWithContexts(StorageObject $source1) 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] ]); $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, - $inherit->info()['contexts']['custom'] - [self::CONTEXT_OBJECT_KEY]['value']); + $custom = $inherit->info()['contexts']['custom']; + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $custom[self::CONTEXT_OBJECT_KEY]['value']); $oKey = 'c-override'; $oVal = 'c-val'; From eda39e8a753e494ec7cfe3a3cdab6a723e478fb1 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 15:09:20 +0000 Subject: [PATCH 28/64] Pending scenario covers --- Storage/src/StorageClient.php | 12 ------------ Storage/tests/System/ManageObjectsTest.php | 20 +++++--------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/Storage/src/StorageClient.php b/Storage/src/StorageClient.php index 1fdaa7c7d2df..9e9e1c0a32c3 100644 --- a/Storage/src/StorageClient.php +++ b/Storage/src/StorageClient.php @@ -464,18 +464,6 @@ public function restore(string $name, string $generation, array $options = []) * period for objects in seconds. During the retention period an * object cannot be overwritten or deleted. Retention period must * be greater than zero and less than 100 years. - * @type array $contexts User-defined or system-defined object contexts. - * Each object context is a key-payload pair, where the key provides the - * identification and the payload holds the associated value and additional metadata. - * @type array $contexts.custom Custom user-defined contexts. Keys must start - * with an alphanumeric character and cannot contain double quotes (`"`). - * @type string $contexts.custom.{key}.value The value associated with the context. - * Must start with an alphanumeric character and cannot contain double quotes (`"`) - * or forward slashes (`/`). - * @type string $contexts.custom.{key}.createTime The time the context - * was created in RFC 3339 format. **(read only)** - * @type string $contexts.custom.{key}.updateTime The time the context - * was last updated in RFC 3339 format. **(read only)** * @type array $iamConfiguration The bucket's IAM configuration. * @type bool $iamConfiguration.bucketPolicyOnly.enabled this is an alias * for $iamConfiguration.uniformBucketLevelAccess. diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 2fc83e5a474e..8924c8fbca53 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -287,7 +287,6 @@ public function testPatchObjectWithContext(StorageObject $object) $this->assertEquals($modifiedValue, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); $this->assertEquals($newValue, $info['contexts']['custom'][$newKey]['value']); - $info = $object->update([ 'contexts' => [ 'custom' => [ @@ -297,13 +296,11 @@ public function testPatchObjectWithContext(StorageObject $object) ]); $this->assertArrayNotHasKey($newKey, $info['contexts']['custom']); $this->assertArrayHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - $info = $object->update([ 'contexts' => [ 'custom' => (object) [] ] ]); - $hasContexts = isset($info['contexts']['custom']) && !empty($info['contexts']['custom']); $object->delete(); } @@ -316,7 +313,6 @@ public function testRewriteObjectWithContexts(StorageObject $source) $info = $inherited->info(); $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); - $overrideKey = 'override-key'; $overrideVal = 'override-val'; $overridden = $source->rewrite(self::$bucket, [ @@ -327,10 +323,7 @@ public function testRewriteObjectWithContexts(StorageObject $source) $info = $overridden->info(); $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - - $inherited->delete(); - $overridden->delete(); - $source->delete(); + array_map(fn($o) => $o->delete(), [$inherited, $overridden, $source]); } /** @@ -340,7 +333,6 @@ public function testComposeObjectWithContexts(StorageObject $source1) { $bucket = self::$client->bucket($source1->info()['bucket']); $s2Key = 's2-key'; - $source2 = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . 's2-' . uniqid(), 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] @@ -390,26 +382,24 @@ public function testListObjectsWithContextFilters(StorageObject $object) } $objects = iterator_to_array($bucket->objects(['filter' => $filter, 'prefix' => $prefix])); } - - $this->assertCount(1, $objects, 'Exact match filter failed.'); + $this->assertCount(1, $objects); $this->assertEquals($uVal, $objects[0]->info()['contexts']['custom'][$uKey]['value']); - $presence = iterator_to_array($bucket->objects([ 'filter' => "contexts.custom.$uKey:*", 'prefix' => $prefix ])); - $this->assertCount(1, $presence, 'Key presence (wildcard) filter failed.'); + $this->assertCount(1, $presence); $absence = iterator_to_array($bucket->objects([ 'filter' => "-contexts.custom.$uKey", 'prefix' => $prefix ])); - $this->assertCount(0, $absence, 'Key absence filter should return zero for this prefix.'); + $this->assertCount(0, $absence); $absenceVal = iterator_to_array($bucket->objects([ 'filter' => "-contexts.custom.$uKey.value=\"wrong-val\"", 'prefix' => $prefix ])); - $this->assertCount(1, $absenceVal, 'Value absence filter failed.'); + $this->assertCount(1, $absenceVal); $other->delete(); } From be06d296ea0a3a47e9f9ac2ec2f0ce2cce3d9260 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 27 Mar 2026 15:34:36 +0000 Subject: [PATCH 29/64] Pending scenario covers --- Storage/src/Bucket.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 0f504580a1d6..490ff35a032c 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -361,6 +361,12 @@ private function validateContexts(array $contexts) $key )); } + if (!is_scalar($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context value for key "%s" must be a scalar type.', + $key + )); + } $val = (string) $data['value']; if (!preg_match('/^[a-zA-Z0-9]/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); From 64b6c01d6414f2a408eb39031e3ffc24e4b84e87 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 30 Mar 2026 06:08:53 +0000 Subject: [PATCH 30/64] Remove unwanted delete code --- Storage/tests/System/ManageObjectsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 8924c8fbca53..7e559cae3606 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -323,7 +323,7 @@ public function testRewriteObjectWithContexts(StorageObject $source) $info = $overridden->info(); $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - array_map(fn($o) => $o->delete(), [$inherited, $overridden, $source]); + $source->delete(); } /** @@ -355,7 +355,7 @@ public function testComposeObjectWithContexts(StorageObject $source1) $this->assertEquals($oVal, $info['contexts']['custom'][$oKey]['value']); $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - array_map(fn($o) => $o->delete(), [$inherit, $override, $source1, $source2]); + $source1->delete(); } /** From 1241ae680f1889b2b8d979ba5bf471549af031ff Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 30 Mar 2026 06:16:35 +0000 Subject: [PATCH 31/64] Remove unwanted delete code --- Storage/tests/Unit/BucketTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 18c9f26f9843..6a00cebaeb89 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -801,7 +801,7 @@ public function testGetMetadataIncludesContexts() */ public function testListObjectsWithContextFilters($filter, $expectedItems) { - $this->connection->projectId()->willReturn('test-project'); + $this->connection->projectId()->willReturn(self::PROJECT_ID); $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ From 500b9f2509dd2c1e1f038a2b7abb9c674d3c0de5 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 30 Mar 2026 06:23:10 +0000 Subject: [PATCH 32/64] Add FIle const --- Storage/tests/Unit/BucketTest.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 6a00cebaeb89..56e83895add6 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -54,6 +54,7 @@ class BucketTest extends TestCase const BUCKET_NAME = 'my-bucket'; const PROJECT_ID = 'my-project'; const NOTIFICATION_ID = '1234'; + const FILE_NAME_TEST = 'test.txt'; private $connection; private $resumableUploader; private $multipartUploader; @@ -587,7 +588,7 @@ public function testCreateObjectWithInvalidContexts() ]; $this->getBucket()->upload('data', [ - 'name' => 'test.txt', + 'name' => self::FILE_NAME_TEST, 'contexts' => $invalidContexts ]); } @@ -597,7 +598,7 @@ public function testRejectInvalidLeadingUnicodeValueInObjectContexts() $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); $this->getBucket()->upload('test data', [ - 'name' => 'test.txt', + 'name' => self::FILE_NAME_TEST, 'contexts' => [ 'custom' => [ 'my-custom-key' => ['value' => '✨-sparkle'] @@ -617,13 +618,13 @@ public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi } return isset($args['contexts']) && $args['contexts'] === $expectedInApi; }))->shouldBeCalled()->willReturn([ - 'name' => 'test.txt', + 'name' => self::FILE_NAME_TEST, 'contexts' => $expectedInApi ]); $object = new StorageObject( $this->connection->reveal(), - 'test.txt', + self::FILE_NAME_TEST, '', 1, ['bucket' => self::BUCKET_NAME] @@ -653,7 +654,7 @@ public function contextUpdateProvider() */ public function testPatchObjectContext($patchData, $expectedMatchFunc, $mockResponse) { - $objectName = 'patch-test.txt'; + $objectName = 'patch-'.self::FILE_NAME_TEST; $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); $this->connection->patchObject(Argument::that($expectedMatchFunc)) ->shouldBeCalledTimes(1) @@ -769,7 +770,7 @@ public function composeContextDataProvider() public function testGetMetadataIncludesContexts() { - $objectName = 'metadata-test.txt'; + $objectName = 'metadata-'.self::FILE_NAME_TEST; $this->connection->projectId()->willReturn(self::PROJECT_ID); $metadataResponse = [ From 0ed4e1ec6adb54579fc7eba5c2c821ce5c2e352d Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Wed, 1 Apr 2026 14:24:08 +0000 Subject: [PATCH 33/64] Handle more scenario and resolved comments --- Storage/src/StorageObject.php | 4 +- Storage/tests/Unit/BucketTest.php | 191 ++++++++++++++++++------------ 2 files changed, 118 insertions(+), 77 deletions(-) diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index f6292bd22dd0..112105859081 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -481,8 +481,8 @@ public function rewrite($destination, array $options = []) return new StorageObject( $this->connection, $response['resource']['name'], - $response['resource']['bucket'], - $response['resource']['generation'], + $response['resource']['bucket'] ?? $destination, + $response['resource']['generation'] ?? null, $response['resource'] + ['requesterProjectId' => $this->identity['userProject']], $destinationKey, $destinationKeySHA256 diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 56e83895add6..483797959dc0 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -577,15 +577,13 @@ public function testCreateObjectWithContexts() $this->assertEquals($contexts, $object->info()['contexts']); } - public function testCreateObjectWithInvalidContexts() + /** + * @dataProvider invalidContextsProvider + */ + public function testUploadWithInvalidContexts($invalidContexts, $expectedMessage) { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Object context value cannot contain forbidden characters.'); - $invalidContexts = [ - 'custom' => [ - 'valid-key' => ['value' => 'invalid/value'] - ] - ]; + $this->expectExceptionMessage($expectedMessage); $this->getBucket()->upload('data', [ 'name' => self::FILE_NAME_TEST, @@ -593,22 +591,58 @@ public function testCreateObjectWithInvalidContexts() ]); } - public function testRejectInvalidLeadingUnicodeValueInObjectContexts() + /** + * @dataProvider invalidContextsProvider + */ + public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessage) { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Object context value must start with an alphanumeric.'); - $this->getBucket()->upload('test data', [ - 'name' => self::FILE_NAME_TEST, - 'contexts' => [ - 'custom' => [ - 'my-custom-key' => ['value' => '✨-sparkle'] - ] + $this->expectExceptionMessage($expectedMessage); + + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + $sourceObject->rewrite('dest-bucket', ['contexts' => $invalidContexts]); + } + + public function testRewriteWithEmptyContexts() + { + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + + $this->connection->rewriteObject(Argument::withEntry('contexts', [])) + ->shouldBeCalled() + ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); + + $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); + $this->assertEmpty($result->info()['contexts']); + } + + public function invalidContextsProvider() + { + return [ + 'Invalid Leading Unicode' => [ + ['custom' => ['key' => ['value' => '✨-sparkle']]], + 'Object context value must start with an alphanumeric.' + ], + 'Forbidden Characters (Slash and Quote)' => [ + ['custom' => ['k1' => ['value' => 'invalid/val'], 'k2' => ['value' => 'val"quote']]], + 'Object context value cannot contain forbidden characters.' + ], + 'Key Not Starting with Alphanumeric' => [ + ['custom' => ['_key' => ['value' => 'val']]], + 'Object context key must start with an alphanumeric.' + ], + 'Custom Field Not An Array' => [ + ['custom' => 'not-an-array'], + 'Object contexts custom field must be an array.' + ], + 'Value Property Missing' => [ + ['custom' => ['key' => ['no-value-here' => 'val']]], + 'Context for key "key" must have a \'value\' property.' ] - ]); + ]; } /** - * @dataProvider contextUpdateProvider + * @dataProvider objectContextUpdateDataProvider */ public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi) { @@ -632,62 +666,53 @@ public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi $object->update(['contexts' => $inputContexts]); $info = $object->info(); if ($expectedInApi === null) { - $this->assertArrayNotHasKey('contexts', $info); + $hasContexts = isset($info['contexts']) && $info['contexts'] !== null; + $this->assertFalse($hasContexts); } else { $this->assertArrayHasKey('contexts', $info); $this->assertEquals($expectedInApi, $info['contexts']); } } - public function contextUpdateProvider() + public function objectContextUpdateDataProvider() { $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; return [ 'Valid Update' => [$validContexts['contexts'], $validContexts['contexts']], - 'Empty Array' => [[], []] + 'Empty Array' => [[], []], + 'Null Case' => [null, null] ]; } /** - * @dataProvider patchContextProvider + * @dataProvider objectContextPatchDataProvider */ - public function testPatchObjectContext($patchData, $expectedMatchFunc, $mockResponse) + public function testPatchObjectContext($key, $value) { $objectName = 'patch-'.self::FILE_NAME_TEST; $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); - $this->connection->patchObject(Argument::that($expectedMatchFunc)) - ->shouldBeCalledTimes(1) - ->willReturn($mockResponse + ['name' => $objectName]); + + $patchData = ['contexts' => ['custom' => [$key => $value]]]; + + $this->connection->patchObject(Argument::withEntry('contexts', $patchData['contexts'])) + ->shouldBeCalledTimes(1) + ->willReturn([ + 'name' => $objectName, + 'contexts' => $patchData['contexts'] + ]); + $result = $object->update($patchData); - $this->assertEquals($mockResponse['contexts'] ?? null, $result['contexts'] ?? null); + $this->assertEquals($value, $result['contexts']['custom'][$key]); } - public function patchContextProvider() + public function objectContextPatchDataProvider() { return [ - 'Update/Add Key' => [ - ['contexts' => ['custom' => ['new-key' => ['value' => 'brand-new-val']]]], - function ($args) { - return ($args['contexts']['custom']['new-key']['value'] ?? '') === 'brand-new-val'; - }, - ['contexts' => ['custom' => ['new-key' => ['value' => 'brand-new-val']]]] - ], - 'Delete Specific Key' => [ - ['contexts' => ['custom' => ['key-to-delete' => null]]], - function ($args) { - return array_key_exists('key-to-delete', $args['contexts']['custom'] ?? []) && - $args['contexts']['custom']['key-to-delete'] === null; - }, - ['contexts' => ['custom' => ['remaining-key' => ['value' => 'stays']]]] - ], - 'Clear All Contexts' => [ - ['contexts' => null], - function ($args) { - return array_key_exists('contexts', $args) && $args['contexts'] === null; - }, - ['contexts' => null] - ] + 'Update Key' => ['new-key', 'brand-new-val'], + 'Delete Key' => ['key-to-delete', null], + 'Empty Value' => ['key-with-empty', ''], + 'Special Chars Key' => ['key123', 'value-456'] ]; } @@ -732,7 +757,7 @@ public function testRewriteObjectWithContexts() } /** - * @dataProvider composeContextDataProvider + * @dataProvider objectContextComposeDataProvider */ public function testComposeObjectWithContexts(array $options, array $expectedContexts) { @@ -757,7 +782,7 @@ public function testComposeObjectWithContexts(array $options, array $expectedCon $this->assertEquals($expectedContexts, $composedObject->info()['contexts']); } - public function composeContextDataProvider() + public function objectContextComposeDataProvider() { $sourceContexts = ['custom' => ['s1-key' => ['value' => 's1-val']]]; $overrideContexts = ['custom' => ['new-key' => ['value' => 'new-val']]]; @@ -771,7 +796,7 @@ public function composeContextDataProvider() public function testGetMetadataIncludesContexts() { $objectName = 'metadata-'.self::FILE_NAME_TEST; - + $now = (new \DateTime())->format('Y-m-d\TH:i:s.v\Z'); $this->connection->projectId()->willReturn(self::PROJECT_ID); $metadataResponse = [ 'name' => $objectName, @@ -779,7 +804,9 @@ public function testGetMetadataIncludesContexts() 'generation' => 12345, 'contexts' => [ 'custom' => [ - 'existing-key' => ['value' => 'existing-val'] + 'existing-key' => ['value' => 'existing-val'], + 'createTime' => $now, + 'updateTime' => $now ] ] ]; @@ -791,6 +818,14 @@ public function testGetMetadataIncludesContexts() $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); $info = $bucket->object($objectName)->info(); $this->assertArrayHasKey('contexts', $info); + $this->assertArrayHasKey( + 'createTime', + $info['contexts']['custom'] + ); + $this->assertArrayHasKey( + 'updateTime', + $info['contexts']['custom'] + ); $this->assertEquals( 'existing-val', $info['contexts']['custom']['existing-key']['value'] @@ -798,7 +833,7 @@ public function testGetMetadataIncludesContexts() } /** - * @dataProvider contextFilterProvider + * @dataProvider objectContextFilterDataProvider */ public function testListObjectsWithContextFilters($filter, $expectedItems) { @@ -821,32 +856,38 @@ public function testListObjectsWithContextFilters($filter, $expectedItems) } } - public function contextFilterProvider() + public function objectContextFilterDataProvider() { $unicodeKey = 'key-✨'; $unicodeVal = 'val-🚀'; - return [ - 'Presence of key/value pair' => [ - 'contexts.custom.k1.value="v1"', - [['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]]] - ], - 'Absence of key/value pair' => [ - '-contexts.custom.k1.value="v1"', - [['name' => 'f2.txt']] - ], - 'Presence of key regardless of value' => [ - 'contexts.custom.k1:*', - [['name' => 'f1.txt']] - ], - 'Absence of key regardless of value' => [ - '-contexts.custom.k1', - [] - ], - 'Unicode key/value pair' => [ - sprintf('contexts.custom.%s.value="%s"', $unicodeKey, $unicodeVal), - [['name' => 'unicode.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]]] + 'Presence of key/value pair' => [ + 'contexts.custom.k1.value="v1"', + [ + ['name' => 'match.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], + ] + ], + 'Absence of key/value pair' => [ + '-contexts.custom.k1.value="v1"', + [ + ['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v2']]]], // Diff value + ['name' => 'f2.txt'], + ] + ], + 'Presence of key regardless of value' => [ + 'contexts.custom.k1:*', + [ + ['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], + ['name' => 'f2.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'any']]]], + ] + ], + 'Unicode key/value pair' => [ + sprintf('contexts.custom.%s.value="%s"', $unicodeKey, $unicodeVal), + [ + ['name' => 'unicode.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]], + ['name' => 'another.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]] ] + ] ]; } From 3996210d56e0b0f0eb499be59294fab74f97aa0e Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 2 Apr 2026 07:23:34 +0000 Subject: [PATCH 34/64] Add more scenarios and also recheck with document --- Storage/tests/System/ManageObjectsTest.php | 80 ++++-- Storage/tests/Unit/BucketTest.php | 270 +++++++++++++-------- 2 files changed, 228 insertions(+), 122 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 7e559cae3606..3089ec2dcaa1 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -235,36 +235,24 @@ public function testCreateObjectWithContexts() } /** - * @depends testCreateObjectWithContexts - */ - public function testGetObjectWithContexts(StorageObject $object) + * @depends testCreateObjectWithContexts + */ + public function testUpdateReplacesAllObjectContexts(StorageObject $object) { - $info = $object->info(['projection' => 'full']); - $this->assertArrayHasKey('contexts', $info); - $this->assertArrayHasKey('custom', $info['contexts']); - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); - $object->delete(); - } + $replacementKey = 'replacement-key-' . uniqid(); + $replacementVal = 'replacement-value'; - /** - * @depends testCreateObjectWithContexts - */ - public function testRemoveEmptyObjectWithContexts(StorageObject $object) - { - $object->update([ + $info = $object->update([ 'contexts' => [ 'custom' => [ - self::CONTEXT_OBJECT_KEY => (object) [] + self::CONTEXT_OBJECT_KEY => (object) [], + $replacementKey => ['value' => $replacementVal] ] ] ]); - - $newInfo = $object->reload(); - if (isset($newInfo['contexts']['custom'])) { - $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $newInfo['contexts']['custom']); - } else { - $this->assertArrayNotHasKey('contexts', $newInfo); - } + $this->assertEquals($replacementVal, $info['contexts']['custom'][$replacementKey]['value']); + $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); + $this->assertCount(1, $info['contexts']['custom']); $object->delete(); } @@ -290,17 +278,27 @@ public function testPatchObjectWithContext(StorageObject $object) $info = $object->update([ 'contexts' => [ 'custom' => [ - $newKey => (object) [] + $newKey => (object) [], + self::CONTEXT_OBJECT_KEY => ['value' => self::CONTEXT_OBJECT_VALUE] ] ] ]); $this->assertArrayNotHasKey($newKey, $info['contexts']['custom']); $this->assertArrayHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); + $this->assertEquals( + self::CONTEXT_OBJECT_VALUE, + $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value'] + ); $info = $object->update([ 'contexts' => [ 'custom' => (object) [] ] ]); + if (isset($info['contexts'])) { + $this->assertArrayNotHasKey('custom', $info['contexts']); + } else { + $this->assertArrayNotHasKey('contexts', $info); + } $object->delete(); } @@ -358,6 +356,18 @@ public function testComposeObjectWithContexts(StorageObject $source1) $source1->delete(); } + /** + * @depends testCreateObjectWithContexts + */ + public function testGetObjectWithContexts(StorageObject $object) + { + $info = $object->info(['projection' => 'full']); + $this->assertArrayHasKey('contexts', $info); + $this->assertArrayHasKey('custom', $info['contexts']); + $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); + $object->delete(); + } + /** * @depends testCreateObjectWithContexts */ @@ -403,6 +413,28 @@ public function testListObjectsWithContextFilters(StorageObject $object) $other->delete(); } + /** + * @depends testCreateObjectWithContexts + */ + public function testRemoveEmptyObjectWithContexts(StorageObject $object) + { + $object->update([ + 'contexts' => [ + 'custom' => [ + self::CONTEXT_OBJECT_KEY => (object) [] + ] + ] + ]); + + $newInfo = $object->reload(); + if (isset($newInfo['contexts']['custom'])) { + $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $newInfo['contexts']['custom']); + } else { + $this->assertArrayNotHasKey('contexts', $newInfo); + } + $object->delete(); + } + public function testObjectExists() { $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 483797959dc0..faa366bad388 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -569,7 +569,7 @@ public function testCreateObjectWithContexts() $this->connection->insertObject(Argument::any()) ->willReturn($this->resumableUploader->reveal()); - $object = $this->getBucket()->upload('some data to upload', [ + $object = $this->getBucket()->upload('upload', [ 'name' => 'data.txt', 'contexts' => $contexts ]); @@ -580,7 +580,7 @@ public function testCreateObjectWithContexts() /** * @dataProvider invalidContextsProvider */ - public function testUploadWithInvalidContexts($invalidContexts, $expectedMessage) + public function testCreateObjectWithInvalidContexts($invalidContexts, $expectedMessage) { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedMessage); @@ -591,54 +591,22 @@ public function testUploadWithInvalidContexts($invalidContexts, $expectedMessage ]); } - /** - * @dataProvider invalidContextsProvider - */ - public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessage) + public function testUpdateReplacesAllMetadataIncludingContexts() { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage($expectedMessage); - - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - $sourceObject->rewrite('dest-bucket', ['contexts' => $invalidContexts]); - } + $objectName = 'replace-test.txt'; + $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); + $newContexts = ['custom' => ['new-key' => ['value' => 'new-val']]]; - public function testRewriteWithEmptyContexts() - { - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - - $this->connection->rewriteObject(Argument::withEntry('contexts', [])) + $this->connection->patchObject(Argument::withEntry('contexts', $newContexts)) ->shouldBeCalled() - ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); + ->willReturn([ + 'name' => $objectName, + 'contexts' => $newContexts, + ]); - $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); - $this->assertEmpty($result->info()['contexts']); - } - - public function invalidContextsProvider() - { - return [ - 'Invalid Leading Unicode' => [ - ['custom' => ['key' => ['value' => '✨-sparkle']]], - 'Object context value must start with an alphanumeric.' - ], - 'Forbidden Characters (Slash and Quote)' => [ - ['custom' => ['k1' => ['value' => 'invalid/val'], 'k2' => ['value' => 'val"quote']]], - 'Object context value cannot contain forbidden characters.' - ], - 'Key Not Starting with Alphanumeric' => [ - ['custom' => ['_key' => ['value' => 'val']]], - 'Object context key must start with an alphanumeric.' - ], - 'Custom Field Not An Array' => [ - ['custom' => 'not-an-array'], - 'Object contexts custom field must be an array.' - ], - 'Value Property Missing' => [ - ['custom' => ['key' => ['no-value-here' => 'val']]], - 'Context for key "key" must have a \'value\' property.' - ] - ]; + $result = $object->update(['contexts' => $newContexts]); + $this->assertEquals('new-val', $result['contexts']['custom']['new-key']['value']); + $this->assertArrayNotHasKey('contentType', $result); } /** @@ -685,75 +653,110 @@ public function objectContextUpdateDataProvider() ]; } + public function testClearAllObjectContexts() + { + $objectName = 'clear-test.txt'; + $object = new StorageObject( + $this->connection->reveal(), + $objectName, + self::BUCKET_NAME + ); + + $patchData = ['contexts' => []]; + $this->connection->patchObject(Argument::withEntry('contexts', [])) + ->shouldBeCalled() + ->willReturn([ + 'name' => $objectName, + 'bucket' => self::BUCKET_NAME, + 'contexts' => [] + ]); + + $result = $object->update($patchData); + $this->assertIsArray($result['contexts']); + $this->assertEmpty($result['contexts']); + } + /** - * @dataProvider objectContextPatchDataProvider + * @dataProvider invalidContextsProvider */ - public function testPatchObjectContext($key, $value) + public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessage) { - $objectName = 'patch-'.self::FILE_NAME_TEST; - $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); - - $patchData = ['contexts' => ['custom' => [$key => $value]]]; - - $this->connection->patchObject(Argument::withEntry('contexts', $patchData['contexts'])) - ->shouldBeCalledTimes(1) - ->willReturn([ - 'name' => $objectName, - 'contexts' => $patchData['contexts'] - ]); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); - $result = $object->update($patchData); - $this->assertEquals($value, $result['contexts']['custom'][$key]); + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + $sourceObject->rewrite('dest-bucket', ['contexts' => $invalidContexts]); } - public function objectContextPatchDataProvider() + public function testRewriteWithEmptyContexts() { - return [ - 'Update Key' => ['new-key', 'brand-new-val'], - 'Delete Key' => ['key-to-delete', null], - 'Empty Value' => ['key-with-empty', ''], - 'Special Chars Key' => ['key123', 'value-456'] - ]; + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + + $this->connection->rewriteObject(Argument::withEntry('contexts', [])) + ->shouldBeCalled() + ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); + + $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); + $this->assertEmpty($result->info()['contexts']); } - public function testRewriteObjectWithContexts() + public function invalidContextsProvider() { - $contexts = [ - 'custom' => [ - 'rewrite-key' => ['value' => 'rewrite-val'] + return [ + 'Invalid Leading Unicode' => [ + ['custom' => ['key' => ['value' => '✨-sparkle']]], + 'Object context value must start with an alphanumeric.' + ], + 'Forbidden Characters (Slash and Quote)' => [ + ['custom' => ['k1' => ['value' => 'invalid/val'], 'k2' => ['value' => 'val"quote']]], + 'Object context value cannot contain forbidden characters.' + ], + 'Key Not Starting with Alphanumeric' => [ + ['custom' => ['_key' => ['value' => 'val']]], + 'Object context key must start with an alphanumeric.' + ], + 'Custom Field Not An Array' => [ + ['custom' => 'not-an-array'], + 'Object contexts custom field must be an array.' + ], + 'Value Property Missing' => [ + ['custom' => ['key' => ['no-value-here' => 'val']]], + 'Context for key "key" must have a \'value\' property.' ] ]; - $destBucket = 'other-bucket'; - $destName = 'rewritten-data.txt'; - - $this->connection->rewriteObject(Argument::that(function ($args) use ($contexts) { - return isset($args['contexts']) && $args['contexts'] === $contexts; - }))->willReturn([ - 'rewriteToken' => null, - 'resource' => [ - 'name' => $destName, - 'bucket' => $destBucket, - 'generation' => 456, - 'contexts' => $contexts - ] - ]); + } - $sourceBucket = 'source-bucket'; + public function testRewriteWithContextOverride() + { $sourceObject = new StorageObject( $this->connection->reveal(), - 'source-file.txt', - $sourceBucket, - 123, - ['bucket' => $sourceBucket] + 'source.txt', + self::BUCKET_NAME ); - $object = $sourceObject->rewrite($destBucket, [ - 'contexts' => $contexts + $overriddenContexts = [ + 'custom' => ['override-key' => ['value' => 'override-val']] + ]; + + $this->connection->rewriteObject(Argument::withEntry('contexts', $overriddenContexts)) + ->shouldBeCalled() + ->willReturn([ + 'resource' => [ + 'name' => 'dest.txt', + 'bucket' => 'dest-bucket', + 'generation' => '1', + 'contexts' => $overriddenContexts + ] + ]); + + $result = $sourceObject->rewrite('dest-bucket', [ + 'contexts' => $overriddenContexts ]); - $this->assertInstanceOf(StorageObject::class, $object); - $this->assertEquals($destName, $object->name()); - $this->assertArrayHasKey('contexts', $object->info()); - $this->assertEquals($contexts, $object->info()['contexts']); + $this->assertInstanceOf(StorageObject::class, $result); + $this->assertEquals( + 'override-val', + $result->info()['contexts']['custom']['override-key']['value'] + ); } /** @@ -832,7 +835,7 @@ public function testGetMetadataIncludesContexts() ); } - /** + /** * @dataProvider objectContextFilterDataProvider */ public function testListObjectsWithContextFilters($filter, $expectedItems) @@ -891,6 +894,77 @@ public function objectContextFilterDataProvider() ]; } + /** + * @dataProvider objectContextPatchDataProvider + */ + public function testPatchExistingObjectContext($key, $value) + { + $objectName = 'patch-'.self::FILE_NAME_TEST; + $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); + + $patchData = ['contexts' => ['custom' => [$key => $value]]]; + + $this->connection->patchObject(Argument::withEntry('contexts', $patchData['contexts'])) + ->shouldBeCalledTimes(1) + ->willReturn([ + 'name' => $objectName, + 'contexts' => $patchData['contexts'] + ]); + + $result = $object->update($patchData); + $this->assertEquals($value, $result['contexts']['custom'][$key]); + } + + public function objectContextPatchDataProvider() + { + return [ + 'Update Key' => ['new-key', 'brand-new-val'], + 'Delete Key' => ['key-to-delete', null], + 'Empty Value' => ['key-with-empty', ''], + 'Special Chars Key' => ['key123', 'value-456'] + ]; + } + + public function testRewriteObjectWithContexts() + { + $contexts = [ + 'custom' => [ + 'rewrite-key' => ['value' => 'rewrite-val'] + ] + ]; + $destBucket = 'other-bucket'; + $destName = 'rewritten-data.txt'; + + $this->connection->rewriteObject(Argument::that(function ($args) use ($contexts) { + return isset($args['contexts']) && $args['contexts'] === $contexts; + }))->willReturn([ + 'rewriteToken' => null, + 'resource' => [ + 'name' => $destName, + 'bucket' => $destBucket, + 'generation' => 456, + 'contexts' => $contexts + ] + ]); + + $sourceBucket = 'source-bucket'; + $sourceObject = new StorageObject( + $this->connection->reveal(), + 'source-file.txt', + $sourceBucket, + 123, + ['bucket' => $sourceBucket] + ); + + $object = $sourceObject->rewrite($destBucket, [ + 'contexts' => $contexts + ]); + $this->assertInstanceOf(StorageObject::class, $object); + $this->assertEquals($destName, $object->name()); + $this->assertArrayHasKey('contexts', $object->info()); + $this->assertEquals($contexts, $object->info()['contexts']); + } + public function testIam() { $bucketInfo = [ From 0e845821063058479dd1e1448b69b26cf6da2d73 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 2 Apr 2026 07:48:58 +0000 Subject: [PATCH 35/64] Make a trait for validateContext and use in both bucket and storageObject file --- Storage/src/Bucket.php | 48 +----------------- Storage/src/StorageObject.php | 5 ++ Storage/src/ValidateContextsTrait.php | 72 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 47 deletions(-) create mode 100644 Storage/src/ValidateContextsTrait.php diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 490ff35a032c..733b45df8777 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -52,6 +52,7 @@ class Bucket { use ArrayTrait; use EncryptionTrait; + use ValidateContextsTrait; const NOTIFICATION_TEMPLATE = '//pubsub.googleapis.com/%s'; const TOPIC_TEMPLATE = 'projects/%s/topics/%s'; @@ -330,53 +331,6 @@ public function upload($data, array $options = []) ); } - /** - * @param array $contexts The contexts array to validate. - * @return void - */ - private function validateContexts(array $contexts) - { - if (!isset($contexts['custom'])) { - return; - } - if (!is_array($contexts['custom'])) { - throw new \InvalidArgumentException('Object contexts custom field must be an array.'); - } - foreach ($contexts['custom'] as $key => $data) { - if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { - throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); - } - if (strpos((string) $key, '"') !== false) { - throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); - } - if (!is_array($data)) { - throw new \InvalidArgumentException(sprintf( - 'Context data for key "%s" must be an array.', - $key - )); - } - if (!isset($data['value'])) { - throw new \InvalidArgumentException(sprintf( - 'Context for key "%s" must have a \'value\' property.', - $key - )); - } - if (!is_scalar($data['value'])) { - throw new \InvalidArgumentException(sprintf( - 'Context value for key "%s" must be a scalar type.', - $key - )); - } - $val = (string) $data['value']; - if (!preg_match('/^[a-zA-Z0-9]/', $val)) { - throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); - } - if (strpos($val, '/') !== false || strpos($val, '"') !== false) { - throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); - } - } - } - /** * Asynchronously uploads an object. * diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index 112105859081..da33669ee202 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -44,6 +44,7 @@ class StorageObject { use ArrayTrait; use EncryptionTrait; + use ValidateContextsTrait; /** * @deprecated @@ -473,6 +474,10 @@ public function rewrite($destination, array $options = []) $options = $this->formatDestinationRequest($destination, $options); + if (isset($options['contexts'])) { + $this->validateContexts($options['contexts']); + } + do { $response = $this->connection->rewriteObject($options); $options['rewriteToken'] = $response['rewriteToken'] ?? null; diff --git a/Storage/src/ValidateContextsTrait.php b/Storage/src/ValidateContextsTrait.php new file mode 100644 index 000000000000..a5d98d179a5f --- /dev/null +++ b/Storage/src/ValidateContextsTrait.php @@ -0,0 +1,72 @@ + $data) { + if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { + throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); + } + if (strpos((string) $key, '"') !== false) { + throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); + } + if (!is_array($data)) { + throw new \InvalidArgumentException(sprintf( + 'Context data for key "%s" must be an array.', + $key + )); + } + if (!isset($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context for key "%s" must have a \'value\' property.', + $key + )); + } + if (!is_scalar($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context value for key "%s" must be a scalar type.', + $key + )); + } + $val = (string) $data['value']; + if (!preg_match('/^[a-zA-Z0-9]/', $val)) { + throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); + } + if (strpos($val, '/') !== false || strpos($val, '"') !== false) { + throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); + } + } + } +} From cfd37d1f4a22fff90e791d2d0cb2b15f8f77ad9b Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 2 Apr 2026 08:31:54 +0000 Subject: [PATCH 36/64] Changed code as per gemini review --- Storage/src/StorageObject.php | 2 +- Storage/src/ValidateContextsTrait.php | 2 +- Storage/tests/System/ManageObjectsTest.php | 6 +++--- Storage/tests/Unit/BucketTest.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index da33669ee202..506f9b7b5faa 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -486,7 +486,7 @@ public function rewrite($destination, array $options = []) return new StorageObject( $this->connection, $response['resource']['name'], - $response['resource']['bucket'] ?? $destination, + $response['resource']['bucket'] ?? ($destination instanceof Bucket ? $destination->name() : $destination), $response['resource']['generation'] ?? null, $response['resource'] + ['requesterProjectId' => $this->identity['userProject']], $destinationKey, diff --git a/Storage/src/ValidateContextsTrait.php b/Storage/src/ValidateContextsTrait.php index a5d98d179a5f..a778f746e5d8 100644 --- a/Storage/src/ValidateContextsTrait.php +++ b/Storage/src/ValidateContextsTrait.php @@ -61,7 +61,7 @@ private function validateContexts(array $contexts) )); } $val = (string) $data['value']; - if (!preg_match('/^[a-zA-Z0-9]/', $val)) { + if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } if (strpos($val, '/') !== false || strpos($val, '"') !== false) { diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 3089ec2dcaa1..2e35b5d293e9 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -245,7 +245,7 @@ public function testUpdateReplacesAllObjectContexts(StorageObject $object) $info = $object->update([ 'contexts' => [ 'custom' => [ - self::CONTEXT_OBJECT_KEY => (object) [], + self::CONTEXT_OBJECT_KEY => null, $replacementKey => ['value' => $replacementVal] ] ] @@ -278,7 +278,7 @@ public function testPatchObjectWithContext(StorageObject $object) $info = $object->update([ 'contexts' => [ 'custom' => [ - $newKey => (object) [], + $newKey => null, self::CONTEXT_OBJECT_KEY => ['value' => self::CONTEXT_OBJECT_VALUE] ] ] @@ -421,7 +421,7 @@ public function testRemoveEmptyObjectWithContexts(StorageObject $object) $object->update([ 'contexts' => [ 'custom' => [ - self::CONTEXT_OBJECT_KEY => (object) [] + self::CONTEXT_OBJECT_KEY => null ] ] ]); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index faa366bad388..57f2c6648eee 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -964,7 +964,7 @@ public function testRewriteObjectWithContexts() $this->assertArrayHasKey('contexts', $object->info()); $this->assertEquals($contexts, $object->info()['contexts']); } - + public function testIam() { $bucketInfo = [ From 54e9eac6986d8d9cfc5df811ecbb161af3347268 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 2 Apr 2026 09:28:40 +0000 Subject: [PATCH 37/64] Changed code as per gemini review --- Storage/tests/Unit/BucketTest.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 57f2c6648eee..90dbd340b28a 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -645,7 +645,6 @@ public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi public function objectContextUpdateDataProvider() { $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; - return [ 'Valid Update' => [$validContexts['contexts'], $validContexts['contexts']], 'Empty Array' => [[], []], @@ -690,8 +689,7 @@ public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessag public function testRewriteWithEmptyContexts() { - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); $this->connection->rewriteObject(Argument::withEntry('contexts', [])) ->shouldBeCalled() ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); From b7afe81052b4435dccb3a0a056ac3c85668b81d6 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Thu, 2 Apr 2026 09:34:39 +0000 Subject: [PATCH 38/64] Changed code as per gemini review --- Storage/tests/Unit/BucketTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 90dbd340b28a..ce2b465b22f1 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -689,7 +689,7 @@ public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessag public function testRewriteWithEmptyContexts() { - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); $this->connection->rewriteObject(Argument::withEntry('contexts', [])) ->shouldBeCalled() ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); From b966e67665d1ce09659905e112aa2880710e193f Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 7 Apr 2026 05:42:58 +0000 Subject: [PATCH 39/64] Changes code as per comments --- Storage/src/StorageObject.php | 4 ++-- Storage/tests/Unit/BucketTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index 506f9b7b5faa..f7d7f905c5b5 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -486,8 +486,8 @@ public function rewrite($destination, array $options = []) return new StorageObject( $this->connection, $response['resource']['name'], - $response['resource']['bucket'] ?? ($destination instanceof Bucket ? $destination->name() : $destination), - $response['resource']['generation'] ?? null, + $response['resource']['bucket'], + $response['resource']['generation'], $response['resource'] + ['requesterProjectId' => $this->identity['userProject']], $destinationKey, $destinationKeySHA256 diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index ce2b465b22f1..a16ec7ad7549 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -578,7 +578,7 @@ public function testCreateObjectWithContexts() } /** - * @dataProvider invalidContextsProvider + * @dataProvider objectInvalidContextsDataProvider */ public function testCreateObjectWithInvalidContexts($invalidContexts, $expectedMessage) { @@ -647,7 +647,6 @@ public function objectContextUpdateDataProvider() $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; return [ 'Valid Update' => [$validContexts['contexts'], $validContexts['contexts']], - 'Empty Array' => [[], []], 'Null Case' => [null, null] ]; } @@ -676,7 +675,7 @@ public function testClearAllObjectContexts() } /** - * @dataProvider invalidContextsProvider + * @dataProvider objectInvalidContextsDataProvider */ public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessage) { @@ -692,13 +691,14 @@ public function testRewriteWithEmptyContexts() $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); $this->connection->rewriteObject(Argument::withEntry('contexts', [])) ->shouldBeCalled() - ->willReturn(['resource' => ['name' => 'dest.txt', 'contexts' => []]]); + ->willReturn(['resource' => ['name' => 'dest.txt', 'bucket' => self::BUCKET_NAME, 'generation' => '1', 'contexts' => []]]); $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); + $this->assertInstanceOf(StorageObject::class, $result); $this->assertEmpty($result->info()['contexts']); } - public function invalidContextsProvider() + public function objectInvalidContextsDataProvider() { return [ 'Invalid Leading Unicode' => [ From c5f7aa823e138a35d94c226d11a80c75d6a73ba2 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 7 Apr 2026 06:11:14 +0000 Subject: [PATCH 40/64] Style check issue --- Storage/tests/Unit/BucketTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index a16ec7ad7549..b842ec0f7efc 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -718,7 +718,10 @@ public function objectInvalidContextsDataProvider() 'Object contexts custom field must be an array.' ], 'Value Property Missing' => [ - ['custom' => ['key' => ['no-value-here' => 'val']]], + [ + 'custom' => + ['key' => ['no-value-here' => 'val']] + ], 'Context for key "key" must have a \'value\' property.' ] ]; From 69569938b707fdcb989df6fe4c606e013c50f95a Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 7 Apr 2026 06:23:14 +0000 Subject: [PATCH 41/64] Style check issue --- Storage/tests/Unit/BucketTest.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index b842ec0f7efc..54c9337a2101 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -701,6 +701,10 @@ public function testRewriteWithEmptyContexts() public function objectInvalidContextsDataProvider() { return [ + 'Value Property Missing' => [ + ['custom' =>['key' => ['no-value-here' => 'val']]], + 'Context for key "key" must have a \'value\' property.' + ], 'Invalid Leading Unicode' => [ ['custom' => ['key' => ['value' => '✨-sparkle']]], 'Object context value must start with an alphanumeric.' @@ -716,13 +720,6 @@ public function objectInvalidContextsDataProvider() 'Custom Field Not An Array' => [ ['custom' => 'not-an-array'], 'Object contexts custom field must be an array.' - ], - 'Value Property Missing' => [ - [ - 'custom' => - ['key' => ['no-value-here' => 'val']] - ], - 'Context for key "key" must have a \'value\' property.' ] ]; } From fe2f2aa59deb27ff5092349ebf2b42d88d3cac63 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 7 Apr 2026 06:40:47 +0000 Subject: [PATCH 42/64] Style check issue --- Storage/tests/Unit/BucketTest.php | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 54c9337a2101..068bd00ad4f1 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -686,18 +686,6 @@ public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessag $sourceObject->rewrite('dest-bucket', ['contexts' => $invalidContexts]); } - public function testRewriteWithEmptyContexts() - { - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - $this->connection->rewriteObject(Argument::withEntry('contexts', [])) - ->shouldBeCalled() - ->willReturn(['resource' => ['name' => 'dest.txt', 'bucket' => self::BUCKET_NAME, 'generation' => '1', 'contexts' => []]]); - - $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); - $this->assertInstanceOf(StorageObject::class, $result); - $this->assertEmpty($result->info()['contexts']); - } - public function objectInvalidContextsDataProvider() { return [ @@ -724,6 +712,19 @@ public function objectInvalidContextsDataProvider() ]; } + public function testRewriteWithEmptyContexts() + { + $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); + $this->connection->rewriteObject(Argument::withEntry('contexts', [])) + ->shouldBeCalled() + ->willReturn(['resource' => + ['name' => 'dest.txt', 'bucket' => self::BUCKET_NAME, 'generation' => '1', 'contexts' => []] + ]); + $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); + $this->assertInstanceOf(StorageObject::class, $result); + $this->assertEmpty($result->info()['contexts']); + } + public function testRewriteWithContextOverride() { $sourceObject = new StorageObject( From 08fb2deea4cc737637e5419bc4269b5f564a28ed Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 7 Apr 2026 08:25:55 +0000 Subject: [PATCH 43/64] Review Manage Test --- Storage/tests/System/ManageObjectsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 2e35b5d293e9..c6fb7e2678c1 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -291,7 +291,7 @@ public function testPatchObjectWithContext(StorageObject $object) ); $info = $object->update([ 'contexts' => [ - 'custom' => (object) [] + 'custom' => null ] ]); if (isset($info['contexts'])) { @@ -346,7 +346,7 @@ public function testComposeObjectWithContexts(StorageObject $source1) 'contexts' => [ 'custom' => [ $oKey => ['value' => $oVal], - self::CONTEXT_OBJECT_KEY => (object) [] + self::CONTEXT_OBJECT_KEY => null ] ] ]); From 5d7f4581af6855bfdabe9a21f835d45221cd3804 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 10 Apr 2026 07:19:38 +0000 Subject: [PATCH 44/64] Overall Completed the Test case only filter in system test is pending --- Storage/tests/System/ManageObjectsTest.php | 373 ++++++++++------- Storage/tests/Unit/BucketTest.php | 445 ++++++++------------- 2 files changed, 404 insertions(+), 414 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index c6fb7e2678c1..7944f7c42a63 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -218,126 +218,186 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } - public function testCreateObjectWithContexts() + private function testCreateObjectWithContexts(array $uploadContexts) { - $bucket = self::$client->createBucket(uniqid('object-contexts-')); + $bucket = self::$client->createBucket(uniqid('object-contexts-')); + $object = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), - 'contexts' => [ - 'custom' => [self::CONTEXT_OBJECT_KEY => ['value' => self::CONTEXT_OBJECT_VALUE]] - ] + 'contexts' => $uploadContexts ]); - $this->assertEquals( - self::CONTEXT_OBJECT_VALUE, - $object->info()['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value'] - ); return $object; } - /** - * @depends testCreateObjectWithContexts - */ - public function testUpdateReplacesAllObjectContexts(StorageObject $object) + public function testCreateRetrieveAndUpdateObjectContexts() { - $replacementKey = 'replacement-key-' . uniqid(); - $replacementVal = 'replacement-value'; + $initialContexts = [ + 'custom' => [ + 'team-owner' => ['value' => 'storage-team'], + 'priority' => ['value' => 'high'], + ], + ]; + + $object = $this->testCreateObjectWithContexts($initialContexts); + $metadata = $object->info(); + $this->assertArrayHasKey('contexts', $metadata); + $this->assertEquals( + 'storage-team', + $metadata['contexts']['custom']['team-owner']['value'] + ); + $this->assertArrayHasKey('createTime', $metadata['contexts']['custom']['team-owner']); - $info = $object->update([ + $patchMetadata = [ 'contexts' => [ 'custom' => [ - self::CONTEXT_OBJECT_KEY => null, - $replacementKey => ['value' => $replacementVal] - ] - ] - ]); - $this->assertEquals($replacementVal, $info['contexts']['custom'][$replacementKey]['value']); - $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - $this->assertCount(1, $info['contexts']['custom']); + 'priority' => ['value' => 'critical'], + 'env' => ['value' => 'prod'], + 'team-owner' => null, + ], + ], + ]; + $updatedMetadata = $object->update($patchMetadata); + $finalCustom = $updatedMetadata['contexts']['custom']; + $this->assertEquals('critical', $finalCustom['priority']['value']); + $this->assertEquals('prod', $finalCustom['env']['value']); + + $this->assertArrayNotHasKey('team-owner', $finalCustom); + + $this->assertArrayHasKey('updateTime', $finalCustom['priority']); $object->delete(); } - /** - * @depends testCreateObjectWithContexts - */ - public function testPatchObjectWithContext(StorageObject $object) - { - $newKey = 'added-key-' . uniqid(); - $newValue = 'added-value'; - $modifiedValue = 'modified-value'; - $info = $object->update([ - 'contexts' => [ - 'custom' => [ - self::CONTEXT_OBJECT_KEY => ['value' => $modifiedValue], - $newKey => ['value' => $newValue] - ] - ] - ]); + public function testGetContextAndServerGenratedTimes() + { + $initialContexts = [ + 'custom' => [ + 'temp-key' => ['value' => 'temp'], + 'status' => ['value' => 'to-be-cleared'], + ], + ]; - $this->assertEquals($modifiedValue, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); - $this->assertEquals($newValue, $info['contexts']['custom'][$newKey]['value']); - $info = $object->update([ - 'contexts' => [ - 'custom' => [ - $newKey => null, - self::CONTEXT_OBJECT_KEY => ['value' => self::CONTEXT_OBJECT_VALUE] - ] - ] - ]); - $this->assertArrayNotHasKey($newKey, $info['contexts']['custom']); - $this->assertArrayHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); - $this->assertEquals( - self::CONTEXT_OBJECT_VALUE, - $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value'] + $object = $this->testCreateObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertArrayHasKey('contexts', $info, 'Contexts missing from server response.'); + + $context = $info['contexts']['custom']['status']; + $this->assertEquals('to-be-cleared', $context['value']); + $this->assertArrayHasKey( + 'createTime', + $context, + 'Server failed to generate createTime for context.' ); - $info = $object->update([ - 'contexts' => [ - 'custom' => null - ] + $this->assertArrayHasKey( + 'updateTime', + $context, + 'Server failed to generate updateTime for context.' + ); + $object->delete(); + } + + public function testClearAllExistingContexts() + { + $initialContexts = [ + 'custom' => [ + 'temp-key' => ['value' => 'temp'], + 'status' => ['value' => 'to-be-cleared'], + ], + ]; + + $object = $this->testCreateObjectWithContexts($initialContexts); + $object->update([ + 'contexts' => null ]); - if (isset($info['contexts'])) { - $this->assertArrayNotHasKey('custom', $info['contexts']); - } else { - $this->assertArrayNotHasKey('contexts', $info); - } + $info = $object->info(); + $this->assertArrayNotHasKey('contexts', $info); $object->delete(); } - /** - * @depends testCreateObjectWithContexts - */ - public function testRewriteObjectWithContexts(StorageObject $source) + public function testCopyOrRewriteObjectWithContexts() { - $inherited = $source->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'orignal'], + ], + ]; + + $object = $this->testCreateObjectWithContexts($initialContexts); + $inherited = $object->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); $info = $inherited->info(); - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); + $this->assertEquals('orignal', $info['contexts']['custom']['tag']['value']); $overrideKey = 'override-key'; $overrideVal = 'override-val'; - $overridden = $source->rewrite(self::$bucket, [ + $overridden = $object->rewrite(self::$bucket, [ 'name' => 'override-' . uniqid(), 'contexts' => ['custom' => [$overrideKey => ['value' => $overrideVal]]] ]); $info = $overridden->info(); $this->assertEquals($overrideVal, $info['contexts']['custom'][$overrideKey]['value']); - $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); + $this->assertArrayNotHasKey('tag', $info['contexts']['custom']); + $object->delete(); + } + + public function testOverrideContextsDuringCopyOrRewrite() + { + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'original'], + ], + ]; + + $source = $this->testCreateObjectWithContexts($initialContexts); + $destName = 'rewrite-dest-' . uniqid() . '.txt'; + + $inherited = $source->rewrite(self::$bucket, [ + 'name' => $destName + ]); + + $this->assertEquals($destName, $inherited->name()); + $this->assertEquals( + 'original', + $inherited->info()['contexts']['custom']['tag']['value'] + ); + + $overrideVal = 'new-value'; + $overridden = $source->rewrite(self::$bucket, [ + 'name' => 'overridden-' . uniqid() . '.txt', + 'contexts' => [ + 'custom' => [ + 'tag' => ['value' => $overrideVal] + ] + ] + ]); + + $info = $overridden->info(); + $this->assertEquals($overrideVal, $info['contexts']['custom']['tag']['value']); + $dropped = $source->rewrite(self::$bucket, [ + 'name' => 'dropped-' . uniqid() . '.txt', + 'dropContextGroups' => ['custom'] + ]); + $this->assertArrayNotHasKey('contexts', $dropped->info()['contexts']); $source->delete(); } - /** - * @depends testCreateObjectWithContexts - */ - public function testComposeObjectWithContexts(StorageObject $source1) + public function testOverrideContextsForComposeObject() { + $initialContexts = [ + 'custom' => [ + 'tag' => ['value' => 'file1'], + ], + ]; + + $source1 = $this->testCreateObjectWithContexts($initialContexts); $bucket = self::$client->bucket($source1->info()['bucket']); $s2Key = 's2-key'; $source2 = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . 's2-' . uniqid(), - 'contexts' => ['custom' => [$s2Key => ['value' => 'val2']]] + 'contexts' => ['custom' => [$s2Key => ['value' => 'file2']]] ]); $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); $custom = $inherit->info()['contexts']['custom']; - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $custom[self::CONTEXT_OBJECT_KEY]['value']); + $this->assertEquals('file1', $custom['tag']['value']); $oKey = 'c-override'; $oVal = 'c-val'; @@ -356,93 +416,114 @@ public function testComposeObjectWithContexts(StorageObject $source1) $source1->delete(); } - /** - * @depends testCreateObjectWithContexts - */ - public function testGetObjectWithContexts(StorageObject $object) + public function testInheritContextsForComposeObject() { - $info = $object->info(['projection' => 'full']); - $this->assertArrayHasKey('contexts', $info); - $this->assertArrayHasKey('custom', $info['contexts']); - $this->assertEquals(self::CONTEXT_OBJECT_VALUE, $info['contexts']['custom'][self::CONTEXT_OBJECT_KEY]['value']); - $object->delete(); + $initialContexts1 = [ + 'custom' => [ + 'tag' => ['value' => 'file1-original'], + ], + ]; + $source1 = $this->testCreateObjectWithContexts($initialContexts1); + + $s2Key = 's2-specific-key'; + $bucket = self::$client->bucket($source1->info()['bucket']); + $source2 = $bucket->upload('data', [ + 'name' => 'source2-' . uniqid() . '.txt', + 'contexts' => [ + 'custom' => [ + $s2Key => ['value' => 'file2-data'] + ] + ] + ]); + + $destName = 'c-inh-' . uniqid() . '.txt'; + $inheritedObject = $bucket->compose([$source1, $source2], $destName); + + $info = $inheritedObject->info(); + $custom = $info['contexts']['custom']; + + $this->assertEquals( + 'file1-original', + $custom['tag']['value'], + 'The composed object failed to inherit context from the first source.' + ); + + $this->assertArrayNotHasKey( + $s2Key, + $custom['s2-specific-key'], + 'The composed object incorrectly merged contexts from the second source.' + ); + + $source1->delete(); + $source2->delete(); } + public function testListObjectsWithContextFilters() + { + $activeContext = [ + 'custom' => [ + 'tag' => ['value' => 'active'], + 'tag2' => ['value' => 'inactive'], + 'tag3' => ['value' => 'filter'] + ], + ]; + $source1 = $this->testCreateObjectWithContexts($activeContext); + + $info = $source1->info(); + + $this->assertArrayHasKey('contexts', $info, 'Contexts property missing.'); + $this->assertArrayHasKey('custom', $info['contexts'], 'Custom context group missing.'); + + $this->assertCount(3, $info['contexts']['custom']); + return $source1; + } + /** - * @depends testCreateObjectWithContexts + * @depends testListObjectsWithContextFilters */ - public function testListObjectsWithContextFilters(StorageObject $object) + public function testExistListObjectsWithContextFilters(StorageObject $source1) { - $bucket = self::$client->bucket($object->info()['bucket']); - $prefix = self::CONTEXT_OBJECT_PREFIX . 'flt-' . uniqid(); - $uKey = 'key-✨-' . uniqid(); - $uVal = 'val-🚀-' . uniqid(); + $info = $source1->info(); - $other = $bucket->upload(self::DATA, [ - 'name' => "$prefix-target.txt", - 'contentType' => 'text/plain', - 'contexts' => ['custom' => [$uKey => ['value' => $uVal]]] - ]); + $custom = $info['contexts']['custom'] ?? []; - $filter = sprintf('contexts.custom.%s.value="%s"', $uKey, $uVal); - $objects = []; - for ($i = 0; $i < 4 && count($objects) !== 1; $i++) { - if ($i > 0) { - sleep(2); - } - $objects = iterator_to_array($bucket->objects(['filter' => $filter, 'prefix' => $prefix])); - } - $this->assertCount(1, $objects); - $this->assertEquals($uVal, $objects[0]->info()['contexts']['custom'][$uKey]['value']); - $presence = iterator_to_array($bucket->objects([ - 'filter' => "contexts.custom.$uKey:*", - 'prefix' => $prefix - ])); - $this->assertCount(1, $presence); - $absence = iterator_to_array($bucket->objects([ - 'filter' => "-contexts.custom.$uKey", - 'prefix' => $prefix - ])); - - $this->assertCount(0, $absence); - $absenceVal = iterator_to_array($bucket->objects([ - 'filter' => "-contexts.custom.$uKey.value=\"wrong-val\"", - 'prefix' => $prefix - ])); - $this->assertCount(1, $absenceVal); - $other->delete(); + $this->assertArrayHasKey('tag', $custom, 'Tag 1 is missing'); + $this->assertArrayHasKey('tag2', $custom, 'Tag 2 is missing'); + $this->assertArrayHasKey('tag3', $custom, 'Tag 3 is missing'); + + $source1->delete(); } /** - * @depends testCreateObjectWithContexts + * @depends testListObjectsWithContextFilters */ - public function testRemoveEmptyObjectWithContexts(StorageObject $object) + public function testKeyPairWithListContextObjectFilter(StorageObject $source1) { - $object->update([ - 'contexts' => [ - 'custom' => [ - self::CONTEXT_OBJECT_KEY => null - ] - ] - ]); + $info = $source1->info(); - $newInfo = $object->reload(); - if (isset($newInfo['contexts']['custom'])) { - $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $newInfo['contexts']['custom']); - } else { - $this->assertArrayNotHasKey('contexts', $newInfo); - } - $object->delete(); + $custom = $info['contexts']['custom'] ?? []; + + $this->assertArrayHasKey('tag2', $custom, 'Tag 2 is missing'); + $this->assertEquals( + 'inactive', + $custom['tag2']['value'], + 'Tag 2 value does not match expected "inactive".' + ); + + $source1->delete(); } - public function testObjectExists() + /** + * @depends testListObjectsWithContextFilters + */ + public function testAbsenceOfKeyValuePairObjectFilter(StorageObject $source1) { - $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); - $this->assertTrue($object->exists()); - $object->delete(); - $this->assertFalse($object->exists()); + $info = $source1->info(); + + } + public function testUploadAsync() { $name = uniqid(self::TESTING_PREFIX); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 068bd00ad4f1..4df675753341 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -554,11 +554,12 @@ public function testIsWritableServerException() $bucket->isWritable(); // raises exception } - public function testCreateObjectWithContexts() + public function testCreateObjectWithValidContexts() { $contexts = [ 'custom' => [ - 'test-key' => ['value' => 'test-value'] + 'dept' => ['value' => 'engineering'], + 'env' => ['value' => 'production'] ] ]; $this->resumableUploader->upload()->willReturn([ @@ -578,19 +579,33 @@ public function testCreateObjectWithContexts() } /** - * @dataProvider objectInvalidContextsDataProvider + * @dataProvider invalidAndUnicodeContextsDataProvider */ - public function testCreateObjectWithInvalidContexts($invalidContexts, $expectedMessage) + public function testCreateObjectWithInvalidAndUnicodeContexts(array $contexts, string $expectedMessage) { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedMessage); $this->getBucket()->upload('data', [ 'name' => self::FILE_NAME_TEST, - 'contexts' => $invalidContexts + 'contexts' => $contexts ]); } + public function invalidAndUnicodeContextsDataProvider() + { + return [ + 'Unicode at start of key' => [ + ['custom' => ['🚀-launcher' => ['value' => '✨-sparkle']]], + 'Object context key must start with an alphanumeric.' + ], + 'Double quotes in key' => [ + ['custom' => ['invalid"key' => ['value' => 'some-value']]], + 'Object context key cannot contain double quotes.' + ], + ]; + } + public function testUpdateReplacesAllMetadataIncludingContexts() { $objectName = 'replace-test.txt'; @@ -609,10 +624,32 @@ public function testUpdateReplacesAllMetadataIncludingContexts() $this->assertArrayNotHasKey('contentType', $result); } + public function testAddAndModifyWithIndividualContexts() + { + $patchMetadata = [ + 'contexts' => [ + 'custom' => [ + 'new-key' => ['value' => 'added'], + 'existing-key' => ['value' => 'modified'] + ] + ] + ]; + + $this->connection->patchObject(Argument::withEntry('metadata', $patchMetadata)) + ->willReturn(['metadata' => $patchMetadata]); + + $file = $this->getBucket()->object(self::FILE_NAME_TEST); + $response = $file->update(['metadata' => $patchMetadata]); + + $this->assertArrayHasKey('contexts', $response['metadata']); + $this->assertSame('added', $response['metadata']['contexts']['custom']['new-key']['value']); + $this->assertSame('modified', $response['metadata']['contexts']['custom']['existing-key']['value']); + } + /** - * @dataProvider objectContextUpdateDataProvider - */ - public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi) + * @dataProvider removeAndClearAllContextsDataProvider + */ + public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInApi) { $this->connection->patchObject(Argument::that(function ($args) use ($expectedInApi) { if ($expectedInApi === null) { @@ -642,326 +679,198 @@ public function testUpdateAndRemoveObjectContexts($inputContexts, $expectedInApi } } - public function objectContextUpdateDataProvider() + public function removeAndClearAllContextsDataProvider() { - $validContexts = ['contexts' => ['custom' => ['key-1' => ['value' => 'val-1']]]]; return [ - 'Valid Update' => [$validContexts['contexts'], $validContexts['contexts']], - 'Null Case' => [null, null] + 'remove an individual context by setting it to null' => [ + ['custom' => ['key-to-delete' => null]], + ['custom' => ['key-to-delete' => null]] + ], + 'clear all contexts by setting custom to null' => [ + ['custom' => null], + ['custom' => null] + ] ]; } - public function testClearAllObjectContexts() + public function testCopyObjectWithMetadataOverrides() { - $objectName = 'clear-test.txt'; - $object = new StorageObject( - $this->connection->reveal(), - $objectName, - self::BUCKET_NAME - ); + $destFileName = 'destination.txt'; + $metadata = [ + 'contexts' => [ + 'custom' => ['tag' => ['value' => 'overridden']], + ], + ]; - $patchData = ['contexts' => []]; - $this->connection->patchObject(Argument::withEntry('contexts', [])) + $destinationObject = $this->prophesize(StorageObject::class); + $destinationObject->info()->willReturn(['metadata' => $metadata]); + $sourceObject = $this->prophesize(StorageObject::class); + $sourceObject->copy(Argument::any(), Argument::withEntry('metadata', $metadata)) ->shouldBeCalled() - ->willReturn([ - 'name' => $objectName, - 'bucket' => self::BUCKET_NAME, - 'contexts' => [] - ]); - - $result = $object->update($patchData); - $this->assertIsArray($result['contexts']); - $this->assertEmpty($result['contexts']); - } + ->willReturn($destinationObject->reveal()); - /** - * @dataProvider objectInvalidContextsDataProvider - */ - public function testRewriteWithInvalidContexts($invalidContexts, $expectedMessage) - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage($expectedMessage); + $response = $sourceObject->reveal()->copy(self::BUCKET_NAME, [ + 'name' => $destFileName, + 'metadata' => $metadata + ]); - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - $sourceObject->rewrite('dest-bucket', ['contexts' => $invalidContexts]); + $this->assertSame( + $metadata['contexts'], + $response->info()['metadata']['contexts'] + ); } - public function objectInvalidContextsDataProvider() + public function testCombineMetadataOverridesWithContexts() { - return [ - 'Value Property Missing' => [ - ['custom' =>['key' => ['no-value-here' => 'val']]], - 'Context for key "key" must have a \'value\' property.' - ], - 'Invalid Leading Unicode' => [ - ['custom' => ['key' => ['value' => '✨-sparkle']]], - 'Object context value must start with an alphanumeric.' - ], - 'Forbidden Characters (Slash and Quote)' => [ - ['custom' => ['k1' => ['value' => 'invalid/val'], 'k2' => ['value' => 'val"quote']]], - 'Object context value cannot contain forbidden characters.' - ], - 'Key Not Starting with Alphanumeric' => [ - ['custom' => ['_key' => ['value' => 'val']]], - 'Object context key must start with an alphanumeric.' - ], - 'Custom Field Not An Array' => [ - ['custom' => 'not-an-array'], - 'Object contexts custom field must be an array.' - ] + $destName = 'combined.txt'; + $sources = ['src1.txt', 'src2.txt']; + $contexts = [ + 'custom' => ['status' => ['value' => 'composed']] ]; - } - - public function testRewriteWithEmptyContexts() - { - $sourceObject = new StorageObject($this->connection->reveal(), 'source.txt', self::BUCKET_NAME); - $this->connection->rewriteObject(Argument::withEntry('contexts', [])) - ->shouldBeCalled() - ->willReturn(['resource' => - ['name' => 'dest.txt', 'bucket' => self::BUCKET_NAME, 'generation' => '1', 'contexts' => []] - ]); - $result = $sourceObject->rewrite('dest-bucket', ['contexts' => []]); - $this->assertInstanceOf(StorageObject::class, $result); - $this->assertEmpty($result->info()['contexts']); - } - public function testRewriteWithContextOverride() - { - $sourceObject = new StorageObject( - $this->connection->reveal(), - 'source.txt', - self::BUCKET_NAME - ); - - $overriddenContexts = [ - 'custom' => ['override-key' => ['value' => 'override-val']] + $expectedOptions = [ + 'destinationBucket' => self::BUCKET_NAME, + 'destinationObject' => $destName, + 'destination' => [ + 'contexts' => $contexts, + 'contentType' => 'text/plain' + ], + 'sourceObjects' => [ + ['name' => 'src1.txt'], + ['name' => 'src2.txt'] + ] ]; - $this->connection->rewriteObject(Argument::withEntry('contexts', $overriddenContexts)) + $this->connection->composeObject($expectedOptions) ->shouldBeCalled() ->willReturn([ - 'resource' => [ - 'name' => 'dest.txt', - 'bucket' => 'dest-bucket', - 'generation' => '1', - 'contexts' => $overriddenContexts + 'name' => $destName, + 'generation' => 12345, + 'metadata' => [ + 'contexts' => $contexts ] ]); - $result = $sourceObject->rewrite('dest-bucket', [ - 'contexts' => $overriddenContexts - ]); - $this->assertInstanceOf(StorageObject::class, $result); - $this->assertEquals( - 'override-val', - $result->info()['contexts']['custom']['override-key']['value'] - ); - } - - /** - * @dataProvider objectContextComposeDataProvider - */ - public function testComposeObjectWithContexts(array $options, array $expectedContexts) - { - $destName = 'composed.txt'; - $sources = ['source1.txt', 'source2.txt']; - - $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); - $this->connection->composeObject(Argument::that(function ($args) use ($options) { - if (isset($options['contexts'])) { - return isset($args['contexts']) && $args['contexts'] === $options['contexts']; - } - return !isset($args['contexts']); - }))->shouldBeCalled()->willReturn([ - 'name' => $destName, - 'bucket' => self::BUCKET_NAME, - 'generation' => 12345, - 'contexts' => $expectedContexts + $object = $this->getBucket()->compose($sources, $destName, [ + 'metadata' => [ + 'contexts' => $contexts + ] ]); - $composedObject = $bucket->compose($sources, $destName, $options); - $this->assertInstanceOf(StorageObject::class, $composedObject); - $this->assertEquals($expectedContexts, $composedObject->info()['contexts']); - } - - public function objectContextComposeDataProvider() - { - $sourceContexts = ['custom' => ['s1-key' => ['value' => 's1-val']]]; - $overrideContexts = ['custom' => ['new-key' => ['value' => 'new-val']]]; - - return [ - 'Inherit from Source' => [[], $sourceContexts], - 'Override with New' => [['contexts' => $overrideContexts], $overrideContexts] - ]; + $this->assertEquals($destName, $object->name()); + $this->assertEquals($contexts, $object->info()['metadata']['contexts']); } - public function testGetMetadataIncludesContexts() + public function testComposeHandlesEmptyStringValuesInContexts() { - $objectName = 'metadata-'.self::FILE_NAME_TEST; - $now = (new \DateTime())->format('Y-m-d\TH:i:s.v\Z'); - $this->connection->projectId()->willReturn(self::PROJECT_ID); - $metadataResponse = [ - 'name' => $objectName, - 'bucket' => self::BUCKET_NAME, - 'generation' => 12345, + $destName = 'empty-string-test.txt'; + $sources = ['src1.txt', 'src2.txt']; + + $metadata = [ 'contexts' => [ 'custom' => [ - 'existing-key' => ['value' => 'existing-val'], - 'createTime' => $now, - 'updateTime' => $now + 'empty-key' => ['value' => ''] ] ] ]; - $this->connection->getObject(Argument::withEntry('object', $objectName)) - ->shouldBeCalled() - ->willReturn($metadataResponse); - - $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); - $info = $bucket->object($objectName)->info(); - $this->assertArrayHasKey('contexts', $info); - $this->assertArrayHasKey( - 'createTime', - $info['contexts']['custom'] - ); - $this->assertArrayHasKey( - 'updateTime', - $info['contexts']['custom'] - ); - $this->assertEquals( - 'existing-val', - $info['contexts']['custom']['existing-key']['value'] + $this->connection->composeObject([ + 'destinationBucket' => self::BUCKET_NAME, + 'destinationObject' => $destName, + 'destination' => $metadata + ['contentType' => 'text/plain'], + 'sourceObjects' => [ + ['name' => 'src1.txt'], + ['name' => 'src2.txt'] + ] + ])->shouldBeCalled()->willReturn([ + 'name' => $destName, + 'generation' => 12345, + 'metadata' => $metadata + ]); + + $object = $this->getBucket()->compose($sources, $destName, [ + 'metadata' => $metadata + ]); + + $this->assertSame( + '', + $object->info()['metadata']['contexts']['custom']['empty-key']['value'], + "The empty string value was lost or converted during the compose process." ); } - /** - * @dataProvider objectContextFilterDataProvider - */ - public function testListObjectsWithContextFilters($filter, $expectedItems) + public function testGetFiltersByPresenceOfKeyValuePair() { - $this->connection->projectId()->willReturn(self::PROJECT_ID); + $filter = 'contexts."status"="active"'; $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ - 'items' => $expectedItems + 'items' => [] ]); - $bucket = new Bucket($this->connection->reveal(), self::BUCKET_NAME); - $objects = iterator_to_array($bucket->objects(['filter' => $filter])); - - $this->assertCount(count($expectedItems), $objects); - if (count($objects) > 0 && isset($expectedItems[0]['contexts'])) { - $this->assertEquals( - $expectedItems[0]['contexts']['custom'], - $objects[0]->info()['contexts']['custom'] - ); - } - } - - public function objectContextFilterDataProvider() - { - $unicodeKey = 'key-✨'; - $unicodeVal = 'val-🚀'; - return [ - 'Presence of key/value pair' => [ - 'contexts.custom.k1.value="v1"', - [ - ['name' => 'match.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], - ] - ], - 'Absence of key/value pair' => [ - '-contexts.custom.k1.value="v1"', - [ - ['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v2']]]], // Diff value - ['name' => 'f2.txt'], - ] - ], - 'Presence of key regardless of value' => [ - 'contexts.custom.k1:*', - [ - ['name' => 'f1.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'v1']]]], - ['name' => 'f2.txt', 'contexts' => ['custom' => ['k1' => ['value' => 'any']]]], - ] - ], - 'Unicode key/value pair' => [ - sprintf('contexts.custom.%s.value="%s"', $unicodeKey, $unicodeVal), - [ - ['name' => 'unicode.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]], - ['name' => 'another.txt', 'contexts' => ['custom' => [$unicodeKey => ['value' => $unicodeVal]]]] - ] - ] - ]; + $bucket = $this->getBucket(); + $iterator = $bucket->objects([ + 'filter' => $filter + ]); + $iterator->current(); } /** - * @dataProvider objectContextPatchDataProvider + * @dataProvider filterExistenceDataProvider */ - public function testPatchExistingObjectContext($key, $value) + public function testGetFiltersByExistence($filter) { - $objectName = 'patch-'.self::FILE_NAME_TEST; - $object = new StorageObject($this->connection->reveal(), $objectName, self::BUCKET_NAME); - - $patchData = ['contexts' => ['custom' => [$key => $value]]]; - - $this->connection->patchObject(Argument::withEntry('contexts', $patchData['contexts'])) - ->shouldBeCalledTimes(1) - ->willReturn([ - 'name' => $objectName, - 'contexts' => $patchData['contexts'] - ]); + $this->connection->listObjects(Argument::withEntry('filter', $filter)) + ->shouldBeCalled() + ->willReturn([ + 'items' => [] + ]); - $result = $object->update($patchData); - $this->assertEquals($value, $result['contexts']['custom'][$key]); + $bucket = $this->getBucket(); + $iterator = $bucket->objects([ + 'filter' => $filter + ]); + + $iterator->current(); } - public function objectContextPatchDataProvider() + public function filterExistenceDataProvider() { return [ - 'Update Key' => ['new-key', 'brand-new-val'], - 'Delete Key' => ['key-to-delete', null], - 'Empty Value' => ['key-with-empty', ''], - 'Special Chars Key' => ['key123', 'value-456'] + 'presence of key (Existence)' => ['contexts."status":*'], + 'absence of key (Non-existence)' => ['-contexts."status":*'] ]; } - public function testRewriteObjectWithContexts() + public function testGetFilesIncludesContextsInMetadata() { - $contexts = [ - 'custom' => [ - 'rewrite-key' => ['value' => 'rewrite-val'] + $fileMetadata = [ + 'name' => 'filename', + 'metadata' => [ + 'contexts' => [ + 'custom' => [ + 'dept' => ['value' => 'eng', 'createTime' => '...'] + ] + ] ] ]; - $destBucket = 'other-bucket'; - $destName = 'rewritten-data.txt'; - - $this->connection->rewriteObject(Argument::that(function ($args) use ($contexts) { - return isset($args['contexts']) && $args['contexts'] === $contexts; - }))->willReturn([ - 'rewriteToken' => null, - 'resource' => [ - 'name' => $destName, - 'bucket' => $destBucket, - 'generation' => 456, - 'contexts' => $contexts - ] - ]); - $sourceBucket = 'source-bucket'; - $sourceObject = new StorageObject( - $this->connection->reveal(), - 'source-file.txt', - $sourceBucket, - 123, - ['bucket' => $sourceBucket] - ); + $this->connection->listObjects(Argument::any()) + ->shouldBeCalled() + ->willReturn([ + 'items' => [$fileMetadata] + ]); - $object = $sourceObject->rewrite($destBucket, [ - 'contexts' => $contexts - ]); - $this->assertInstanceOf(StorageObject::class, $object); - $this->assertEquals($destName, $object->name()); - $this->assertArrayHasKey('contexts', $object->info()); - $this->assertEquals($contexts, $object->info()['contexts']); + $bucket = $this->getBucket(); + $files = iterator_to_array($bucket->objects()); + + $this->assertCount(1, $files); + $this->assertInstanceOf(StorageObject::class, $files[0]); + + $this->assertEquals( + $fileMetadata['metadata']['contexts'], + $files[0]->info()['metadata']['contexts'] + ); } public function testIam() From f8a845dcac8c54663990dc3785870b2f024cbb1e Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 10 Apr 2026 12:07:04 +0000 Subject: [PATCH 45/64] Add New cases and scenarios --- Storage/src/Bucket.php | 51 +++++++- .../ServiceDefinition/storage-v1.json | 5 + Storage/src/StorageObject.php | 5 - Storage/src/ValidateContextsTrait.php | 72 ----------- Storage/tests/System/ManageObjectsTest.php | 116 ++++++++---------- Storage/tests/Unit/BucketTest.php | 4 +- 6 files changed, 109 insertions(+), 144 deletions(-) delete mode 100644 Storage/src/ValidateContextsTrait.php diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 733b45df8777..18a38e36c999 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -52,7 +52,6 @@ class Bucket { use ArrayTrait; use EncryptionTrait; - use ValidateContextsTrait; const NOTIFICATION_TEMPLATE = '//pubsub.googleapis.com/%s'; const TOPIC_TEMPLATE = 'projects/%s/topics/%s'; @@ -331,6 +330,49 @@ public function upload($data, array $options = []) ); } + private function validateContexts(array $contexts) + { + if (!isset($contexts['custom'])) { + return; + } + if (!is_array($contexts['custom'])) { + throw new \InvalidArgumentException('Object contexts custom field must be an array.'); + } + foreach ($contexts['custom'] as $key => $data) { + if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { + throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); + } + if (strpos((string) $key, '"') !== false) { + throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); + } + if (!is_array($data)) { + throw new \InvalidArgumentException(sprintf( + 'Context data for key "%s" must be an array.', + $key + )); + } + if (!isset($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context for key "%s" must have a \'value\' property.', + $key + )); + } + if (!is_scalar($data['value'])) { + throw new \InvalidArgumentException(sprintf( + 'Context value for key "%s" must be a scalar type.', + $key + )); + } + $val = (string) $data['value']; + if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { + throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); + } + if (strpos($val, '/') !== false || strpos($val, '"') !== false) { + throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); + } + } + } + /** * Asynchronously uploads an object. * @@ -697,7 +739,10 @@ public function restore($name, $generation, array $options = []) * * @param array $options [optional] { * Configuration options. - * + * + * @type string $filter Filter results to include only objects to which the + * specified context is attached. You can filter by the presence, + * absence, or specific value of context keys. * @type string $delimiter Returns results in a directory-like mode. * Results will contain only objects whose names, aside from the * prefix, do not contain delimiter. Objects whose names, aside @@ -729,7 +774,7 @@ public function restore($name, $generation, array $options = []) public function objects(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false); - + return new ObjectIterator( new ObjectPageIterator( function (array $object) { diff --git a/Storage/src/Connection/ServiceDefinition/storage-v1.json b/Storage/src/Connection/ServiceDefinition/storage-v1.json index 0874455fb51a..ab44fd020554 100644 --- a/Storage/src/Connection/ServiceDefinition/storage-v1.json +++ b/Storage/src/Connection/ServiceDefinition/storage-v1.json @@ -4824,6 +4824,11 @@ "required": true, "location": "path" }, + "filter": { + "type": "string", + "description": "Filter results to include only objects to which the specified context is attached. You can filter by the presence, absence, or specific value of context keys.", + "location": "query" + }, "delimiter": { "type": "string", "description": "Returns results in a directory-like mode. items will contain only objects whose names, aside from the prefix, do not contain delimiter. Objects whose names, aside from the prefix, contain delimiter will have their name, truncated after the delimiter, returned in prefixes. Duplicate prefixes are omitted.", diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index f7d7f905c5b5..f6292bd22dd0 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -44,7 +44,6 @@ class StorageObject { use ArrayTrait; use EncryptionTrait; - use ValidateContextsTrait; /** * @deprecated @@ -474,10 +473,6 @@ public function rewrite($destination, array $options = []) $options = $this->formatDestinationRequest($destination, $options); - if (isset($options['contexts'])) { - $this->validateContexts($options['contexts']); - } - do { $response = $this->connection->rewriteObject($options); $options['rewriteToken'] = $response['rewriteToken'] ?? null; diff --git a/Storage/src/ValidateContextsTrait.php b/Storage/src/ValidateContextsTrait.php deleted file mode 100644 index a778f746e5d8..000000000000 --- a/Storage/src/ValidateContextsTrait.php +++ /dev/null @@ -1,72 +0,0 @@ - $data) { - if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { - throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); - } - if (strpos((string) $key, '"') !== false) { - throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); - } - if (!is_array($data)) { - throw new \InvalidArgumentException(sprintf( - 'Context data for key "%s" must be an array.', - $key - )); - } - if (!isset($data['value'])) { - throw new \InvalidArgumentException(sprintf( - 'Context for key "%s" must have a \'value\' property.', - $key - )); - } - if (!is_scalar($data['value'])) { - throw new \InvalidArgumentException(sprintf( - 'Context value for key "%s" must be a scalar type.', - $key - )); - } - $val = (string) $data['value']; - if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { - throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); - } - if (strpos($val, '/') !== false || strpos($val, '"') !== false) { - throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); - } - } - } -} diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 7944f7c42a63..d034c93d51ed 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -33,7 +33,6 @@ class ManageObjectsTest extends StorageTestCase const CONTEXT_OBJECT_KEY = 'insert-key'; const CONTEXT_OBJECT_VALUE = 'insert-val'; const CONTEXT_OBJECT_PREFIX = 'object-contexts-'; - public function testListsObjects() { $foundObjects = []; @@ -339,7 +338,7 @@ public function testCopyOrRewriteObjectWithContexts() $object->delete(); } - public function testOverrideContextsDuringCopyOrRewrite() + public function testOverrideContextsDuringCopy() { $initialContexts = [ 'custom' => [ @@ -361,7 +360,7 @@ public function testOverrideContextsDuringCopyOrRewrite() ); $overrideVal = 'new-value'; - $overridden = $source->rewrite(self::$bucket, [ + $overridden = $source->copy(self::$bucket, [ 'name' => 'overridden-' . uniqid() . '.txt', 'contexts' => [ 'custom' => [ @@ -460,70 +459,63 @@ public function testInheritContextsForComposeObject() public function testListObjectsWithContextFilters() { - $activeContext = [ - 'custom' => [ - 'tag' => ['value' => 'active'], - 'tag2' => ['value' => 'inactive'], - 'tag3' => ['value' => 'filter'] - ], - ]; - $source1 = $this->testCreateObjectWithContexts($activeContext); - - $info = $source1->info(); - - $this->assertArrayHasKey('contexts', $info, 'Contexts property missing.'); - $this->assertArrayHasKey('custom', $info['contexts'], 'Custom context group missing.'); - - $this->assertCount(3, $info['contexts']['custom']); - return $source1; - } - - /** - * @depends testListObjectsWithContextFilters - */ - public function testExistListObjectsWithContextFilters(StorageObject $source1) - { - $info = $source1->info(); - - $custom = $info['contexts']['custom'] ?? []; - - $this->assertArrayHasKey('tag', $custom, 'Tag 1 is missing'); - $this->assertArrayHasKey('tag2', $custom, 'Tag 2 is missing'); - $this->assertArrayHasKey('tag3', $custom, 'Tag 3 is missing'); - - $source1->delete(); - } - - /** - * @depends testListObjectsWithContextFilters - */ - public function testKeyPairWithListContextObjectFilter(StorageObject $source1) - { - $info = $source1->info(); - - $custom = $info['contexts']['custom'] ?? []; - - $this->assertArrayHasKey('tag2', $custom, 'Tag 2 is missing'); - $this->assertEquals( - 'inactive', - $custom['tag2']['value'], - 'Tag 2 value does not match expected "inactive".' - ); + + $bucketName = 'test-context-filter-' . time(); + $bucket = self::createBucket(self::$client, $bucketName); + try{ + $activeFile = $bucket->upload('content', [ + 'name' => 'test-active.txt', + 'metadata' => ['contexts' => ['custom' => ['status' => ['value' => 'active']]]] + ]); - $source1->delete(); - } + $inactiveFile = $bucket->upload('content', [ + 'name' => 'test-inactive.txt', + 'metadata' => ['contexts' => ['custom' => ['status' => ['value' => 'inactive']]]] + ]); - /** - * @depends testListObjectsWithContextFilters - */ - public function testAbsenceOfKeyValuePairObjectFilter(StorageObject $source1) - { - $info = $source1->info(); - + $noneFile = $bucket->upload('content', [ + 'name' => 'test-none.txt' + ]); + sleep(2); + + $objects = iterator_to_array($bucket->objects()); + $this->assertCount(3, $objects); + + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="active"' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($activeFile->name(), $objects[0]->name()); + + $objects = iterator_to_array($bucket->objects([ + 'filter' => '-contexts."status"="active"' + ])); + $this->assertCount(2, $objects); + + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status":*' + ])); + $this->assertCount(2, $objects); // Active and Inactive File + + $objects = iterator_to_array($bucket->objects([ + 'filter' => '-contexts."status":*' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($noneFile->name(), $objects[0]->name()); + + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="ghost"' + ])); + $this->assertCount(0, $objects); + }finally { + foreach ($bucket->objects() as $object) { + $object->delete(); + } + $bucket->delete(); + } } - public function testUploadAsync() { $name = uniqid(self::TESTING_PREFIX); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 4df675753341..cd41c531b879 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -805,7 +805,7 @@ public function testGetFiltersByPresenceOfKeyValuePair() $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ - 'items' => [] + 'items' => null ]); $bucket = $this->getBucket(); @@ -823,7 +823,7 @@ public function testGetFiltersByExistence($filter) $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ - 'items' => [] + 'items' => null ]); $bucket = $this->getBucket(); From eac4fd36490edf5d1e1f4298771092cb562e8c59 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 09:20:49 +0000 Subject: [PATCH 46/64] Updated code --- Storage/src/Bucket.php | 6 +-- Storage/tests/System/ManageObjectsTest.php | 55 ++++++++++------------ Storage/tests/Unit/BucketTest.php | 12 ++--- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 18a38e36c999..e3347fe6ed4f 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -739,9 +739,9 @@ public function restore($name, $generation, array $options = []) * * @param array $options [optional] { * Configuration options. - * - * @type string $filter Filter results to include only objects to which the - * specified context is attached. You can filter by the presence, + * + * @type string $filter Filter results to include only objects to which the + * specified context is attached. You can filter by the presence, * absence, or specific value of context keys. * @type string $delimiter Returns results in a directory-like mode. * Results will contain only objects whose names, aside from the diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index d034c93d51ed..5934214c3e6a 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -219,7 +219,7 @@ public function testObjectRetentionUnlockedMode() private function testCreateObjectWithContexts(array $uploadContexts) { - $bucket = self::$client->createBucket(uniqid('object-contexts-')); + $bucket = self::$client->createBucket(uniqid('object-contexts-')); $object = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), @@ -241,7 +241,7 @@ public function testCreateRetrieveAndUpdateObjectContexts() $metadata = $object->info(); $this->assertArrayHasKey('contexts', $metadata); $this->assertEquals( - 'storage-team', + 'storage-team', $metadata['contexts']['custom']['team-owner']['value'] ); $this->assertArrayHasKey('createTime', $metadata['contexts']['custom']['team-owner']); @@ -249,9 +249,9 @@ public function testCreateRetrieveAndUpdateObjectContexts() $patchMetadata = [ 'contexts' => [ 'custom' => [ - 'priority' => ['value' => 'critical'], - 'env' => ['value' => 'prod'], - 'team-owner' => null, + 'priority' => ['value' => 'critical'], + 'env' => ['value' => 'prod'], + 'team-owner' => null, ], ], ]; @@ -259,15 +259,13 @@ public function testCreateRetrieveAndUpdateObjectContexts() $finalCustom = $updatedMetadata['contexts']['custom']; $this->assertEquals('critical', $finalCustom['priority']['value']); $this->assertEquals('prod', $finalCustom['env']['value']); - $this->assertArrayNotHasKey('team-owner', $finalCustom); - $this->assertArrayHasKey('updateTime', $finalCustom['priority']); $object->delete(); } public function testGetContextAndServerGenratedTimes() - { + { $initialContexts = [ 'custom' => [ 'temp-key' => ['value' => 'temp'], @@ -282,13 +280,13 @@ public function testGetContextAndServerGenratedTimes() $context = $info['contexts']['custom']['status']; $this->assertEquals('to-be-cleared', $context['value']); $this->assertArrayHasKey( - 'createTime', - $context, + 'createTime', + $context, 'Server failed to generate createTime for context.' ); $this->assertArrayHasKey( - 'updateTime', - $context, + 'updateTime', + $context, 'Server failed to generate updateTime for context.' ); $object->delete(); @@ -355,7 +353,7 @@ public function testOverrideContextsDuringCopy() $this->assertEquals($destName, $inherited->name()); $this->assertEquals( - 'original', + 'original', $inherited->info()['contexts']['custom']['tag']['value'] ); @@ -437,19 +435,16 @@ public function testInheritContextsForComposeObject() $destName = 'c-inh-' . uniqid() . '.txt'; $inheritedObject = $bucket->compose([$source1, $source2], $destName); - - $info = $inheritedObject->info(); - $custom = $info['contexts']['custom']; + $custom = $inheritedObject->info()['contexts']['custom']; $this->assertEquals( - 'file1-original', - $custom['tag']['value'], + 'file1-original', + $custom['tag']['value'], 'The composed object failed to inherit context from the first source.' ); - $this->assertArrayNotHasKey( - $s2Key, - $custom['s2-specific-key'], + $s2Key, + $custom['s2-specific-key'], 'The composed object incorrectly merged contexts from the second source.' ); @@ -459,10 +454,9 @@ public function testInheritContextsForComposeObject() public function testListObjectsWithContextFilters() { - $bucketName = 'test-context-filter-' . time(); $bucket = self::createBucket(self::$client, $bucketName); - try{ + try { $activeFile = $bucket->upload('content', [ 'name' => 'test-active.txt', 'metadata' => ['contexts' => ['custom' => ['status' => ['value' => 'active']]]] @@ -476,12 +470,15 @@ public function testListObjectsWithContextFilters() $noneFile = $bucket->upload('content', [ 'name' => 'test-none.txt' ]); - - sleep(2); - $objects = iterator_to_array($bucket->objects()); $this->assertCount(3, $objects); + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."status"="inactive"' + ])); + $this->assertCount(1, $objects); + $this->assertEquals($inactiveFile->name(), $objects[0]->name()); + $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status"="active"' ])); @@ -491,12 +488,12 @@ public function testListObjectsWithContextFilters() $objects = iterator_to_array($bucket->objects([ 'filter' => '-contexts."status"="active"' ])); - $this->assertCount(2, $objects); + $this->assertCount(2, $objects); $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status":*' ])); - $this->assertCount(2, $objects); // Active and Inactive File + $this->assertCount(2, $objects); $objects = iterator_to_array($bucket->objects([ 'filter' => '-contexts."status":*' @@ -508,7 +505,7 @@ public function testListObjectsWithContextFilters() 'filter' => 'contexts."status"="ghost"' ])); $this->assertCount(0, $objects); - }finally { + } finally { foreach ($bucket->objects() as $object) { $object->delete(); } diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index cd41c531b879..cbdefd4faca3 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -683,12 +683,12 @@ public function removeAndClearAllContextsDataProvider() { return [ 'remove an individual context by setting it to null' => [ - ['custom' => ['key-to-delete' => null]], + ['custom' => ['key-to-delete' => null]], ['custom' => ['key-to-delete' => null]] ], 'clear all contexts by setting custom to null' => [ - ['custom' => null], - ['custom' => null] + ['custom' => null], + ['custom' => null] ] ]; } @@ -733,7 +733,7 @@ public function testCombineMetadataOverridesWithContexts() 'destinationObject' => $destName, 'destination' => [ 'contexts' => $contexts, - 'contentType' => 'text/plain' + 'contentType' => 'text/plain' ], 'sourceObjects' => [ ['name' => 'src1.txt'], @@ -805,14 +805,14 @@ public function testGetFiltersByPresenceOfKeyValuePair() $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() ->willReturn([ - 'items' => null + 'items' => null ]); $bucket = $this->getBucket(); $iterator = $bucket->objects([ 'filter' => $filter ]); - $iterator->current(); + $iterator->current(); } /** From b91937623bcf73a9154d0bfa66a94b28aa8f20d3 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 09:59:55 +0000 Subject: [PATCH 47/64] Updated code --- Storage/src/Bucket.php | 6 +++--- Storage/tests/System/ManageObjectsTest.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index e3347fe6ed4f..b942a6c3194b 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -740,9 +740,6 @@ public function restore($name, $generation, array $options = []) * @param array $options [optional] { * Configuration options. * - * @type string $filter Filter results to include only objects to which the - * specified context is attached. You can filter by the presence, - * absence, or specific value of context keys. * @type string $delimiter Returns results in a directory-like mode. * Results will contain only objects whose names, aside from the * prefix, do not contain delimiter. Objects whose names, aside @@ -765,6 +762,9 @@ public function restore($name, $generation, array $options = []) * distinct results. **Defaults to** `false`. * @type string $fields Selector which will cause the response to only * return the specified fields. + * @type string $filter Filter results to include only objects to which the + * specified context is attached. You can filter by the presence, + * absence, or specific value of context keys. * @type string $matchGlob A glob pattern to filter results. The string * value must be UTF-8 encoded. See: * https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 5934214c3e6a..e315411828e4 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -493,7 +493,7 @@ public function testListObjectsWithContextFilters() $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status":*' ])); - $this->assertCount(2, $objects); + $this->assertCount(2, $objects); $objects = iterator_to_array($bucket->objects([ 'filter' => '-contexts."status":*' From b65b013104792a43f27529f83c10bfa1696a2f21 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 10:10:47 +0000 Subject: [PATCH 48/64] Updated code --- Storage/src/Bucket.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index b942a6c3194b..c344c6e41ed4 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -762,9 +762,6 @@ public function restore($name, $generation, array $options = []) * distinct results. **Defaults to** `false`. * @type string $fields Selector which will cause the response to only * return the specified fields. - * @type string $filter Filter results to include only objects to which the - * specified context is attached. You can filter by the presence, - * absence, or specific value of context keys. * @type string $matchGlob A glob pattern to filter results. The string * value must be UTF-8 encoded. See: * https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob From 6ae713899a9e5612540d4ec44e4cfabe0cb15b0b Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 10:28:40 +0000 Subject: [PATCH 49/64] Updated code --- Storage/src/Bucket.php | 127 ++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 64 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index c344c6e41ed4..b4250f92e2e6 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -1,4 +1,6 @@ connection = $connection; $this->identity = [ 'bucket' => $name, - 'userProject' => $this->pluck('requesterProjectId', $info, false) + 'userProject' => $this->pluck('requesterProjectId', $info, false), ]; $this->info = $info; $this->projectId = $this->connection->projectId(); @@ -315,7 +317,7 @@ public function upload($data, array $options = []) $response = $this->connection->insertObject( $this->formatEncryptionHeaders($options) + $this->identity + [ - 'data' => $data + 'data' => $data, ] )->upload(); @@ -330,7 +332,7 @@ public function upload($data, array $options = []) ); } - private function validateContexts(array $contexts) + private function validateContexts(array $contexts): void { if (!isset($contexts['custom'])) { return; @@ -342,7 +344,7 @@ private function validateContexts(array $contexts) if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); } - if (strpos((string) $key, '"') !== false) { + if (str_contains((string) $key, '"')) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); } if (!is_array($data)) { @@ -367,7 +369,7 @@ private function validateContexts(array $contexts) if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } - if (strpos($val, '/') !== false || strpos($val, '"') !== false) { + if (str_contains($val, '/') || str_contains($val, '"')) { throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); } } @@ -458,26 +460,24 @@ public function uploadAsync($data, array $options = []) $encryptionKeySHA256 = $options['encryptionKeySHA256'] ?? null; $promise = $this->connection->insertObject( - $this->formatEncryptionHeaders($options) + - $this->identity + - [ - 'data' => $data, - 'resumable' => false + $this->formatEncryptionHeaders($options) + + $this->identity + + [ + 'data' => $data, + 'resumable' => false, ] )->uploadAsync(); return $promise->then( - function (array $response) use ($encryptionKey, $encryptionKeySHA256) { - return new StorageObject( - $this->connection, - $response['name'], - $this->identity['bucket'], - $response['generation'], - $response, - $encryptionKey, - $encryptionKeySHA256 - ); - } + fn(array $response) => new StorageObject( + $this->connection, + $response['name'], + $this->identity['bucket'], + $response['generation'], + $response, + $encryptionKey, + $encryptionKeySHA256 + ) ); } @@ -551,7 +551,7 @@ public function getResumableUploader($data, array $options = []) return $this->connection->insertObject( $this->formatEncryptionHeaders($options) + $this->identity + [ 'data' => $data, - 'resumable' => true + 'resumable' => true, ] ); } @@ -620,7 +620,7 @@ public function getStreamableUploader($data, array $options = []) $this->formatEncryptionHeaders($options) + $this->identity + [ 'data' => $data, 'streamable' => true, - 'validate' => false + 'validate' => false, ] ); } @@ -668,7 +668,7 @@ public function object($name, array $options = []) $this->identity['bucket'], $generation, array_filter([ - 'requesterProjectId' => $this->identity['userProject'] + 'requesterProjectId' => $this->identity['userProject'], ]), $encryptionKey, $encryptionKeySHA256 @@ -714,7 +714,7 @@ public function restore($name, $generation, array $options = []) $this->identity['bucket'], $res['generation'], // restored object will have a new generation $res + array_filter([ - 'requesterProjectId' => $this->identity['userProject'] + 'requesterProjectId' => $this->identity['userProject'], ]) ); } @@ -762,6 +762,9 @@ public function restore($name, $generation, array $options = []) * distinct results. **Defaults to** `false`. * @type string $fields Selector which will cause the response to only * return the specified fields. + * @type string $filter Filter results to include only objects to which the + * specified context is attached. You can filter by the presence, + * absence, or specific value of context keys. * @type string $matchGlob A glob pattern to filter results. The string * value must be UTF-8 encoded. See: * https://cloud.google.com/storage/docs/json_api/v1/objects/list#list-object-glob @@ -771,20 +774,18 @@ public function restore($name, $generation, array $options = []) public function objects(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false); - + return new ObjectIterator( new ObjectPageIterator( - function (array $object) { - return new StorageObject( - $this->connection, - $object['name'], - $this->identity['bucket'], - isset($object['generation']) ? $object['generation'] : null, - $object + array_filter([ - 'requesterProjectId' => $this->identity['userProject'] - ]) - ); - }, + fn(array $object) => new StorageObject( + $this->connection, + $object['name'], + $this->identity['bucket'], + $object['generation'] ?? null, + $object + array_filter([ + 'requesterProjectId' => $this->identity['userProject'], + ]) + ), [$this->connection, 'listObjects'], $options + $this->identity, ['resultLimit' => $resultLimit] @@ -885,7 +886,7 @@ public function createNotification($topic, array $options = []) { $res = $this->connection->insertNotification($options + $this->identity + [ 'topic' => $this->getFormattedTopic($topic), - 'payload_format' => 'JSON_API_V1' + 'payload_format' => 'JSON_API_V1', ]); return new Notification( @@ -893,7 +894,7 @@ public function createNotification($topic, array $options = []) $res['id'], $this->identity['bucket'], $res + [ - 'requesterProjectId' => $this->identity['userProject'] + 'requesterProjectId' => $this->identity['userProject'], ] ); } @@ -964,16 +965,14 @@ public function notifications(array $options = []) /** @var ItemIterator */ return new ItemIterator( new PageIterator( - function (array $notification) { - return new Notification( - $this->connection, - $notification['id'], - $this->identity['bucket'], - $notification + [ - 'requesterProjectId' => $this->identity['userProject'] - ] - ); - }, + fn(array $notification) => new Notification( + $this->connection, + $notification['id'], + $this->identity['bucket'], + $notification + [ + 'requesterProjectId' => $this->identity['userProject'], + ] + ), [$this->connection, 'listNotifications'], $options + $this->identity, ['resultLimit' => $resultLimit] @@ -1000,7 +999,7 @@ function (array $notification) { * } * @return void */ - public function delete(array $options = []) + public function delete(array $options = []): void { $this->connection->deleteBucket($options + $this->identity); } @@ -1179,8 +1178,8 @@ public function compose(array $sourceObjects, $name, array $options = []) $options += [ 'destinationBucket' => $this->name(), 'destinationObject' => $name, - 'destinationPredefinedAcl' => isset($options['predefinedAcl']) ? $options['predefinedAcl'] : null, - 'destination' => isset($options['metadata']) ? $options['metadata'] : null, + 'destinationPredefinedAcl' => $options['predefinedAcl'] ?? null, + 'destination' => $options['metadata'] ?? null, 'userProject' => $this->identity['userProject'], 'sourceObjects' => array_map(function ($sourceObject) { $name = null; @@ -1193,9 +1192,9 @@ public function compose(array $sourceObjects, $name, array $options = []) return array_filter([ 'name' => $name ?: $sourceObject, - 'generation' => $generation + 'generation' => $generation, ]); - }, $sourceObjects) + }, $sourceObjects), ]; if (!isset($options['destination']['contentType'])) { @@ -1217,7 +1216,7 @@ public function compose(array $sourceObjects, $name, array $options = []) $this->identity['bucket'], $response['generation'], $response + array_filter([ - 'requesterProjectId' => $this->identity['userProject'] + 'requesterProjectId' => $this->identity['userProject'], ]) ); } @@ -1460,7 +1459,7 @@ public function iam() $this->identity['bucket'], [ 'parent' => null, - 'args' => $this->identity + 'args' => $this->identity, ] ); } @@ -1517,9 +1516,9 @@ public function lockRetentionPolicy(array $options = []) if (!isset($options['ifMetagenerationMatch'])) { if (!isset($this->info['metageneration'])) { throw new \BadMethodCallException( - 'No metageneration value was detected. Please either provide ' . - 'a value explicitly or ensure metadata is loaded through a ' . - 'call such as Bucket::reload().' + 'No metageneration value was detected. Please either provide ' + . 'a value explicitly or ensure metadata is loaded through a ' + . 'call such as Bucket::reload().' ); } @@ -1817,8 +1816,8 @@ private function getFormattedTopic($topic) if (!$this->projectId) { throw new GoogleException( - 'No project ID was provided, ' . - 'and we were unable to detect a default project ID.' + 'No project ID was provided, ' + . 'and we were unable to detect a default project ID.' ); } From 60fab43edb8a7ef7fad41f9ff8a4d457a435129a Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 10:32:28 +0000 Subject: [PATCH 50/64] Updated code --- Storage/src/Bucket.php | 104 +++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index b4250f92e2e6..022c9c66bf0a 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -1,6 +1,4 @@ connection = $connection; $this->identity = [ 'bucket' => $name, - 'userProject' => $this->pluck('requesterProjectId', $info, false), + 'userProject' => $this->pluck('requesterProjectId', $info, false) ]; $this->info = $info; $this->projectId = $this->connection->projectId(); @@ -317,7 +315,7 @@ public function upload($data, array $options = []) $response = $this->connection->insertObject( $this->formatEncryptionHeaders($options) + $this->identity + [ - 'data' => $data, + 'data' => $data ] )->upload(); @@ -332,7 +330,7 @@ public function upload($data, array $options = []) ); } - private function validateContexts(array $contexts): void + private function validateContexts(array $contexts) { if (!isset($contexts['custom'])) { return; @@ -344,7 +342,7 @@ private function validateContexts(array $contexts): void if (!preg_match('/^[a-zA-Z0-9]/', (string) $key)) { throw new \InvalidArgumentException('Object context key must start with an alphanumeric.'); } - if (str_contains((string) $key, '"')) { + if (strpos((string) $key, '"') !== false) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); } if (!is_array($data)) { @@ -369,7 +367,7 @@ private function validateContexts(array $contexts): void if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } - if (str_contains($val, '/') || str_contains($val, '"')) { + if (strpos($val, '/') !== false || strpos($val, '"') !== false) { throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); } } @@ -460,24 +458,26 @@ public function uploadAsync($data, array $options = []) $encryptionKeySHA256 = $options['encryptionKeySHA256'] ?? null; $promise = $this->connection->insertObject( - $this->formatEncryptionHeaders($options) - + $this->identity - + [ - 'data' => $data, - 'resumable' => false, + $this->formatEncryptionHeaders($options) + + $this->identity + + [ + 'data' => $data, + 'resumable' => false ] )->uploadAsync(); return $promise->then( - fn(array $response) => new StorageObject( - $this->connection, - $response['name'], - $this->identity['bucket'], - $response['generation'], - $response, - $encryptionKey, - $encryptionKeySHA256 - ) + function (array $response) use ($encryptionKey, $encryptionKeySHA256) { + return new StorageObject( + $this->connection, + $response['name'], + $this->identity['bucket'], + $response['generation'], + $response, + $encryptionKey, + $encryptionKeySHA256 + ); + } ); } @@ -551,7 +551,7 @@ public function getResumableUploader($data, array $options = []) return $this->connection->insertObject( $this->formatEncryptionHeaders($options) + $this->identity + [ 'data' => $data, - 'resumable' => true, + 'resumable' => true ] ); } @@ -620,7 +620,7 @@ public function getStreamableUploader($data, array $options = []) $this->formatEncryptionHeaders($options) + $this->identity + [ 'data' => $data, 'streamable' => true, - 'validate' => false, + 'validate' => false ] ); } @@ -668,7 +668,7 @@ public function object($name, array $options = []) $this->identity['bucket'], $generation, array_filter([ - 'requesterProjectId' => $this->identity['userProject'], + 'requesterProjectId' => $this->identity['userProject'] ]), $encryptionKey, $encryptionKeySHA256 @@ -714,7 +714,7 @@ public function restore($name, $generation, array $options = []) $this->identity['bucket'], $res['generation'], // restored object will have a new generation $res + array_filter([ - 'requesterProjectId' => $this->identity['userProject'], + 'requesterProjectId' => $this->identity['userProject'] ]) ); } @@ -774,7 +774,7 @@ public function restore($name, $generation, array $options = []) public function objects(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false); - + return new ObjectIterator( new ObjectPageIterator( fn(array $object) => new StorageObject( @@ -886,7 +886,7 @@ public function createNotification($topic, array $options = []) { $res = $this->connection->insertNotification($options + $this->identity + [ 'topic' => $this->getFormattedTopic($topic), - 'payload_format' => 'JSON_API_V1', + 'payload_format' => 'JSON_API_V1' ]); return new Notification( @@ -894,7 +894,7 @@ public function createNotification($topic, array $options = []) $res['id'], $this->identity['bucket'], $res + [ - 'requesterProjectId' => $this->identity['userProject'], + 'requesterProjectId' => $this->identity['userProject'] ] ); } @@ -965,14 +965,16 @@ public function notifications(array $options = []) /** @var ItemIterator */ return new ItemIterator( new PageIterator( - fn(array $notification) => new Notification( - $this->connection, - $notification['id'], - $this->identity['bucket'], - $notification + [ - 'requesterProjectId' => $this->identity['userProject'], - ] - ), + function (array $notification) { + return new Notification( + $this->connection, + $notification['id'], + $this->identity['bucket'], + $notification + [ + 'requesterProjectId' => $this->identity['userProject'] + ] + ); + }, [$this->connection, 'listNotifications'], $options + $this->identity, ['resultLimit' => $resultLimit] @@ -999,7 +1001,7 @@ public function notifications(array $options = []) * } * @return void */ - public function delete(array $options = []): void + public function delete(array $options = []) { $this->connection->deleteBucket($options + $this->identity); } @@ -1178,8 +1180,8 @@ public function compose(array $sourceObjects, $name, array $options = []) $options += [ 'destinationBucket' => $this->name(), 'destinationObject' => $name, - 'destinationPredefinedAcl' => $options['predefinedAcl'] ?? null, - 'destination' => $options['metadata'] ?? null, + 'destinationPredefinedAcl' => isset($options['predefinedAcl']) ? $options['predefinedAcl'] : null, + 'destination' => isset($options['metadata']) ? $options['metadata'] : null, 'userProject' => $this->identity['userProject'], 'sourceObjects' => array_map(function ($sourceObject) { $name = null; @@ -1192,9 +1194,9 @@ public function compose(array $sourceObjects, $name, array $options = []) return array_filter([ 'name' => $name ?: $sourceObject, - 'generation' => $generation, + 'generation' => $generation ]); - }, $sourceObjects), + }, $sourceObjects) ]; if (!isset($options['destination']['contentType'])) { @@ -1216,7 +1218,7 @@ public function compose(array $sourceObjects, $name, array $options = []) $this->identity['bucket'], $response['generation'], $response + array_filter([ - 'requesterProjectId' => $this->identity['userProject'], + 'requesterProjectId' => $this->identity['userProject'] ]) ); } @@ -1459,7 +1461,7 @@ public function iam() $this->identity['bucket'], [ 'parent' => null, - 'args' => $this->identity, + 'args' => $this->identity ] ); } @@ -1516,9 +1518,9 @@ public function lockRetentionPolicy(array $options = []) if (!isset($options['ifMetagenerationMatch'])) { if (!isset($this->info['metageneration'])) { throw new \BadMethodCallException( - 'No metageneration value was detected. Please either provide ' - . 'a value explicitly or ensure metadata is loaded through a ' - . 'call such as Bucket::reload().' + 'No metageneration value was detected. Please either provide ' . + 'a value explicitly or ensure metadata is loaded through a ' . + 'call such as Bucket::reload().' ); } @@ -1816,8 +1818,8 @@ private function getFormattedTopic($topic) if (!$this->projectId) { throw new GoogleException( - 'No project ID was provided, ' - . 'and we were unable to detect a default project ID.' + 'No project ID was provided, ' . + 'and we were unable to detect a default project ID.' ); } From 7a90dad8d84c5fcd3a2ef251d89322401368c95f Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 10:42:26 +0000 Subject: [PATCH 51/64] Updated code --- Storage/src/Bucket.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 022c9c66bf0a..b942a6c3194b 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -777,15 +777,17 @@ public function objects(array $options = []) return new ObjectIterator( new ObjectPageIterator( - fn(array $object) => new StorageObject( - $this->connection, - $object['name'], - $this->identity['bucket'], - $object['generation'] ?? null, - $object + array_filter([ - 'requesterProjectId' => $this->identity['userProject'], - ]) - ), + function (array $object) { + return new StorageObject( + $this->connection, + $object['name'], + $this->identity['bucket'], + isset($object['generation']) ? $object['generation'] : null, + $object + array_filter([ + 'requesterProjectId' => $this->identity['userProject'] + ]) + ); + }, [$this->connection, 'listObjects'], $options + $this->identity, ['resultLimit' => $resultLimit] From e4ec9bfccc7c2c933363d00f6380bdba26713fae Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Mon, 13 Apr 2026 10:57:08 +0000 Subject: [PATCH 52/64] Updated code --- Storage/src/Bucket.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index b942a6c3194b..889e31cd6366 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -774,7 +774,6 @@ public function restore($name, $generation, array $options = []) public function objects(array $options = []) { $resultLimit = $this->pluck('resultLimit', $options, false); - return new ObjectIterator( new ObjectPageIterator( function (array $object) { From f37252c51724bc382271b479a41f40431fe00ba0 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 06:31:34 +0000 Subject: [PATCH 53/64] Updated Code --- Storage/tests/System/ManageObjectsTest.php | 16 ++-- Storage/tests/Unit/BucketTest.php | 103 ++------------------- 2 files changed, 18 insertions(+), 101 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index e315411828e4..cba575e558cf 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -217,7 +217,7 @@ public function testObjectRetentionUnlockedMode() $this->assertFalse($object->exists()); } - private function testCreateObjectWithContexts(array $uploadContexts) + private function createObjectWithContexts(array $uploadContexts) { $bucket = self::$client->createBucket(uniqid('object-contexts-')); @@ -237,7 +237,7 @@ public function testCreateRetrieveAndUpdateObjectContexts() ], ]; - $object = $this->testCreateObjectWithContexts($initialContexts); + $object = $this->createObjectWithContexts($initialContexts); $metadata = $object->info(); $this->assertArrayHasKey('contexts', $metadata); $this->assertEquals( @@ -273,7 +273,7 @@ public function testGetContextAndServerGenratedTimes() ], ]; - $object = $this->testCreateObjectWithContexts($initialContexts); + $object = $this->createObjectWithContexts($initialContexts); $info = $object->info(); $this->assertArrayHasKey('contexts', $info, 'Contexts missing from server response.'); @@ -301,7 +301,7 @@ public function testClearAllExistingContexts() ], ]; - $object = $this->testCreateObjectWithContexts($initialContexts); + $object = $this->createObjectWithContexts($initialContexts); $object->update([ 'contexts' => null ]); @@ -318,7 +318,7 @@ public function testCopyOrRewriteObjectWithContexts() ], ]; - $object = $this->testCreateObjectWithContexts($initialContexts); + $object = $this->createObjectWithContexts($initialContexts); $inherited = $object->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); $info = $inherited->info(); @@ -344,7 +344,7 @@ public function testOverrideContextsDuringCopy() ], ]; - $source = $this->testCreateObjectWithContexts($initialContexts); + $source = $this->createObjectWithContexts($initialContexts); $destName = 'rewrite-dest-' . uniqid() . '.txt'; $inherited = $source->rewrite(self::$bucket, [ @@ -385,7 +385,7 @@ public function testOverrideContextsForComposeObject() ], ]; - $source1 = $this->testCreateObjectWithContexts($initialContexts); + $source1 = $this->createObjectWithContexts($initialContexts); $bucket = self::$client->bucket($source1->info()['bucket']); $s2Key = 's2-key'; $source2 = $bucket->upload(self::DATA, [ @@ -420,7 +420,7 @@ public function testInheritContextsForComposeObject() 'tag' => ['value' => 'file1-original'], ], ]; - $source1 = $this->testCreateObjectWithContexts($initialContexts1); + $source1 = $this->createObjectWithContexts($initialContexts1); $s2Key = 's2-specific-key'; $bucket = self::$client->bucket($source1->info()['bucket']); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index cbdefd4faca3..568f8d1d2eb5 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -621,7 +621,6 @@ public function testUpdateReplacesAllMetadataIncludingContexts() $result = $object->update(['contexts' => $newContexts]); $this->assertEquals('new-val', $result['contexts']['custom']['new-key']['value']); - $this->assertArrayNotHasKey('contentType', $result); } public function testAddAndModifyWithIndividualContexts() @@ -651,14 +650,11 @@ public function testAddAndModifyWithIndividualContexts() */ public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInApi) { - $this->connection->patchObject(Argument::that(function ($args) use ($expectedInApi) { - if ($expectedInApi === null) { - return !isset($args['contexts']) || $args['contexts'] === null; - } - return isset($args['contexts']) && $args['contexts'] === $expectedInApi; - }))->shouldBeCalled()->willReturn([ + $this->connection->patchObject( + Argument::withEntry('contexts', $expectedInApi) + )->shouldBeCalled()->willReturn([ 'name' => self::FILE_NAME_TEST, - 'contexts' => $expectedInApi + 'contexts' => $expectedInApi ]); $object = new StorageObject( @@ -674,8 +670,8 @@ public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInA $hasContexts = isset($info['contexts']) && $info['contexts'] !== null; $this->assertFalse($hasContexts); } else { - $this->assertArrayHasKey('contexts', $info); - $this->assertEquals($expectedInApi, $info['contexts']); + $actualContexts = $object->info()['contexts'] ?? null; + $this->assertEquals($expectedInApi, $actualContexts); } } @@ -720,85 +716,6 @@ public function testCopyObjectWithMetadataOverrides() ); } - public function testCombineMetadataOverridesWithContexts() - { - $destName = 'combined.txt'; - $sources = ['src1.txt', 'src2.txt']; - $contexts = [ - 'custom' => ['status' => ['value' => 'composed']] - ]; - - $expectedOptions = [ - 'destinationBucket' => self::BUCKET_NAME, - 'destinationObject' => $destName, - 'destination' => [ - 'contexts' => $contexts, - 'contentType' => 'text/plain' - ], - 'sourceObjects' => [ - ['name' => 'src1.txt'], - ['name' => 'src2.txt'] - ] - ]; - - $this->connection->composeObject($expectedOptions) - ->shouldBeCalled() - ->willReturn([ - 'name' => $destName, - 'generation' => 12345, - 'metadata' => [ - 'contexts' => $contexts - ] - ]); - - $object = $this->getBucket()->compose($sources, $destName, [ - 'metadata' => [ - 'contexts' => $contexts - ] - ]); - - $this->assertEquals($destName, $object->name()); - $this->assertEquals($contexts, $object->info()['metadata']['contexts']); - } - - public function testComposeHandlesEmptyStringValuesInContexts() - { - $destName = 'empty-string-test.txt'; - $sources = ['src1.txt', 'src2.txt']; - - $metadata = [ - 'contexts' => [ - 'custom' => [ - 'empty-key' => ['value' => ''] - ] - ] - ]; - - $this->connection->composeObject([ - 'destinationBucket' => self::BUCKET_NAME, - 'destinationObject' => $destName, - 'destination' => $metadata + ['contentType' => 'text/plain'], - 'sourceObjects' => [ - ['name' => 'src1.txt'], - ['name' => 'src2.txt'] - ] - ])->shouldBeCalled()->willReturn([ - 'name' => $destName, - 'generation' => 12345, - 'metadata' => $metadata - ]); - - $object = $this->getBucket()->compose($sources, $destName, [ - 'metadata' => $metadata - ]); - - $this->assertSame( - '', - $object->info()['metadata']['contexts']['custom']['empty-key']['value'], - "The empty string value was lost or converted during the compose process." - ); - } - public function testGetFiltersByPresenceOfKeyValuePair() { $filter = 'contexts."status"="active"'; @@ -816,9 +733,9 @@ public function testGetFiltersByPresenceOfKeyValuePair() } /** - * @dataProvider filterExistenceDataProvider + * @dataProvider listFilterExistenceDataProvider */ - public function testGetFiltersByExistence($filter) + public function testListFiltersByExistence($filter) { $this->connection->listObjects(Argument::withEntry('filter', $filter)) ->shouldBeCalled() @@ -830,11 +747,11 @@ public function testGetFiltersByExistence($filter) $iterator = $bucket->objects([ 'filter' => $filter ]); - + $iterator->current(); } - public function filterExistenceDataProvider() + public function listFilterExistenceDataProvider() { return [ 'presence of key (Existence)' => ['contexts."status":*'], From e1a4699311c53805b4feff7fe616bba22ee91270 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 06:49:32 +0000 Subject: [PATCH 54/64] Fixer --- Storage/tests/Unit/BucketTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 568f8d1d2eb5..2f605fe159dd 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -651,10 +651,10 @@ public function testAddAndModifyWithIndividualContexts() public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInApi) { $this->connection->patchObject( - Argument::withEntry('contexts', $expectedInApi) + Argument::withEntry('contexts', $expectedInApi) )->shouldBeCalled()->willReturn([ 'name' => self::FILE_NAME_TEST, - 'contexts' => $expectedInApi + 'contexts' => $expectedInApi ]); $object = new StorageObject( From 1270cf29bbb5c3247823b23a84e5e68fd2256ddb Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 08:06:23 +0000 Subject: [PATCH 55/64] CHanges as per gemini review --- Storage/src/Bucket.php | 9 ++++--- Storage/tests/System/ManageObjectsTest.php | 28 +++++++--------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 889e31cd6366..1cc2a49cda0f 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -280,7 +280,7 @@ public function exists(array $options = []) * @type array $contexts.custom Custom user-defined contexts. Keys must start * with an alphanumeric character and cannot contain double quotes (`"`). * @type string $contexts.custom.{key}.value The value associated with the context. - * Must start with an alphanumeric character and cannot contain double quotes (`"`) + * If not empty, must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). * @type string $contexts.custom.{key}.createTime The time the context * was created in RFC 3339 format. **(read only)** @@ -330,7 +330,7 @@ public function upload($data, array $options = []) ); } - private function validateContexts(array $contexts) + private function validateContexts(?array $contexts) { if (!isset($contexts['custom'])) { return; @@ -345,6 +345,9 @@ private function validateContexts(array $contexts) if (strpos((string) $key, '"') !== false) { throw new \InvalidArgumentException('Object context key cannot contain double quotes.'); } + if ($data === null) { + continue; + } if (!is_array($data)) { throw new \InvalidArgumentException(sprintf( 'Context data for key "%s" must be an array.', @@ -364,7 +367,7 @@ private function validateContexts(array $contexts) )); } $val = (string) $data['value']; - if ($val !== '' && !preg_match('/^[a-zA-Z0-9]/', $val)) { + if ($val !== '' && !preg_match('/^[a-zA-Z0-9][^"\/]*$/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } if (strpos($val, '/') !== false || strpos($val, '"') !== false) { diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index cba575e558cf..d90a39a1900f 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -219,7 +219,7 @@ public function testObjectRetentionUnlockedMode() private function createObjectWithContexts(array $uploadContexts) { - $bucket = self::$client->createBucket(uniqid('object-contexts-')); + $bucket = self::$bucket; $object = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), @@ -369,11 +369,6 @@ public function testOverrideContextsDuringCopy() $info = $overridden->info(); $this->assertEquals($overrideVal, $info['contexts']['custom']['tag']['value']); - $dropped = $source->rewrite(self::$bucket, [ - 'name' => 'dropped-' . uniqid() . '.txt', - 'dropContextGroups' => ['custom'] - ]); - $this->assertArrayNotHasKey('contexts', $dropped->info()['contexts']); $source->delete(); } @@ -435,19 +430,14 @@ public function testInheritContextsForComposeObject() $destName = 'c-inh-' . uniqid() . '.txt'; $inheritedObject = $bucket->compose([$source1, $source2], $destName); - $custom = $inheritedObject->info()['contexts']['custom']; - - $this->assertEquals( - 'file1-original', - $custom['tag']['value'], - 'The composed object failed to inherit context from the first source.' - ); - $this->assertArrayNotHasKey( - $s2Key, - $custom['s2-specific-key'], - 'The composed object incorrectly merged contexts from the second source.' - ); - + $info = $inheritedObject->info(); + $this->assertArrayHasKey('contexts', $info); + $this->assertArrayHasKey('custom', $info['contexts']); + $custom = $info['contexts']['custom']; + $this->assertEquals('file1-original', $custom['tag']['value'], 'The composed object failed to inherit context from the first source.'); + $this->assertArrayHasKey($s2Key, $custom, 'The composed object should have merged contexts from the second source.'); + $this->assertEquals('file2-data', $custom[$s2Key]['value']); + $source1->delete(); $source2->delete(); } From af178b1eabd55d2ebb5ea3abb169a43c08378e58 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 08:31:50 +0000 Subject: [PATCH 56/64] CS Fixer --- Storage/tests/System/ManageObjectsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index d90a39a1900f..c0c6f200a59b 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -434,8 +434,8 @@ public function testInheritContextsForComposeObject() $this->assertArrayHasKey('contexts', $info); $this->assertArrayHasKey('custom', $info['contexts']); $custom = $info['contexts']['custom']; - $this->assertEquals('file1-original', $custom['tag']['value'], 'The composed object failed to inherit context from the first source.'); - $this->assertArrayHasKey($s2Key, $custom, 'The composed object should have merged contexts from the second source.'); + $this->assertEquals('file1-original', $custom['tag']['value']); + $this->assertArrayHasKey($s2Key, $custom); $this->assertEquals('file2-data', $custom[$s2Key]['value']); $source1->delete(); From c4021cbaed2420b5f9d5620ca1db6016663953ba Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 10:02:58 +0000 Subject: [PATCH 57/64] gemini review --- Storage/src/Bucket.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Storage/src/Bucket.php b/Storage/src/Bucket.php index 1cc2a49cda0f..5441168e1825 100644 --- a/Storage/src/Bucket.php +++ b/Storage/src/Bucket.php @@ -307,6 +307,9 @@ public function upload($data, array $options = []) } if (isset($options['contexts'])) { + if (!is_array($options['contexts'])) { + throw new \InvalidArgumentException('Object contexts must be an array.'); + } $this->validateContexts($options['contexts']); } @@ -370,9 +373,6 @@ private function validateContexts(?array $contexts) if ($val !== '' && !preg_match('/^[a-zA-Z0-9][^"\/]*$/', $val)) { throw new \InvalidArgumentException('Object context value must start with an alphanumeric.'); } - if (strpos($val, '/') !== false || strpos($val, '"') !== false) { - throw new \InvalidArgumentException('Object context value cannot contain forbidden characters.'); - } } } From 8eaf4de1ad4fb5aa6c6e11afabac72f1813c532e Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Tue, 14 Apr 2026 18:28:32 +0000 Subject: [PATCH 58/64] gemini review --- Storage/src/Connection/Rest.php | 4 ++-- Storage/tests/System/ManageObjectsTest.php | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Storage/src/Connection/Rest.php b/Storage/src/Connection/Rest.php index f8fc63e5c76a..8ad357b7cf27 100644 --- a/Storage/src/Connection/Rest.php +++ b/Storage/src/Connection/Rest.php @@ -514,8 +514,8 @@ private function resolveUploadOptions(array $args) unset($args['retention']); } if (isset($args['contexts'])) { - // during object creation context properties go into metadata - // but not into request body + // during object creation context properties are part of the object resource + // and should be included in the request body. $args['metadata']['contexts'] = $args['contexts']; unset($args['contexts']); } diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index c0c6f200a59b..325ae88c4612 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -220,7 +220,6 @@ public function testObjectRetentionUnlockedMode() private function createObjectWithContexts(array $uploadContexts) { $bucket = self::$bucket; - $object = $bucket->upload(self::DATA, [ 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), 'contexts' => $uploadContexts @@ -343,20 +342,16 @@ public function testOverrideContextsDuringCopy() 'tag' => ['value' => 'original'], ], ]; - $source = $this->createObjectWithContexts($initialContexts); $destName = 'rewrite-dest-' . uniqid() . '.txt'; - $inherited = $source->rewrite(self::$bucket, [ 'name' => $destName ]); - $this->assertEquals($destName, $inherited->name()); $this->assertEquals( 'original', $inherited->info()['contexts']['custom']['tag']['value'] ); - $overrideVal = 'new-value'; $overridden = $source->copy(self::$bucket, [ 'name' => 'overridden-' . uniqid() . '.txt', @@ -367,8 +362,7 @@ public function testOverrideContextsDuringCopy() ] ]); - $info = $overridden->info(); - $this->assertEquals($overrideVal, $info['contexts']['custom']['tag']['value']); + $this->assertEquals($overrideVal, $overridden->info()['contexts']['custom']['tag']['value']); $source->delete(); } From 51d3e09d93ce842fce8205d39a14f67e721adc7a Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 02:52:03 +0000 Subject: [PATCH 59/64] Changed according to feedback --- Storage/tests/System/ManageObjectsTest.php | 171 ++++++++++++--------- Storage/tests/Unit/BucketTest.php | 34 ++-- 2 files changed, 118 insertions(+), 87 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 325ae88c4612..1f7b81d29ba7 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -30,9 +30,7 @@ class ManageObjectsTest extends StorageTestCase { const DATA = 'data'; - const CONTEXT_OBJECT_KEY = 'insert-key'; - const CONTEXT_OBJECT_VALUE = 'insert-val'; - const CONTEXT_OBJECT_PREFIX = 'object-contexts-'; + public function testListsObjects() { $foundObjects = []; @@ -221,12 +219,59 @@ private function createObjectWithContexts(array $uploadContexts) { $bucket = self::$bucket; $object = $bucket->upload(self::DATA, [ - 'name' => self::CONTEXT_OBJECT_PREFIX . uniqid(), + 'name' => 'object-contexts-' . uniqid(), 'contexts' => $uploadContexts ]); return $object; } + public function testPatchObjectContexts() + { + $initialContexts = [ + 'custom' => [ + 'new-key' => ['value' => 'new-value'], + 'another-key' => ['value' => 'another-value'] + ], + ]; + //Adding Individual Contexts + $object = $this->createObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertEquals('new-value', $info['contexts']['custom']['new-key']['value']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Modifying individual contexts + $object->update([ + 'contexts' => [ + 'custom' => [ + 'new-key' => ['value' => 'modified-value'] + ] + ] + ]); + $info = $object->info(); + $this->assertEquals('modified-value', $info['contexts']['custom']['new-key']['value']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Removing individual contexts + $object->update([ + 'contexts' => [ + 'custom' => [ + 'new-key' => null + ] + ] + ]); + $info = $object->info(); + $this->assertArrayNotHasKey('new-key', $info['contexts']['custom']); + $this->assertEquals('another-value', $info['contexts']['custom']['another-key']['value']); + + // Clearing all contexts + $object->update([ + 'contexts' => null + ]); + $info = $object->info(); + $this->assertArrayNotHasKey('contexts', $info); + $object->delete(); + } + public function testCreateRetrieveAndUpdateObjectContexts() { $initialContexts = [ @@ -245,7 +290,7 @@ public function testCreateRetrieveAndUpdateObjectContexts() ); $this->assertArrayHasKey('createTime', $metadata['contexts']['custom']['team-owner']); - $patchMetadata = [ + $metadata = [ 'contexts' => [ 'custom' => [ 'priority' => ['value' => 'critical'], @@ -254,7 +299,7 @@ public function testCreateRetrieveAndUpdateObjectContexts() ], ], ]; - $updatedMetadata = $object->update($patchMetadata); + $updatedMetadata = $object->update($metadata); $finalCustom = $updatedMetadata['contexts']['custom']; $this->assertEquals('critical', $finalCustom['priority']['value']); $this->assertEquals('prod', $finalCustom['env']['value']); @@ -263,30 +308,27 @@ public function testCreateRetrieveAndUpdateObjectContexts() $object->delete(); } - public function testGetContextAndServerGenratedTimes() + public function testGetContextsWithServerTime() { $initialContexts = [ 'custom' => [ - 'temp-key' => ['value' => 'temp'], - 'status' => ['value' => 'to-be-cleared'], + 'temp-key' => ['value' => 'temp'] ], ]; $object = $this->createObjectWithContexts($initialContexts); $info = $object->info(); - $this->assertArrayHasKey('contexts', $info, 'Contexts missing from server response.'); + $this->assertArrayHasKey('contexts', $info); - $context = $info['contexts']['custom']['status']; - $this->assertEquals('to-be-cleared', $context['value']); + $context = $info['contexts']['custom']; + $this->assertEquals('temp', $context['temp-key']['value']); $this->assertArrayHasKey( 'createTime', - $context, - 'Server failed to generate createTime for context.' + $context['temp-key'] ); $this->assertArrayHasKey( 'updateTime', - $context, - 'Server failed to generate updateTime for context.' + $context['temp-key'] ); $object->delete(); } @@ -301,15 +343,19 @@ public function testClearAllExistingContexts() ]; $object = $this->createObjectWithContexts($initialContexts); + $info = $object->info(); + $this->assertArrayHasKey('contexts', $info); + $this->assertEquals('temp', $info['contexts']['custom']['temp-key']['value']); + $this->assertEquals('to-be-cleared', $info['contexts']['custom']['status']['value']); + $object->update([ 'contexts' => null ]); - $info = $object->info(); - $this->assertArrayNotHasKey('contexts', $info); + $this->assertArrayNotHasKey('contexts', $object->info()); $object->delete(); } - public function testCopyOrRewriteObjectWithContexts() + public function testRewriteObjectWithContexts() { $initialContexts = [ 'custom' => [ @@ -318,10 +364,10 @@ public function testCopyOrRewriteObjectWithContexts() ]; $object = $this->createObjectWithContexts($initialContexts); + // Inherit object contexts during a rewrite operation. $inherited = $object->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); - $info = $inherited->info(); - - $this->assertEquals('orignal', $info['contexts']['custom']['tag']['value']); + $this->assertEquals('orignal', $inherited->info()['contexts']['custom']['tag']['value']); + // Override object contexts during a rewrite operation. $overrideKey = 'override-key'; $overrideVal = 'override-val'; $overridden = $object->rewrite(self::$bucket, [ @@ -343,15 +389,7 @@ public function testOverrideContextsDuringCopy() ], ]; $source = $this->createObjectWithContexts($initialContexts); - $destName = 'rewrite-dest-' . uniqid() . '.txt'; - $inherited = $source->rewrite(self::$bucket, [ - 'name' => $destName - ]); - $this->assertEquals($destName, $inherited->name()); - $this->assertEquals( - 'original', - $inherited->info()['contexts']['custom']['tag']['value'] - ); + $this->assertEquals('original', $source->info()['contexts']['custom']['tag']['value']); $overrideVal = 'new-value'; $overridden = $source->copy(self::$bucket, [ 'name' => 'overridden-' . uniqid() . '.txt', @@ -366,7 +404,7 @@ public function testOverrideContextsDuringCopy() $source->delete(); } - public function testOverrideContextsForComposeObject() + public function testComposeObjectWithOverrideAndInheritContexts() { $initialContexts = [ 'custom' => [ @@ -378,13 +416,14 @@ public function testOverrideContextsForComposeObject() $bucket = self::$client->bucket($source1->info()['bucket']); $s2Key = 's2-key'; $source2 = $bucket->upload(self::DATA, [ - 'name' => self::CONTEXT_OBJECT_PREFIX . 's2-' . uniqid(), + 'name' => 'override-object-contexts-' . 's2-' . uniqid(), 'contexts' => ['custom' => [$s2Key => ['value' => 'file2']]] ]); + //Inherit Contexts $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); $custom = $inherit->info()['contexts']['custom']; $this->assertEquals('file1', $custom['tag']['value']); - + // Override Contexts $oKey = 'c-override'; $oVal = 'c-val'; $override = $bucket->compose([$source1, $source2], 'c-ovr-' . uniqid() . '.txt'); @@ -392,50 +431,16 @@ public function testOverrideContextsForComposeObject() 'contexts' => [ 'custom' => [ $oKey => ['value' => $oVal], - self::CONTEXT_OBJECT_KEY => null + 'insert-key' => null ] ] ]); $this->assertEquals($oVal, $info['contexts']['custom'][$oKey]['value']); - $this->assertArrayNotHasKey(self::CONTEXT_OBJECT_KEY, $info['contexts']['custom']); + $this->assertArrayNotHasKey('insert-key', $info['contexts']['custom']); $source1->delete(); } - public function testInheritContextsForComposeObject() - { - $initialContexts1 = [ - 'custom' => [ - 'tag' => ['value' => 'file1-original'], - ], - ]; - $source1 = $this->createObjectWithContexts($initialContexts1); - - $s2Key = 's2-specific-key'; - $bucket = self::$client->bucket($source1->info()['bucket']); - $source2 = $bucket->upload('data', [ - 'name' => 'source2-' . uniqid() . '.txt', - 'contexts' => [ - 'custom' => [ - $s2Key => ['value' => 'file2-data'] - ] - ] - ]); - - $destName = 'c-inh-' . uniqid() . '.txt'; - $inheritedObject = $bucket->compose([$source1, $source2], $destName); - $info = $inheritedObject->info(); - $this->assertArrayHasKey('contexts', $info); - $this->assertArrayHasKey('custom', $info['contexts']); - $custom = $info['contexts']['custom']; - $this->assertEquals('file1-original', $custom['tag']['value']); - $this->assertArrayHasKey($s2Key, $custom); - $this->assertEquals('file2-data', $custom[$s2Key]['value']); - - $source1->delete(); - $source2->delete(); - } - public function testListObjectsWithContextFilters() { $bucketName = 'test-context-filter-' . time(); @@ -454,41 +459,59 @@ public function testListObjectsWithContextFilters() $noneFile = $bucket->upload('content', [ 'name' => 'test-none.txt' ]); + + // Should list all objects matching a prefix $objects = iterator_to_array($bucket->objects()); $this->assertCount(3, $objects); + // Should filter by presence of key/value pair $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status"="inactive"' ])); $this->assertCount(1, $objects); $this->assertEquals($inactiveFile->name(), $objects[0]->name()); + // Should filter by presence of different value $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status"="active"' ])); $this->assertCount(1, $objects); $this->assertEquals($activeFile->name(), $objects[0]->name()); + // Should filter by absence of key/value pair (NOT) $objects = iterator_to_array($bucket->objects([ 'filter' => '-contexts."status"="active"' ])); $this->assertCount(2, $objects); + // Should filter by presence of key regardless of value (Existence) $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status":*' ])); $this->assertCount(2, $objects); + // Should filter by absence of key regardless of value (Non-existence) $objects = iterator_to_array($bucket->objects([ 'filter' => '-contexts."status":*' ])); $this->assertCount(1, $objects); $this->assertEquals($noneFile->name(), $objects[0]->name()); + // Should return empty list when no contexts match the filter $objects = iterator_to_array($bucket->objects([ 'filter' => 'contexts."status"="ghost"' ])); $this->assertCount(0, $objects); + + // Should correctly handle double quotes in filter keys + $bucket->upload('content', [ + 'name' => 'quoted.txt', + 'metadata' => ['contexts' => ['custom' => ['priority' => ['value' => 'quoted-val']]]] + ]); + $objects = iterator_to_array($bucket->objects([ + 'filter' => 'contexts."priority"="quoted-val"' + ])); + $this->assertCount(1, $objects); } finally { foreach ($bucket->objects() as $object) { $object->delete(); @@ -497,6 +520,14 @@ public function testListObjectsWithContextFilters() } } + public function testObjectExists() + { + $object = self::$bucket->upload(self::DATA, ['name' => uniqid(self::TESTING_PREFIX)]); + $this->assertTrue($object->exists()); + $object->delete(); + $this->assertFalse($object->exists()); + } + public function testUploadAsync() { $name = uniqid(self::TESTING_PREFIX); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 2f605fe159dd..27ec00e22f68 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -646,15 +646,15 @@ public function testAddAndModifyWithIndividualContexts() } /** - * @dataProvider removeAndClearAllContextsDataProvider - */ - public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInApi) + * @dataProvider removeAndClearAllContextsDataProvider + */ + public function testRemoveAndClearAllObjectContexts($objectContexts) { $this->connection->patchObject( - Argument::withEntry('contexts', $expectedInApi) + Argument::withEntry('contexts', $objectContexts) )->shouldBeCalled()->willReturn([ 'name' => self::FILE_NAME_TEST, - 'contexts' => $expectedInApi + 'contexts' => $objectContexts ]); $object = new StorageObject( @@ -664,14 +664,14 @@ public function testRemoveAndClearAllObjectContexts($inputContexts, $expectedInA 1, ['bucket' => self::BUCKET_NAME] ); - $object->update(['contexts' => $inputContexts]); - $info = $object->info(); - if ($expectedInApi === null) { + $object->update(['contexts' => $objectContexts]); + $info = $object->info(); + if ($objectContexts === null) { $hasContexts = isset($info['contexts']) && $info['contexts'] !== null; $this->assertFalse($hasContexts); } else { $actualContexts = $object->info()['contexts'] ?? null; - $this->assertEquals($expectedInApi, $actualContexts); + $this->assertEquals($objectContexts, $actualContexts); } } @@ -679,11 +679,9 @@ public function removeAndClearAllContextsDataProvider() { return [ 'remove an individual context by setting it to null' => [ - ['custom' => ['key-to-delete' => null]], ['custom' => ['key-to-delete' => null]] ], 'clear all contexts by setting custom to null' => [ - ['custom' => null], ['custom' => null] ] ]; @@ -716,7 +714,7 @@ public function testCopyObjectWithMetadataOverrides() ); } - public function testGetFiltersByPresenceOfKeyValuePair() + public function testListFiltersByPresenceOfKeyValuePair() { $filter = 'contexts."status"="active"'; $this->connection->listObjects(Argument::withEntry('filter', $filter)) @@ -729,7 +727,7 @@ public function testGetFiltersByPresenceOfKeyValuePair() $iterator = $bucket->objects([ 'filter' => $filter ]); - $iterator->current(); + $this->assertCount(0, iterator_to_array($iterator)); } /** @@ -748,7 +746,7 @@ public function testListFiltersByExistence($filter) 'filter' => $filter ]); - $iterator->current(); + $this->assertCount(0, iterator_to_array($iterator)); } public function listFilterExistenceDataProvider() @@ -764,9 +762,11 @@ public function testGetFilesIncludesContextsInMetadata() $fileMetadata = [ 'name' => 'filename', 'metadata' => [ - 'contexts' => [ - 'custom' => [ - 'dept' => ['value' => 'eng', 'createTime' => '...'] + 'contexts' => [ 'custom' => [ 'dept' => + [ + 'value' => 'eng', + 'createTime' => '2026-04-16T01:01:01.045123456Z', 'updateTime' => '2026-04-16T01:01:01.045123' + ] ] ] ] From a8a67ff82271ea93bc097b87cba24c3cd57b21bd Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 03:01:09 +0000 Subject: [PATCH 60/64] Final changes code --- Storage/tests/System/ManageObjectsTest.php | 5 +++++ Storage/tests/Unit/BucketTest.php | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index 1f7b81d29ba7..fad6c233623a 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -233,6 +233,7 @@ public function testPatchObjectContexts() 'another-key' => ['value' => 'another-value'] ], ]; + //Adding Individual Contexts $object = $this->createObjectWithContexts($initialContexts); $info = $object->info(); @@ -364,9 +365,11 @@ public function testRewriteObjectWithContexts() ]; $object = $this->createObjectWithContexts($initialContexts); + // Inherit object contexts during a rewrite operation. $inherited = $object->rewrite(self::$bucket, ['name' => 'inherit-' . uniqid()]); $this->assertEquals('orignal', $inherited->info()['contexts']['custom']['tag']['value']); + // Override object contexts during a rewrite operation. $overrideKey = 'override-key'; $overrideVal = 'override-val'; @@ -419,10 +422,12 @@ public function testComposeObjectWithOverrideAndInheritContexts() 'name' => 'override-object-contexts-' . 's2-' . uniqid(), 'contexts' => ['custom' => [$s2Key => ['value' => 'file2']]] ]); + //Inherit Contexts $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); $custom = $inherit->info()['contexts']['custom']; $this->assertEquals('file1', $custom['tag']['value']); + // Override Contexts $oKey = 'c-override'; $oVal = 'c-val'; diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 27ec00e22f68..3b417ef7da5c 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -665,7 +665,7 @@ public function testRemoveAndClearAllObjectContexts($objectContexts) ['bucket' => self::BUCKET_NAME] ); $object->update(['contexts' => $objectContexts]); - $info = $object->info(); + $info = $object->info(); if ($objectContexts === null) { $hasContexts = isset($info['contexts']) && $info['contexts'] !== null; $this->assertFalse($hasContexts); @@ -765,13 +765,13 @@ public function testGetFilesIncludesContextsInMetadata() 'contexts' => [ 'custom' => [ 'dept' => [ 'value' => 'eng', - 'createTime' => '2026-04-16T01:01:01.045123456Z', 'updateTime' => '2026-04-16T01:01:01.045123' + 'createTime' => '2026-04-16T01:01:01.045123456Z', + 'updateTime' => '2026-04-16T01:01:01.045123' ] ] ] ] ]; - $this->connection->listObjects(Argument::any()) ->shouldBeCalled() ->willReturn([ From fa23edb0c349ff54d22847e1d0b7dce73f8ce798 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 03:46:45 +0000 Subject: [PATCH 61/64] Final changes code --- Storage/tests/Unit/BucketTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index 3b417ef7da5c..fe127366147a 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -801,6 +801,7 @@ public function testIam() $this->assertInstanceOf(Iam::class, $bucket->iam()); } + public function testRequesterPays() { $this->connection->getBucket(Argument::withEntry('userProject', 'foo')) From 99065d24f7c9215bd3b1753334d9dd5d754a251f Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 03:51:00 +0000 Subject: [PATCH 62/64] Final changes code --- Storage/tests/Unit/BucketTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index fe127366147a..ad974f112c33 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -765,7 +765,7 @@ public function testGetFilesIncludesContextsInMetadata() 'contexts' => [ 'custom' => [ 'dept' => [ 'value' => 'eng', - 'createTime' => '2026-04-16T01:01:01.045123456Z', + 'createTime' => '2026-04-16T01:01:01.045123456Z', 'updateTime' => '2026-04-16T01:01:01.045123' ] ] From b30316a292655e88a4a1e1b535d0de29dcf63702 Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 05:28:04 +0000 Subject: [PATCH 63/64] Final change and push --- Storage/tests/System/ManageObjectsTest.php | 6 +++--- Storage/tests/Unit/BucketTest.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Storage/tests/System/ManageObjectsTest.php b/Storage/tests/System/ManageObjectsTest.php index fad6c233623a..fc2c2b251b96 100644 --- a/Storage/tests/System/ManageObjectsTest.php +++ b/Storage/tests/System/ManageObjectsTest.php @@ -423,12 +423,12 @@ public function testComposeObjectWithOverrideAndInheritContexts() 'contexts' => ['custom' => [$s2Key => ['value' => 'file2']]] ]); - //Inherit Contexts + //Inherit contexts during compose $inherit = $bucket->compose([$source1, $source2], 'c-inh-' . uniqid() . '.txt'); $custom = $inherit->info()['contexts']['custom']; $this->assertEquals('file1', $custom['tag']['value']); - - // Override Contexts + + // Override contexts during compose $oKey = 'c-override'; $oVal = 'c-val'; $override = $bucket->compose([$source1, $source2], 'c-ovr-' . uniqid() . '.txt'); diff --git a/Storage/tests/Unit/BucketTest.php b/Storage/tests/Unit/BucketTest.php index ad974f112c33..f9a17a13dc53 100644 --- a/Storage/tests/Unit/BucketTest.php +++ b/Storage/tests/Unit/BucketTest.php @@ -801,7 +801,7 @@ public function testIam() $this->assertInstanceOf(Iam::class, $bucket->iam()); } - + public function testRequesterPays() { $this->connection->getBucket(Argument::withEntry('userProject', 'foo')) From bd9c7200561238bbc9b980ca017a1a2891c3bc9d Mon Sep 17 00:00:00 2001 From: salilg-eng Date: Fri, 17 Apr 2026 07:38:19 +0000 Subject: [PATCH 64/64] Final change and push --- Storage/src/StorageObject.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Storage/src/StorageObject.php b/Storage/src/StorageObject.php index f6292bd22dd0..e768931bfd22 100644 --- a/Storage/src/StorageObject.php +++ b/Storage/src/StorageObject.php @@ -237,7 +237,7 @@ public function delete(array $options = []) * @type array $contexts.custom Custom user-defined contexts. Keys must start * with an alphanumeric character and cannot contain double quotes (`"`). * @type string $contexts.custom.{key}.value The value associated with the context. - * Must start with an alphanumeric character and cannot contain double quotes (`"`) + * If not empty, must start with an alphanumeric character and cannot contain double quotes (`"`) * or forward slashes (`/`). * @type string $contexts.custom.{key}.createTime The time the context * was created in RFC 3339 format. **(read only)**