diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index 98a50ed5d0df2..e19119eb672a0 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -723,6 +723,8 @@ public function updateCard($addressBookId, $cardUri, $cardData) { return '"' . $etag . '"'; } + $storedEtag = $this->getCardEtag($addressBookId, $cardUri); + $query->update($this->dbCardsTable) ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB)) ->set('lastmodified', $query->createNamedParameter(time())) @@ -735,9 +737,14 @@ public function updateCard($addressBookId, $cardUri, $cardData) { $this->etagCache[$etagCacheKey] = $etag; - $this->addChange($addressBookId, $cardUri, 2); $this->updateProperties($addressBookId, $cardUri, $cardData); + if ($storedEtag === $etag) { + return '"' . $etag . '"'; + } + + $this->addChange($addressBookId, $cardUri, 2); + $addressBookData = $this->getAddressBookById($addressBookId); $shares = $this->getShares($addressBookId); $objectRow = $this->getCard($addressBookId, $cardUri); @@ -820,9 +827,9 @@ public function deleteCard($addressBookId, $cardUri) { ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri))) ->executeStatement(); - $this->addChange($addressBookId, $cardUri, 3); - if ($ret === 1) { + $this->addChange($addressBookId, $cardUri, 3); + if ($cardId !== null) { $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow)); $this->purgeProperties($addressBookId, $cardId); @@ -1486,6 +1493,22 @@ protected function getCardId(int $addressBookId, string $uri): int { return (int)$cardIds['id']; } + /** + * Get the etag currently stored for a contact, or null if there is none + */ + protected function getCardEtag(int $addressBookId, string $uri): ?string { + $query = $this->db->getQueryBuilder(); + $query->select('etag')->from($this->dbCardsTable) + ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + + $result = $query->executeQuery(); + $etag = $result->fetchOne(); + $result->closeCursor(); + + return $etag === false ? null : (string)$etag; + } + /** * For shared address books the sharee is set in the ACL of the address book * diff --git a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php index 8b6b9fccd13fb..eb5dfa105ea24 100644 --- a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php +++ b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php @@ -141,13 +141,7 @@ protected function setUp(): void { $this->createMock(LoggerInterface::class) ); - $this->backend = new CardDavBackend($this->db, - $this->principal, - $this->userManager, - $this->dispatcher, - $this->sharingBackend, - $this->config, - ); + $this->backend = $this->createBackend(); // start every test with a empty cards_properties and cards table $query = $this->db->getQueryBuilder(); $query->delete('cards_properties')->executeStatement(); @@ -163,6 +157,44 @@ protected function setUp(): void { } } + /** + * A fresh instance starts with an empty in-memory etag cache, like a + * subsequent request or background job would. + */ + private function createBackend(): CardDavBackend { + return new CardDavBackend($this->db, + $this->principal, + $this->userManager, + $this->dispatcher, + $this->sharingBackend, + $this->config, + ); + } + + private function countChanges(int $addressBookId): int { + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*')) + ->from('addressbookchanges') + ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId))); + $result = $query->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } + + private function getSyncToken(int $addressBookId): int { + $query = $this->db->getQueryBuilder(); + $query->select('synctoken') + ->from('addressbooks') + ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId))); + $result = $query->executeQuery(); + $syncToken = (int)$result->fetchOne(); + $result->closeCursor(); + + return $syncToken; + } + protected function tearDown(): void { if (is_null($this->backend)) { return; @@ -486,6 +518,69 @@ public function testDeleteWithoutCard(): void { $this->assertTrue($this->backend->deleteCard($bookId, $uri)); } + public function testDeleteCardWithoutMatchingRowRecordsNoChange(): void { + $this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []); + $books = $this->backend->getUsersOwnAddressBooks(self::UNIT_TEST_USER); + $bookId = (int)$books[0]['id']; + + $this->assertFalse($this->backend->deleteCard($bookId, 'does-not-exist.vcf')); + + $this->assertSame(0, $this->countChanges($bookId)); + $this->assertSame(1, $this->getSyncToken($bookId)); + } + + public function testUpdateCardWithUnchangedDataRecordsNoChange(): void { + $this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []); + $books = $this->backend->getUsersOwnAddressBooks(self::UNIT_TEST_USER); + $bookId = (int)$books[0]['id']; + + $uri = $this->getUniqueID('card'); + $this->backend->createCard($bookId, $uri, $this->vcardTest0); + $changesAfterCreate = $this->countChanges($bookId); + $syncTokenAfterCreate = $this->getSyncToken($bookId); + + $etag = $this->createBackend()->updateCard($bookId, $uri, $this->vcardTest0); + + $this->assertEquals('"' . md5($this->vcardTest0) . '"', $etag); + $this->assertSame($changesAfterCreate, $this->countChanges($bookId)); + $this->assertSame($syncTokenAfterCreate, $this->getSyncToken($bookId)); + } + + public function testUpdateCardWithChangedDataRecordsChange(): void { + $this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []); + $books = $this->backend->getUsersOwnAddressBooks(self::UNIT_TEST_USER); + $bookId = (int)$books[0]['id']; + + $uri = $this->getUniqueID('card'); + $this->backend->createCard($bookId, $uri, $this->vcardTest0); + $changesAfterCreate = $this->countChanges($bookId); + $syncTokenAfterCreate = $this->getSyncToken($bookId); + + $this->createBackend()->updateCard($bookId, $uri, $this->vcardTest1); + + $this->assertSame($changesAfterCreate + 1, $this->countChanges($bookId)); + $this->assertSame($syncTokenAfterCreate + 1, $this->getSyncToken($bookId)); + } + + /** + * Federated full syncs null `lastmodified` up front and delete whatever is + * still null afterwards, so an unchanged card must still refresh the column. + */ + public function testUpdateCardWithUnchangedDataClearsPendingState(): void { + $this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []); + $books = $this->backend->getUsersOwnAddressBooks(self::UNIT_TEST_USER); + $bookId = (int)$books[0]['id']; + + $uri = $this->getUniqueID('card'); + $this->backend->createCard($bookId, $uri, $this->vcardTest0); + $this->backend->markCardsAsPending($bookId); + $this->assertCount(1, $this->backend->getPendingCards($bookId)); + + $this->createBackend()->updateCard($bookId, $uri, $this->vcardTest0); + + $this->assertEmpty($this->backend->getPendingCards($bookId)); + } + public function testSyncSupport(): void { $this->backend = $this->getMockBuilder(CardDavBackend::class) ->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend, $this->config])