diff --git a/lib/Providers/DAV/Calendar/Hybrid/EventCollection.php b/lib/Providers/DAV/Calendar/Hybrid/EventCollection.php index 53269cc..5cbfe8d 100644 --- a/lib/Providers/DAV/Calendar/Hybrid/EventCollection.php +++ b/lib/Providers/DAV/Calendar/Hybrid/EventCollection.php @@ -328,6 +328,13 @@ public function childExists($id): bool { $listFilter->condition('uuid', $id, FilterComparisonOperator::EQ); // retrieve object properties $entities = $this->localService->entityList($listFilter); + // fall back to a lookup by remote entity id + if (count($entities) === 0 && $this->collection->remoteId !== null) { + $listFilter = $this->localService->entityListFilter(); + $listFilter->condition('cid', $this->collection->localId); + $listFilter->condition('ceid', $this->collection->remoteId . $id, FilterComparisonOperator::EQ); + $entities = $this->localService->entityList($listFilter); + } return count($entities) > 0; } @@ -362,14 +369,19 @@ public function getMultipleChildren(array $ids): array { * @return EventEntity|false */ public function getChild($id): EventEntity|false { - // remove extension - $id = str_replace('.ics', '', $id); // construct filter $listFilter = $this->localService->entityListFilter(); $listFilter->condition('cid', $this->collection->localId); $listFilter->condition('uuid', $id); // retrieve object properties $entities = $this->localService->entityList($listFilter); + // fall back to a lookup by remote entity id + if (count($entities) === 0 && $this->collection->remoteId !== null) { + $listFilter = $this->localService->entityListFilter(); + $listFilter->condition('cid', $this->collection->localId); + $listFilter->condition('ceid', $this->collection->remoteId . $id, FilterComparisonOperator::EQ); + $entities = $this->localService->entityList($listFilter); + } // evaluate if object properties where retrieved if (count($entities) > 0) { return new EventEntity($this, $entities[0]); diff --git a/lib/Providers/DAV/Contacts/Hybrid/ContactCollection.php b/lib/Providers/DAV/Contacts/Hybrid/ContactCollection.php index 60d1e94..1842ad8 100644 --- a/lib/Providers/DAV/Contacts/Hybrid/ContactCollection.php +++ b/lib/Providers/DAV/Contacts/Hybrid/ContactCollection.php @@ -266,6 +266,13 @@ public function childExists($id): bool { $listFilter->condition('uuid', $id, FilterComparisonOperator::EQ); // retrieve object properties $entities = $this->localService->entityList($listFilter); + // fall back to a lookup by remote entity id + if (count($entities) === 0 && $this->collection->remoteId !== null) { + $listFilter = $this->localService->entityListFilter(); + $listFilter->condition('cid', $this->collection->localId); + $listFilter->condition('ceid', $this->collection->remoteId . $id, FilterComparisonOperator::EQ); + $entities = $this->localService->entityList($listFilter); + } return count($entities) > 0; } @@ -306,6 +313,13 @@ public function getChild($id): ContactEntity|false { $listFilter->condition('uuid', $id, FilterComparisonOperator::EQ); // retrieve object properties $entities = $this->localService->entityList($listFilter); + // fall back to a lookup by remote entity id + if (count($entities) === 0 && $this->collection->remoteId !== null) { + $listFilter = $this->localService->entityListFilter(); + $listFilter->condition('cid', $this->collection->localId); + $listFilter->condition('ceid', $this->collection->remoteId . $id, FilterComparisonOperator::EQ); + $entities = $this->localService->entityList($listFilter); + } // evaluate if object properties where retrieved if (count($entities) > 0) { return new ContactEntity($this, $entities[0]); diff --git a/lib/Service/Remote/RemoteContactsService.php b/lib/Service/Remote/RemoteContactsService.php index 7079706..b2f554c 100644 --- a/lib/Service/Remote/RemoteContactsService.php +++ b/lib/Service/Remote/RemoteContactsService.php @@ -280,6 +280,9 @@ public function entityCreate(Entity $so): ?Entity { $result = $this->dataStore->create($path, $data, 'application/vcard'); $ro = clone $so; + // persist the full resource path so the stored identifier matches what a + // subsequent pull from the remote server would report for this entity + $ro->remoteEntityId = $path; $ro->remoteSignature = $result['etag'] ?? null; return $ro; diff --git a/lib/Service/Remote/RemoteEventsService.php b/lib/Service/Remote/RemoteEventsService.php index f93664d..d350123 100644 --- a/lib/Service/Remote/RemoteEventsService.php +++ b/lib/Service/Remote/RemoteEventsService.php @@ -281,6 +281,9 @@ public function entityCreate(Entity $so): ?Entity { $result = $this->dataStore->create($path, $data, 'application/vcalendar'); $ro = clone $so; + // persist the full resource path so the stored identifier matches what a + // subsequent pull from the remote server would report for this entity + $ro->remoteEntityId = $path; $ro->remoteSignature = $result['etag'] ?? null; return $ro; diff --git a/lib/Store/Local/Filters/ContactFilter.php b/lib/Store/Local/Filters/ContactFilter.php index 4a69446..0346061 100644 --- a/lib/Store/Local/Filters/ContactFilter.php +++ b/lib/Store/Local/Filters/ContactFilter.php @@ -17,8 +17,8 @@ class ContactFilter extends FilterBase { 'uid' => true, 'sid' => true, 'cid' => true, - 'uid' => true, 'uuid' => true, + 'ceid' => true, 'label' => true, ]; diff --git a/lib/Store/Local/Filters/EventFilter.php b/lib/Store/Local/Filters/EventFilter.php index 231a353..e2757c9 100644 --- a/lib/Store/Local/Filters/EventFilter.php +++ b/lib/Store/Local/Filters/EventFilter.php @@ -17,8 +17,8 @@ class EventFilter extends FilterBase { 'uid' => true, 'sid' => true, 'cid' => true, - 'uid' => true, 'uuid' => true, + 'ceid' => true, 'startson' => true, 'endson' => true, 'label' => true, diff --git a/tests/php/dav/Remote/RemoteContactsServiceTest.php b/tests/php/dav/Remote/RemoteContactsServiceTest.php index e63892d..05be1c4 100644 --- a/tests/php/dav/Remote/RemoteContactsServiceTest.php +++ b/tests/php/dav/Remote/RemoteContactsServiceTest.php @@ -355,7 +355,8 @@ public function testEntityCreate(): void { $this->assertInstanceOf(Entity::class, $createdEntity); $this->assertSame($entity->remoteCollectionId, $createdEntity->remoteCollectionId); - $this->assertSame($entity->remoteEntityId, $createdEntity->remoteEntityId); + // the created entity carries the full resource path as its remote id + $this->assertSame($resourcePath, $createdEntity->remoteEntityId); $this->assertSame($payload, $createdEntity->data); $this->assertIsString($createdEntity->remoteSignature); $this->assertNotSame('', $createdEntity->remoteSignature); diff --git a/tests/php/dav/Remote/RemoteEventsServiceTest.php b/tests/php/dav/Remote/RemoteEventsServiceTest.php index 28bb08c..6662efe 100644 --- a/tests/php/dav/Remote/RemoteEventsServiceTest.php +++ b/tests/php/dav/Remote/RemoteEventsServiceTest.php @@ -356,7 +356,8 @@ public function testEntityCreate(): void { $this->assertInstanceOf(Entity::class, $createdEntity); $this->assertSame($entity->remoteCollectionId, $createdEntity->remoteCollectionId); - $this->assertSame($entity->remoteEntityId, $createdEntity->remoteEntityId); + // the created entity carries the full resource path as its remote id + $this->assertSame($resourcePath, $createdEntity->remoteEntityId); $this->assertSame($payload, $createdEntity->data); $this->assertIsString($createdEntity->remoteSignature); $this->assertNotSame('', $createdEntity->remoteSignature); diff --git a/tests/php/unit/Providers/DAV/Calendar/Hybrid/EventCollectionTest.php b/tests/php/unit/Providers/DAV/Calendar/Hybrid/EventCollectionTest.php new file mode 100644 index 0000000..96493fa --- /dev/null +++ b/tests/php/unit/Providers/DAV/Calendar/Hybrid/EventCollectionTest.php @@ -0,0 +1,150 @@ +servicesStore = $this->createMock(ServicesStore::class); + $this->localService = $this->createMock(LocalEventsService::class); + $this->remoteFactory = $this->createMock(RemoteFactory::class); + + $this->collection = new Collection(); + $this->collection->userId = 'user1'; + $this->collection->serviceId = 1; + $this->collection->localId = 42; + $this->collection->uuid = 'collection-uuid'; + $this->collection->remoteId = '/remote/Calendar/'; + + $this->sut = new EventCollection( + $this->servicesStore, + $this->localService, + $this->remoteFactory, + $this->collection, + ); + + $this->localService->method('entityListFilter') + ->willReturnCallback(static fn (): EventFilter => new EventFilter()); + } + + /** + * extract the value of a given attribute from a filter + */ + private function conditionValue(EventFilter $filter, string $attribute): mixed { + foreach ($filter->conditions() as $condition) { + if ($condition['attribute'] === $attribute) { + return $condition['value']; + } + } + return null; + } + + public function testGetChildByUuid(): void { + $entity = new Entity(); + $entity->uuid = 'entity-uuid'; + + $this->localService->expects($this->once()) + ->method('entityList') + ->willReturnCallback(function (EventFilter $filter) use ($entity): array { + // the .ics extension is stripped before the uuid lookup + $this->assertSame('entity-uuid', $this->conditionValue($filter, 'uuid')); + return [$entity]; + }); + + $child = $this->sut->getChild('entity-uuid.ics'); + + $this->assertInstanceOf(EventEntity::class, $child); + $this->assertSame('entity-uuid', $child->getName()); + } + + public function testGetChildFallsBackToRemoteEntityId(): void { + $entity = new Entity(); + $entity->uuid = 'entity-uuid'; + $entity->remoteEntityId = '/remote/Calendar/submitted-id.ics'; + + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturnCallback(function (EventFilter $filter) use ($entity): array { + // uuid lookup yields nothing, ceid lookup resolves the entity by + // the reconstructed full resource path (extension preserved) + if ($this->conditionValue($filter, 'uuid') === 'submitted-id') { + return []; + } + $this->assertSame('/remote/Calendar/submitted-id.ics', $this->conditionValue($filter, 'ceid')); + return [$entity]; + }); + + $child = $this->sut->getChild('submitted-id.ics'); + + $this->assertInstanceOf(EventEntity::class, $child); + $this->assertSame('entity-uuid', $child->getName()); + } + + public function testGetChildThrowsWhenMissing(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturn([]); + + $this->expectException(\Sabre\DAV\Exception\NotFound::class); + + $this->sut->getChild('unknown.ics'); + } + + public function testChildExistsByUuid(): void { + $this->localService->expects($this->once()) + ->method('entityList') + ->willReturnCallback(function (EventFilter $filter): array { + $this->assertSame('entity-uuid', $this->conditionValue($filter, 'uuid')); + return [new Entity()]; + }); + + $this->assertTrue($this->sut->childExists('entity-uuid.ics')); + } + + public function testChildExistsFallsBackToRemoteEntityId(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturnCallback(function (EventFilter $filter): array { + if ($this->conditionValue($filter, 'uuid') === 'submitted-id') { + return []; + } + $this->assertSame('/remote/Calendar/submitted-id.ics', $this->conditionValue($filter, 'ceid')); + return [new Entity()]; + }); + + $this->assertTrue($this->sut->childExists('submitted-id.ics')); + } + + public function testChildExistsReturnsFalseWhenMissing(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturn([]); + + $this->assertFalse($this->sut->childExists('unknown.ics')); + } +} diff --git a/tests/php/unit/Providers/DAV/Contacts/Hybrid/ContactCollectionTest.php b/tests/php/unit/Providers/DAV/Contacts/Hybrid/ContactCollectionTest.php new file mode 100644 index 0000000..857d769 --- /dev/null +++ b/tests/php/unit/Providers/DAV/Contacts/Hybrid/ContactCollectionTest.php @@ -0,0 +1,146 @@ +servicesStore = $this->createMock(ServicesStore::class); + $this->localService = $this->createMock(LocalContactsService::class); + $this->remoteFactory = $this->createMock(RemoteFactory::class); + + $this->collection = new Collection(); + $this->collection->userId = 'user1'; + $this->collection->serviceId = 1; + $this->collection->localId = 42; + $this->collection->uuid = 'collection-uuid'; + $this->collection->remoteId = '/remote/Contacts/'; + + $this->sut = new ContactCollection( + $this->servicesStore, + $this->localService, + $this->remoteFactory, + $this->collection, + ); + + $this->localService->method('entityListFilter') + ->willReturnCallback(static fn (): ContactFilter => new ContactFilter()); + } + + /** + * extract the value of a given attribute from a filter + */ + private function conditionValue(ContactFilter $filter, string $attribute): mixed { + foreach ($filter->conditions() as $condition) { + if ($condition['attribute'] === $attribute) { + return $condition['value']; + } + } + return null; + } + + public function testGetChildByUuid(): void { + $entity = new Entity(); + $entity->uuid = 'entity-uuid'; + + $this->localService->expects($this->once()) + ->method('entityList') + ->willReturnCallback(function (ContactFilter $filter) use ($entity): array { + $this->assertSame('entity-uuid', $this->conditionValue($filter, 'uuid')); + return [$entity]; + }); + + $child = $this->sut->getChild('entity-uuid'); + + $this->assertInstanceOf(ContactEntity::class, $child); + $this->assertSame('entity-uuid', $child->getName()); + } + + public function testGetChildFallsBackToRemoteEntityId(): void { + $entity = new Entity(); + $entity->uuid = 'entity-uuid'; + $entity->remoteEntityId = '/remote/Contacts/submitted-id.vcf'; + + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturnCallback(function (ContactFilter $filter) use ($entity): array { + // uuid lookup yields nothing, ceid lookup resolves the entity by + // the reconstructed full resource path + if ($this->conditionValue($filter, 'uuid') === 'submitted-id.vcf') { + return []; + } + $this->assertSame('/remote/Contacts/submitted-id.vcf', $this->conditionValue($filter, 'ceid')); + return [$entity]; + }); + + $child = $this->sut->getChild('submitted-id.vcf'); + + $this->assertInstanceOf(ContactEntity::class, $child); + $this->assertSame('entity-uuid', $child->getName()); + } + + public function testGetChildThrowsWhenMissing(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturn([]); + + $this->expectException(\Sabre\DAV\Exception\NotFound::class); + + $this->sut->getChild('unknown'); + } + + public function testChildExistsByUuid(): void { + $this->localService->expects($this->once()) + ->method('entityList') + ->willReturn([new Entity()]); + + $this->assertTrue($this->sut->childExists('entity-uuid')); + } + + public function testChildExistsFallsBackToRemoteEntityId(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturnCallback(function (ContactFilter $filter): array { + if ($this->conditionValue($filter, 'uuid') === 'submitted-id.vcf') { + return []; + } + $this->assertSame('/remote/Contacts/submitted-id.vcf', $this->conditionValue($filter, 'ceid')); + return [new Entity()]; + }); + + $this->assertTrue($this->sut->childExists('submitted-id.vcf')); + } + + public function testChildExistsReturnsFalseWhenMissing(): void { + $this->localService->expects($this->exactly(2)) + ->method('entityList') + ->willReturn([]); + + $this->assertFalse($this->sut->childExists('unknown')); + } +}