diff --git a/.env b/.env index b20bbb2c..54dbb535 100644 --- a/.env +++ b/.env @@ -50,4 +50,4 @@ BOOK_FILE_NAMING_FORMAT="{serie}-{serieIndex}-{title}" KOBO_PROXY_USE_DEV=0 KOBO_PROXY_USE_EVERYWHERE=0 KOBO_PROXY_ENABLED=1 -###< kobo/proxy \ No newline at end of file +###< kobo/proxy diff --git a/.env.test b/.env.test index 16c52ced..d2ff4f8b 100644 --- a/.env.test +++ b/.env.test @@ -13,4 +13,4 @@ TYPESENSE_KEY=xyz # Disable the proxy for the test env KOBO_PROXY_USE_EVERYWHERE=0 -KOBO_PROXY_ENABLED=0 \ No newline at end of file +KOBO_PROXY_ENABLED=0 diff --git a/.github/workflows/symfony.yml b/.github/workflows/symfony.yml index d38b77b4..e0cc84e5 100644 --- a/.github/workflows/symfony.yml +++ b/.github/workflows/symfony.yml @@ -31,7 +31,9 @@ jobs: php-version: '8.2' - uses: actions/checkout@v3 - name: Copy .env.test.local - run: php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');" + run: | + php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');" + echo "KEPUBIFY_BIN=/usr/local/bin/kepubify" >> .env.test.local - name: Cache Composer packages id: composer-cache uses: actions/cache@v3 @@ -42,6 +44,28 @@ jobs: ${{ runner.os }}-php- - name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Cache kepubify + id: kepubify-cache-restore + uses: actions/cache@v3 + with: + path: /usr/local/bin/kepubify + key: ${{ runner.os }}-kepubify-cache + - name: Install kepubify + if: steps.kepubify-cache-restore.outputs.cache-hit != 'true' + run: | + mkdir -p /usr/local/bin/ + # Install kepubify (from https://github.com/linuxserver/docker-calibre-web/blob/master/Dockerfile) + KEPUBIFY_RELEASE=$(curl -sX GET "https://api.github.com/repos/pgaskin/kepubify/releases/latest" | awk '/tag_name/{print $4;exit}' FS='[""]'); + curl -o /usr/local/bin/kepubify -L https://github.com/pgaskin/kepubify/releases/download/${KEPUBIFY_RELEASE}/kepubify-linux-64bit + chmod +x /usr/local/bin/kepubify + - name: Cache kepubify + uses: actions/cache/save@v4 + if: steps.kepubify-cache-restore.outputs.cache-hit != 'true' + id: kepubify-cache-save + with: + path: /usr/local/bin/kepubify + key: ${{ runner.os }}-kepubify-cache + - name: Create Database run: | mkdir -p data diff --git a/composer.json b/composer.json index c89a4fd5..b6a056ed 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "symfony/http-client": "^6.2", "symfony/intl": "^7.1", "symfony/mailer": "^7.1", + "symfony/messenger": "^6.2", "symfony/mime": "^7.1", "symfony/monolog-bundle": "^3.0", "symfony/notifier": "^7.1", diff --git a/composer.lock b/composer.lock index 66891e04..642c702e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3f110a43edc107101565ea62e1f477e3", + "content-hash": "b8316e0e6fad10c57c329c72630eac18", "packages": [ { "name": "acseo/typesense-bundle", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 270b2537..32fb174e 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -26,6 +26,11 @@ framework: enable_attributes: true name_converter: 'App\Kobo\Serializer\KoboNameConverter' + cache: + pools: + kepubify_result_pool: + adapter: cache.app + when@test: framework: test: true diff --git a/config/services.yaml b/config/services.yaml index e22f5b7f..5a58c8d4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -14,6 +14,8 @@ parameters: KOBO_API_URL: '%env(KOBO_API_URL)%' env(KOBO_IMAGE_API_URL): "https://cdn.kobo.com/book-images" KOBO_IMAGE_API_URL: '%env(KOBO_IMAGE_API_URL)%' + env(KEPUBIFY_BIN): "/usr/bin/kepubify" + KEPUBIFY_BIN: '%env(KEPUBIFY_BIN)%' services: # default configuration for services in *this* file _defaults: diff --git a/src/Controller/Kobo/KoboDownloadController.php b/src/Controller/Kobo/KoboDownloadController.php index 0fb4e719..db56ac10 100644 --- a/src/Controller/Kobo/KoboDownloadController.php +++ b/src/Controller/Kobo/KoboDownloadController.php @@ -25,11 +25,11 @@ public function __construct( } #[Route('/v1/download/{id}.{extension}', name: 'download', requirements: ['bookId' => '\d+', 'extension' => '[A-Za-z0-9]+'], methods: ['GET'])] - public function download(KoboDevice $kobo, Book $book): Response + public function download(KoboDevice $kobo, Book $book, string $extension): Response { $this->assertCanDownload($kobo, $book); - return $this->downloadHelper->getResponse($book); + return $this->downloadHelper->getResponse($book, $extension); } private function assertCanDownload(KoboDevice $kobo, Book $book): void diff --git a/src/Kobo/BookDownloadInfo.php b/src/Kobo/BookDownloadInfo.php new file mode 100644 index 00000000..af6eedab --- /dev/null +++ b/src/Kobo/BookDownloadInfo.php @@ -0,0 +1,20 @@ +size; + } + + public function getUrl(): string + { + return $this->url; + } +} diff --git a/src/Kobo/DownloadHelper.php b/src/Kobo/DownloadHelper.php index ec28902c..5bbefc67 100644 --- a/src/Kobo/DownloadHelper.php +++ b/src/Kobo/DownloadHelper.php @@ -6,12 +6,17 @@ use App\Entity\KoboDevice; use App\Exception\BookFileNotFound; use App\Kobo\ImageProcessor\CoverTransformer; +use App\Kobo\Kepubify\KepubifyConversionFailed; +use App\Kobo\Kepubify\KepubifyMessage; +use App\Kobo\Response\MetadataResponseService; use App\Service\BookFileSystemManager; use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class DownloadHelper @@ -20,8 +25,9 @@ public function __construct( private readonly BookFileSystemManager $fileSystemManager, private readonly CoverTransformer $coverTransformer, protected UrlGeneratorInterface $urlGenerator, - protected LoggerInterface $logger) - { + protected LoggerInterface $logger, + protected MessageBusInterface $messageBus, + ) { } protected function getBookFilename(Book $book): string @@ -34,17 +40,12 @@ public function getSize(Book $book): int return $this->fileSystemManager->getBookSize($book) ?? 0; } - public function getCoverSize(Book $book): int - { - return $this->fileSystemManager->getCoverSize($book) ?? 0; - } - - public function getUrlForKoboDevice(Book $book, KoboDevice $kobo): string + private function getUrlForKoboDevice(Book $book, KoboDevice $kobo, string $extension): string { return $this->urlGenerator->generate('app_kobodownload', [ 'id' => $book->getId(), 'accessKey' => $kobo->getAccessKey(), - 'extension' => $book->getExtension(), + 'extension' => strtolower($extension), ], UrlGeneratorInterface::ABSOLUTE_URL); } @@ -86,28 +87,45 @@ public function getCoverResponse(Book $book, int $width, int $height, string $ex return $response; } - public function getResponse(Book $book): StreamedResponse + /** + * @throws NotFoundHttpException Book conversion failed + */ + public function getResponse(Book $book, string $format): Response { $bookPath = $this->getBookFilename($book); if (false === $this->exists($book)) { throw new BookFileNotFound($bookPath); } - $response = new StreamedResponse(function () use ($bookPath) { - readfile($bookPath); - }, Response::HTTP_OK); - $filename = $book->getBookFilename(); + $message = null; + + if ($format === MetadataResponseService::KEPUB_FORMAT) { + try { + $message = $this->runKepubify($bookPath); + } catch (KepubifyConversionFailed $e) { + throw new NotFoundHttpException('Book conversion failed', $e); + } + } + + $fileToStream = $message?->destination ?? $bookPath; + $fileSize = $message?->size ?? filesize($fileToStream); + + $response = (new BinaryFileResponse($fileToStream, Response::HTTP_OK)) + ->deleteFileAfterSend($message?->destination !== null); + + $filename = basename($book->getBookFilename(), $book->getExtension()).strtolower($format); $encodedFilename = rawurlencode($filename); $simpleName = rawurlencode(sprintf('book-%s-%s', $book->getId(), preg_replace('/[^a-zA-Z0-9\.\-_]/', '_', $filename))); - $response->headers->set('Content-Type', match (strtolower($book->getExtension())) { - 'epub', 'epub3' => 'application/epub+zip', + $response->headers->set('Content-Type', match (strtoupper($format)) { + MetadataResponseService::KEPUB_FORMAT, MetadataResponseService::EPUB_FORMAT, MetadataResponseService::EPUB3_FORMAT => 'application/epub+zip', default => 'application/octet-stream', }); - $response->headers->set('Content-Disposition', - sprintf('attachment; filename="%s"; filename*=UTF-8\'\'%s', $simpleName, $encodedFilename)); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $encodedFilename, $simpleName)); - $response->headers->set('Content-Length', (string) $this->getSize($book)); + if ($fileSize !== false) { + $response->headers->set('Content-Length', (string) $fileSize); + } return $response; } @@ -116,4 +134,47 @@ public function coverExist(Book $book): bool { return $this->fileSystemManager->coverExist($book); } + + /** + * @throws KepubifyConversionFailed + */ + private function runKepubify(string $bookPath): KepubifyMessage + { + $message = new KepubifyMessage($bookPath); + $this->messageBus->dispatch($message); + + if ($message->destination === null || $message->size === null) { + throw new KepubifyConversionFailed($bookPath); + } + + return $message; + } + + /** + * @throws KepubifyConversionFailed + * @throws BookFileNotFound + */ + public function getDownloadInfo(Book $book, KoboDevice $koboDevice, string $extension): BookDownloadInfo + { + $bookPath = $this->getBookFilename($book); + if (false === $this->exists($book)) { + throw new BookFileNotFound($bookPath); + } + + $url = $this->getUrlForKoboDevice($book, $koboDevice, $extension); + + if (strtoupper($extension) !== MetadataResponseService::KEPUB_FORMAT) { + return new BookDownloadInfo($this->getSize($book), $url); + } + + // Convert the book to fetch the final size + $message = $this->runKepubify($bookPath); + + $info = new BookDownloadInfo((int) $message->size, $url); + if ($message->destination !== null) { + unlink($message->destination); + } + + return $info; + } } diff --git a/src/Kobo/Kepubify/KebpubifyCachedData.php b/src/Kobo/Kepubify/KebpubifyCachedData.php new file mode 100644 index 00000000..97d529aa --- /dev/null +++ b/src/Kobo/Kepubify/KebpubifyCachedData.php @@ -0,0 +1,33 @@ +size = (int) filesize($filename); + $this->content = (string) file_get_contents($filename); + } + + public function getSize(): int + { + return $this->size; + } + + public function getContent(): string + { + return $this->content; + } + + public function jsonSerialize(): array + { + return [ + 'size' => $this->size, + 'content' => $this->content, + ]; + } +} diff --git a/src/Kobo/Kepubify/KepubifyConversionFailed.php b/src/Kobo/Kepubify/KepubifyConversionFailed.php new file mode 100644 index 00000000..9f8ede3c --- /dev/null +++ b/src/Kobo/Kepubify/KepubifyConversionFailed.php @@ -0,0 +1,11 @@ +kepubifyBinary = $kepubifyBinary; + } + + public function isEnabled(): bool + { + return trim($this->kepubifyBinary) !== ''; + } + + public function getKepubifyBinary(): string + { + return $this->kepubifyBinary; + } + + public function disable(): string + { + $lastValue = $this->kepubifyBinary; + $this->kepubifyBinary = ''; + + return $lastValue; + } +} diff --git a/src/Kobo/Kepubify/KepubifyMessage.php b/src/Kobo/Kepubify/KepubifyMessage.php new file mode 100644 index 00000000..1419f794 --- /dev/null +++ b/src/Kobo/Kepubify/KepubifyMessage.php @@ -0,0 +1,25 @@ +kepubifyEnabler->isEnabled()) { + return; + } + + // Create a temporary file + $temporaryFile = $this->getTemporaryFilename(); + if ($temporaryFile === false) { + $this->logger->error('Error while creating temporary file'); + + return; + } + + // Fetch the conversion result from cache + try { + $item = $this->kepubifyCachePool->getItem('kepubify_object_'.md5($message->source)); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error while caching kepubify: {error}', [ + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + return; + } + + // Do the conversion and cache it + if (false === $item->isHit()) { + $temporaryFile = $this->convert($message, $temporaryFile); + if ($temporaryFile === null) { + $message->destination = null; + $message->size = null; + + return; + } + + $data = new KebpubifyCachedData($temporaryFile); + $item->set($data); + $item->expiresAfter(self::CACHE_TIME_SECONDS); + $this->kepubifyCachePool->save($item); + + $message->size = $data->getSize(); + $message->destination = $temporaryFile; + + return; + } + + // Restore the result from cache + $data = $item->get(); + if (!$data instanceof KebpubifyCachedData) { + try { + $this->kepubifyCachePool->deleteItem($item->getKey()); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error while deleting cached kepubify data: {error}', [ + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + } + + return; + } + + $result = file_put_contents($temporaryFile, $data->getContent()); + if ($result === false) { + $this->logger->error('Error while restoring cached kepubify data'); + $temporaryFile = null; + } + $message->destination = $temporaryFile; + $message->size = $data->getSize(); + } + + /** + * Create a temporary file to handle the conversion result. + * Note that the name must be unique to handle concurrent requests + * because the file will be deleted once the request is served. + * + * @return string|false + */ + private function getTemporaryFilename(): string|false + { + $dir = $this->cacheDir.self::CACHE_FOLDER; + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + return false; + } + + return tempnam($dir, self::TEMP_NAME_SUFFIX); + } + + private function convert(KepubifyMessage $message, string $temporaryFile): ?string + { + // Run the conversion + $process = new Process([$this->kepubifyEnabler->getKepubifyBinary(), '--output', $temporaryFile, $message->source]); + $this->logger->debug('Run kepubify command: {command}', ['command' => $process->getCommandLine()]); + $process->run(); + + if (!$process->isSuccessful()) { + $this->logger->error('Error while running kepubify: {output}: {error}', [ + 'output' => $process->getOutput(), + 'error' => $process->getErrorOutput(), + ]); + @unlink($temporaryFile); + $temporaryFile = null; + } + + return $temporaryFile; + } +} diff --git a/src/Kobo/Response/MetadataResponseService.php b/src/Kobo/Response/MetadataResponseService.php index 9f6d2e15..13e7398d 100644 --- a/src/Kobo/Response/MetadataResponseService.php +++ b/src/Kobo/Response/MetadataResponseService.php @@ -5,12 +5,22 @@ use App\Entity\Book; use App\Entity\KoboDevice; use App\Kobo\DownloadHelper; +use App\Kobo\Kepubify\KepubifyConversionFailed; +use App\Kobo\Kepubify\KepubifyEnabler; use App\Kobo\SyncToken; +use Psr\Log\LoggerInterface; class MetadataResponseService { - public function __construct(protected DownloadHelper $downloadHelper) - { + public const KEPUB_FORMAT = 'KEPUB'; + public const EPUB3_FORMAT = 'EPUB3'; + public const EPUB_FORMAT = 'EPUB'; + + public function __construct( + protected DownloadHelper $downloadHelper, + protected KepubifyEnabler $kepubifyEnabler, + protected LoggerInterface $koboLogger, + ) { } protected function getDownloadUrls(Book $book, KoboDevice $kobo, ?array $filters = []): array @@ -19,19 +29,31 @@ protected function getDownloadUrls(Book $book, KoboDevice $kobo, ?array $filters $platform = reset($platforms); $platform = $platform === false ? 'Generic' : $platform; - $response = []; + // If format conversion is enabled, we convert the book to KEPUB and return it + if ($this->kepubifyEnabler->isEnabled()) { + try { + $downloadInfo = $this->downloadHelper->getDownloadInfo($book, $kobo, self::KEPUB_FORMAT); - $formats = ['EPUB3']; // EPUB3 is required for Kobo - foreach ($formats as $format) { // and ... EPUB3FL ?; - $response[] = [ - 'Format' => $format, - 'Size' => $this->downloadHelper->getSize($book), - 'Url' => $this->downloadHelper->getUrlForKoboDevice($book, $kobo), - 'Platform' => $platform, - ]; + return [0 => [ + 'Format' => self::KEPUB_FORMAT, + 'Size' => $downloadInfo->getSize(), + 'Url' => $downloadInfo->getUrl(), + 'Platform' => $platform, + ]]; + } catch (KepubifyConversionFailed $e) { + $this->koboLogger->info('Conversion failed for book {book}', ['book' => $book->getUuid(), 'exception' => $e]); + } } - return $response; + // Otherwise, we return the original book with a EPUB3 format + $downloadInfo = $this->downloadHelper->getDownloadInfo($book, $kobo, $book->getExtension()); + + return [0 => [ + 'Format' => self::EPUB3_FORMAT, + 'Size' => $downloadInfo->getSize(), + 'Url' => $downloadInfo->getUrl(), + 'Platform' => $platform, + ]]; } public function fromBook(Book $book, KoboDevice $kobo, ?SyncToken $syncToken = null): array diff --git a/tests/Contraints/AssertHasDownloadWithFormat.php b/tests/Contraints/AssertHasDownloadWithFormat.php new file mode 100644 index 00000000..02e9cac0 --- /dev/null +++ b/tests/Contraints/AssertHasDownloadWithFormat.php @@ -0,0 +1,72 @@ +test($other); + }catch (\InvalidArgumentException $e){ + return false; + } + return true; + } + + /** + * @param array|mixed $other + */ + public function test($other): void + { + if (false === is_array($other)) { + throw new \InvalidArgumentException('JSON is not an array'); + } + + if (count($other) < 1) { + throw new \InvalidArgumentException('array is empty'); + } + + $other = $other[0]; + + if (!array_key_exists('DownloadUrls', $other)) { + throw new \InvalidArgumentException('DownloadUrls exists'); + } + + $downloads = $other['DownloadUrls']; + if (false === is_array($downloads) || count($downloads) < 1) { + throw new \InvalidArgumentException('DownloadUrls is empty'); + } + + + foreach ($downloads as $pos => $download) { + if (!array_key_exists('Format', $download)) { + throw new \InvalidArgumentException('Download has no key Format'); + } + + if ($download['Format'] !== $this->format) { + throw new \InvalidArgumentException(sprintf('Invalid format, expected %s got %s', $this->format, $download['Format'])); + } + + foreach (['Format', 'Platform', 'Url', 'Size'] as $key) { + if (!array_key_exists($key, $download)) { + throw new \InvalidArgumentException('Download ' . $pos . ' has ko key ' . $key); + } + + if (trim((string)$download[$key]) === '') { + throw new \InvalidArgumentException('Download ' . $pos . ' has an empty value for key ' . $key); + } + } + } + } + + public function toString(): string + { + return sprintf('JSON contains a download key with the specified format: %s', $this->format); + } +} \ No newline at end of file diff --git a/tests/Controller/Kobo/AbstractKoboControllerTest.php b/tests/Controller/Kobo/AbstractKoboControllerTest.php index fa6f4260..14676dec 100644 --- a/tests/Controller/Kobo/AbstractKoboControllerTest.php +++ b/tests/Controller/Kobo/AbstractKoboControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Controller\Kobo; +use App\Kobo\Kepubify\KepubifyEnabler; use App\Tests\InjectFakeFileSystemTrait; use Symfony\Component\BrowserKit\AbstractBrowser; use App\DataFixtures\BookFixture; @@ -44,7 +45,7 @@ public function getKoboDevice(bool $refresh = false): KoboDevice return $this->koboDevice; } - private function getBook(): Book + protected function getBook(): Book { // @phpstan-ignore-next-line return $this->getEntityManager()->getRepository(Book::class)->find(BookFixture::ID); @@ -82,6 +83,12 @@ private function loadKoboDevice(): KoboDevice return $kobo; } + protected function getKepubifyEnabler(): KepubifyEnabler + { + $service = self::getContainer()->get(KepubifyEnabler::class); + assert($service instanceof KepubifyEnabler); + return $service; + } } \ No newline at end of file diff --git a/tests/Controller/Kobo/KoboDownloadControllerTest.php b/tests/Controller/Kobo/KoboDownloadControllerTest.php index a337aecf..c1b13ef3 100644 --- a/tests/Controller/Kobo/KoboDownloadControllerTest.php +++ b/tests/Controller/Kobo/KoboDownloadControllerTest.php @@ -6,6 +6,7 @@ use App\Entity\Book; use App\Entity\KoboDevice; use App\Kobo\DownloadHelper; +use App\Kobo\Response\MetadataResponseService; class KoboDownloadControllerTest extends AbstractKoboControllerTest { @@ -14,6 +15,55 @@ public function testDownload(): void $client = static::getClient(); $this->injectFakeFileSystemManager(); + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); + self::assertNotNull($book, 'The book is not linked to the Kobo'); + + /** @var DownloadHelper $downloadHelper */ + $downloadHelper = self::getContainer()->get(DownloadHelper::class); + + self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); + + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.%s', $this->accessKey, BookFixture::ID, 'epub')); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('Content-Type', 'application/epub+zip'); + self::assertResponseHasHeader('Content-Length'); + $expectedDisposition = "attachment; filename=book-1-TheOdysses.epub; filename*=utf-8''TheOdysses.epub"; + self::assertResponseHeaderSame('Content-Disposition', $expectedDisposition, 'The Content-Disposition header is not as expected'); + + } + + public function testDownloadKepubFailed(): void + { + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + + // Disable Kepubify conversion + $lastValue = $this->getKepubifyEnabler()->disable(); + + try { + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); + self::assertNotNull($book, 'The book is not linked to the Kobo'); + + /** @var DownloadHelper $downloadHelper */ + $downloadHelper = self::getContainer()->get(DownloadHelper::class); + + self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); + + + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.%s', $this->accessKey, BookFixture::ID, MetadataResponseService::KEPUB_FORMAT)); + self::assertResponseStatusCodeSame(404); // We can not download kepub as the conversion is disabled + } finally { + // Re-enable Kepubify conversion for other tests + $this->getKepubifyEnabler()->setKepubifyBinary($lastValue); + } + } + + public function testDownloadKepub(): void + { + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + $book = $this->findByIdAndKobo(BookFixture::ID, $this->getKoboDevice()); self::assertNotNull($book, 'The book is not linked to the Kobo'); @@ -23,13 +73,13 @@ public function testDownload(): void self::assertTrue($downloadHelper->exists($book), 'The book file does not exist'); - $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.epub', $this->accessKey, BookFixture::ID)); + $client?->request('GET', sprintf('/kobo/%s/v1/download/%s.'.MetadataResponseService::KEPUB_FORMAT, $this->accessKey, BookFixture::ID)); self::assertResponseIsSuccessful(); self::assertResponseHeaderSame('Content-Type', 'application/epub+zip'); self::assertResponseHasHeader('Content-Length'); - $expectedDisposition = "attachment; filename=\"book-1-TheOdysses.epub\"; filename*=UTF-8''TheOdysses.epub"; + $expectedDisposition = "attachment; filename=book-1-TheOdysses.kepub; filename*=utf-8''TheOdysses.kepub"; self::assertResponseHeaderSame('Content-Disposition', $expectedDisposition, 'The Content-Disposition header is not as expected'); } diff --git a/tests/Controller/Kobo/KoboSyncControllerTest.php b/tests/Controller/Kobo/KoboSyncControllerTest.php index 2ac691b2..9338d0d4 100644 --- a/tests/Controller/Kobo/KoboSyncControllerTest.php +++ b/tests/Controller/Kobo/KoboSyncControllerTest.php @@ -3,6 +3,8 @@ namespace App\Tests\Controller\Kobo; use App\Entity\KoboSyncedBook; +use App\Kobo\Response\MetadataResponseService; +use App\Tests\Contraints\AssertHasDownloadWithFormat; use App\Tests\Contraints\JSONIsValidSyncResponse; class KoboSyncControllerTest extends AbstractKoboControllerTest @@ -20,6 +22,7 @@ public function assertPreConditions(): void public function testSyncControllerWithForce() : void { $client = static::getClient(); + $this->injectFakeFileSystemManager(); $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/sync?force=1'); @@ -40,6 +43,7 @@ public function testSyncControllerWithForce() : void public function testSyncControllerWithoutForce() : void { $client = static::getClient(); + $this->injectFakeFileSystemManager(); $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/sync'); @@ -56,4 +60,36 @@ public function testSyncControllerWithoutForce() : void $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); } + + public function testSyncControllerMetadata() : void + { + $uuid = $this->getBook()->getUuid(); + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + $this->getKepubifyEnabler()->disable(); + + $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/'.$uuid."/metadata"); + + $response = self::getJsonResponse(); + self::assertResponseIsSuccessful(); + self::assertThat($response, new AssertHasDownloadWithFormat(MetadataResponseService::EPUB3_FORMAT), 'Response is not a valid download response'); + + $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); + } + + public function testSyncControllerMetadataWithConversion() : void + { + $uuid = $this->getBook()->getUuid(); + $client = static::getClient(); + $this->injectFakeFileSystemManager(); + self::assertTrue($this->getKepubifyEnabler()->isEnabled()); + + $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/'.$uuid."/metadata"); + + $response = self::getJsonResponse(); + self::assertResponseIsSuccessful(); + self::assertThat($response, new AssertHasDownloadWithFormat(MetadataResponseService::KEPUB_FORMAT), 'Response is not a valid download response'); + + $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); + } } \ No newline at end of file