diff --git a/config/services.yaml b/config/services.yaml index 6bfbdfd2..3cacbea4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -136,4 +136,10 @@ when@test: bind: $publicDirectory: '%kernel.project_dir%/tests/Resources' App\Service\BookFileSystemManagerInterface: - alias: 'App\Tests\FileSystemManagerForTests' \ No newline at end of file + alias: 'App\Tests\FileSystemManagerForTests' + + App\Tests\TestClock: + public: true + 'Gedmo\Timestampable\TimestampableListener': + calls: + - [ setClock, ['@App\Tests\TestClock'] ] diff --git a/src/Controller/Kobo/Api/V1/LibraryController.php b/src/Controller/Kobo/Api/V1/LibraryController.php index 4769b5ee..13517b7a 100644 --- a/src/Controller/Kobo/Api/V1/LibraryController.php +++ b/src/Controller/Kobo/Api/V1/LibraryController.php @@ -193,7 +193,7 @@ public function apiEndpoint(KoboDevice $koboDevice, SyncToken $syncToken, Reques $shouldContinue = $this->upstreamSyncMerger->merge($koboDevice, $response, $request); $httpResponse = $response->toJsonResponse(); - $httpResponse->headers->set('x-kobo-sync', $shouldContinue || count($books) < $count ? 'continue' : 'done'); + $httpResponse->headers->set(KoboDevice::KOBO_SYNC_SHOULD_CONTINUE_HEADER, $shouldContinue || count($books) < $count ? 'continue' : 'done'); // Once the response is generated, we update the list of synced books // If you do this before, the logic will be broken diff --git a/src/Entity/KoboDevice.php b/src/Entity/KoboDevice.php index 4d72e558..2beda821 100644 --- a/src/Entity/KoboDevice.php +++ b/src/Entity/KoboDevice.php @@ -20,6 +20,7 @@ class KoboDevice public const KOBO_DEVICE_ID_HEADER = 'X-Kobo-Deviceid'; public const KOBO_DEVICE_MODEL_HEADER = 'X-Kobo-Devicemodel'; public const KOBO_SYNC_TOKEN_HEADER = 'kobo-synctoken'; + public const KOBO_SYNC_SHOULD_CONTINUE_HEADER = 'x-kobo-sync'; #[ORM\Id] #[ORM\GeneratedValue] diff --git a/src/Kobo/Response/SyncResponse.php b/src/Kobo/Response/SyncResponse.php index 0a5835db..1412908f 100644 --- a/src/Kobo/Response/SyncResponse.php +++ b/src/Kobo/Response/SyncResponse.php @@ -192,7 +192,7 @@ private function getNewTags(): array private function getChangedTag(): array { $shelves = array_filter($this->shelves, function (Shelf $shelf) { - return $this->syncToken->lastModified instanceof \DateTimeInterface && $shelf->getUpdated() < $this->syncToken->lastModified; + return $this->syncToken->tagLastModified instanceof \DateTimeInterface && $shelf->getUpdated() >= $this->syncToken->tagLastModified; }); return array_map(function (Shelf $shelf) { diff --git a/src/Kobo/Response/SyncResponseHelper.php b/src/Kobo/Response/SyncResponseHelper.php index 9cc1ddfd..a1e4259a 100644 --- a/src/Kobo/Response/SyncResponseHelper.php +++ b/src/Kobo/Response/SyncResponseHelper.php @@ -23,13 +23,11 @@ public function isChangedEntitlement(Book $book, KoboDevice $koboDevice, SyncTok return false; } - if ($this->isChangedReadingState($book, $koboDevice, $syncToken)) { + if (!$syncToken->lastModified instanceof \DateTimeInterface) { return false; } - return ($syncToken->lastModified instanceof \DateTimeInterface && $book->getUpdated() instanceof \DateTimeInterface - && $book->getUpdated() >= $syncToken->lastModified) - || ($syncToken->lastCreated instanceof \DateTimeInterface && $book->getCreated() >= $syncToken->lastCreated); + return $book->getUpdated() >= $syncToken->lastModified; } public function isNewEntitlement(Book $book, SyncToken $syncToken): bool @@ -39,9 +37,14 @@ public function isNewEntitlement(Book $book, SyncToken $syncToken): bool public function isChangedReadingState(Book $book, KoboDevice $koboDevice, SyncToken $syncToken): bool { - if ($this->isNewEntitlement($book, $syncToken)) { + if ($this->isChangedEntitlement($book, $koboDevice, $syncToken)) { return false; } + + if (!$syncToken->readingStateLastModified instanceof \DateTimeInterface) { + return false; + } + $lastInteraction = $book->getLastInteraction($koboDevice->getUser()); return ($lastInteraction instanceof BookInteraction) && $lastInteraction->getUpdated() >= $syncToken->readingStateLastModified; diff --git a/src/Kobo/UpstreamSyncMerger.php b/src/Kobo/UpstreamSyncMerger.php index 325235d9..0ea95f2c 100644 --- a/src/Kobo/UpstreamSyncMerger.php +++ b/src/Kobo/UpstreamSyncMerger.php @@ -80,6 +80,6 @@ private function parseJson(Response $response): array private function shouldContinue(Response $response): bool { - return $response->headers->get('x-kobo-sync') === 'continue'; + return $response->headers->get(KoboDevice::KOBO_SYNC_SHOULD_CONTINUE_HEADER) === 'continue'; } } diff --git a/src/Repository/BookRepository.php b/src/Repository/BookRepository.php index 7333ad3a..a6becb0b 100644 --- a/src/Repository/BookRepository.php +++ b/src/Repository/BookRepository.php @@ -392,24 +392,28 @@ private function getChangedBooksQueryBuilder(KoboDevice $koboDevice, SyncToken $ ->setParameter('koboDevice', $koboDevice) ->setParameter('extension', 'epub') // Pdf is not supported by kobo sync ->groupBy('book.id'); + $bigOr = $qb->expr()->orX(); + if ($syncToken->lastCreated instanceof \DateTimeInterface) { - $qb->andWhere($qb->expr()->orX( + $bigOr->addMultiple([ $qb->expr()->isNull('koboSyncedBooks.created'), $qb->expr()->gte('book.created', ':lastCreated'), - )) - ->setParameter('lastCreated', $syncToken->lastCreated); + ]); + $qb->setParameter('lastCreated', $syncToken->lastCreated); } if ($syncToken->lastModified instanceof \DateTimeInterface) { - $qb->andWhere($qb->expr()->orX( + $bigOr->addMultiple([ 'book.updated > :lastModified', 'book.created > :lastModified', 'koboSyncedBooks.updated > :lastModified', $qb->expr()->isNull('koboSyncedBooks.updated'), - )); + ]); $qb->setParameter('lastModified', $syncToken->lastModified); } + $qb->andWhere($bigOr); + $qb->orderBy('book.updated'); if ($syncToken->filters['PrioritizeRecentReads'] ?? false) { $qb->orderBy('bookInteractions.updated', 'ASC'); diff --git a/tests/Controller/Kobo/KoboSyncControllerTest.php b/tests/Controller/Kobo/KoboSyncControllerTest.php index 6dabc9f9..4ee7d51b 100644 --- a/tests/Controller/Kobo/KoboSyncControllerTest.php +++ b/tests/Controller/Kobo/KoboSyncControllerTest.php @@ -3,6 +3,7 @@ namespace App\Tests\Controller\Kobo; use App\DataFixtures\BookFixture; +use App\Entity\KoboDevice; use App\Entity\KoboSyncedBook; use App\Kobo\Response\MetadataResponseService; use App\Kobo\SyncToken; @@ -10,6 +11,7 @@ use App\Service\KoboSyncTokenExtractor; use App\Tests\Contraints\AssertHasDownloadWithFormat; use App\Tests\Contraints\JSONIsValidSyncResponse; +use App\Tests\TestClock; class KoboSyncControllerTest extends AbstractKoboControllerTest { @@ -18,7 +20,7 @@ protected function tearDown(): void $this->getKoboStoreProxy()->setClient(null); $this->getKoboProxyConfiguration()->setEnabled(false); $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); - + $this->getService(TestClock::class)->setTime(null); parent::tearDown(); } @@ -117,7 +119,7 @@ public function testSyncControllerPaginated() : void }, $pageNum), 'Response is not a valid sync response for page '.$pageNum); $expectedContinueHeader = $pageNum === $numberOfPages ? 'done' : 'continue'; - self::assertResponseHeaderSame('x-kobo-sync', $expectedContinueHeader, 'x-kobo-sync is invalid'); + self::assertResponseHeaderSame(KoboDevice::KOBO_SYNC_SHOULD_CONTINUE_HEADER, $expectedContinueHeader, 'x-kobo-sync is invalid'); } $count = $this->getEntityManager()->getRepository(KoboSyncedBook::class)->count(['koboDevice' => 1]); @@ -132,6 +134,46 @@ public function testSyncControllerPaginated() : void } + /** + * @covers pagination2 + * @throws \JsonException + */ + public function testSyncControllerEdited() : void{ + $client = static::getClient(); + + // Create an old sync token + $clock = $this->getService(TestClock::class) + ->setTime(new \DateTimeImmutable('1 hour ago')); + $syncToken = new SyncToken(); + $syncToken->lastCreated = $clock->now(); + $syncToken->lastModified = $clock->now(); + $syncToken->tagLastModified = $clock->now(); + + // Sync all the books + $headers = $this->getService(KoboSyncTokenExtractor::class)->getTestHeader($syncToken); + $client?->request('GET', '/kobo/' . $this->accessKey . '/v1/library/sync', [], [], $headers); + self::assertResponseIsSuccessful(); + + // Edit the book detail 30 minutes ago + $clock->setTime(new \DateTimeImmutable('30 minutes ago')); + $book = $this->getBook(); + $book->setSlug($book->getSlug().".."); + $this->getEntityManager()->flush(); + + // Restore the real time + $clock->setTime(null); + + // Make sure the book has changed. + $client?->request('GET', '/kobo/' . $this->accessKey . '/v1/library/sync', [], [], $headers); + self::assertResponseIsSuccessful(); + self::assertThat(self::getJsonResponse(), new JSONIsValidSyncResponse([ + 'ChangedEntitlement' => 1 + ])); + + self::assertResponseHeaderSame(KoboDevice::KOBO_SYNC_SHOULD_CONTINUE_HEADER, 'done', 'x-kobo-sync is invalid'); + } + + public function testSyncControllerWithRemote() : void { $client = static::getClient(); diff --git a/tests/TestClock.php b/tests/TestClock.php new file mode 100644 index 00000000..30fcd8c0 --- /dev/null +++ b/tests/TestClock.php @@ -0,0 +1,21 @@ +now ?? new \DateTimeImmutable(); + } + + public function setTime(?\DateTimeImmutable $now): self + { + $this->now = $now; + + return $this; + } +} \ No newline at end of file