refactor(reader): serve pages as static files instead of base64
Replace the per-page API call (base64 payload) with static image URLs
served directly by Caddy from public/images/pages/{chapterId}/.
- LocalImageStorage now stores to public/images/ (was MANGA_DATA_PATH)
- LegacyChapterRepository returns /images/pages/{id}/{file} URLs,
uses getimagesize() instead of loading file content into memory
- Delete GetChapterPage query/handler/response, ChapterPageResource,
ChapterPageProvider, PageContent model
- Remove getPageContent() from ChapterRepositoryInterface
- Frontend: loadChapter() fetches chapter + all pages in parallel,
ReaderPage uses URL instead of base64 data URI, InfiniteReader drops
lazy-load observer side effect, readerStore drops loadedPages/preload
- GetChapterPagesTest: extract fixture images from CBZ at runtime,
ignore tests/Fixtures/pages/ in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6875ad4222
commit
322c396165
251
.claude/skills/vue-frontend/SKILL.md
Normal file
251
.claude/skills/vue-frontend/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: vue-frontend
|
||||
description: Architecture Vue.js du projet Mangarr — structure DDD front (domain/application/infrastructure/presentation), patterns Pinia store, TanStack Query composables, API repositories, conventions de nommage. Utiliser quand on crée ou modifie un composant Vue, une page, un store Pinia, un composable, ou un repository API dans assets/vue/app/.
|
||||
allowed-tools: Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Architecture Vue.js — Mangarr Frontend
|
||||
|
||||
## Structure des dossiers
|
||||
|
||||
```
|
||||
assets/vue/app/
|
||||
index.js # Point d'entrée : Vue + Pinia + Router + VueQuery
|
||||
App.vue # Root : <router-view> + <NotificationToast>
|
||||
router/index.js # Routes imbriquées sous Layout, base /vue/
|
||||
domain/
|
||||
{DomainName}/
|
||||
domain/
|
||||
entities/ # Classes entités JS
|
||||
constants/ # Constantes du domaine
|
||||
application/
|
||||
store/ # Stores Pinia
|
||||
infrastructure/
|
||||
api/ # Clients HTTP (ApiXxxRepository)
|
||||
presentation/
|
||||
pages/ # Composants pleine page
|
||||
components/ # Composants réutilisables
|
||||
composables/ # Logique Vue (useXxx)
|
||||
shared/
|
||||
components/
|
||||
layout/ # Layout, Header, Sidebar
|
||||
ui/ # Composants UI génériques
|
||||
composables/ # useNotifications, etc.
|
||||
stores/ # headerStore, menuStore
|
||||
plugin/ # vueQuery.js config
|
||||
```
|
||||
|
||||
**Domaines existants :** `manga`, `reader`, `import`, `conversion`, `activity`, `setting`
|
||||
|
||||
## Conventions de nommage
|
||||
|
||||
| Couche | Pattern | Exemple |
|
||||
|--------|---------|---------|
|
||||
| Entité | `PascalCase` | `Manga`, `ImportFile`, `Job` |
|
||||
| Store Pinia | `use{Domain}Store()` | `useMangaStore()` |
|
||||
| Composable | `use{Feature}()` | `useMangaDetails()`, `useNotifications()` |
|
||||
| Repository API | `Api{Domain}Repository` | `ApiMangaRepository` |
|
||||
| Page | `{Domain}{Action}.vue` | `MangaDetails.vue`, `NewImportPage.vue` |
|
||||
| Composant | `{Domain}{Feature}.vue` | `MangaCard.vue`, `StatusBadge.vue` |
|
||||
| Modal | `{Feature}Modal.vue` | `MangaDeleteModal.vue` |
|
||||
|
||||
## Pattern Store Pinia
|
||||
|
||||
```javascript
|
||||
// application/store/xyzStore.js
|
||||
export const useXyzStore = defineStore('xyz', {
|
||||
state: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isReady: (state) => state.data && !state.isLoading,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async load() {
|
||||
this.isLoading = true
|
||||
try {
|
||||
const repo = new ApiXyzRepository()
|
||||
this.data = await repo.getAll()
|
||||
} catch (err) {
|
||||
this.error = err.message
|
||||
throw err
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Pattern Composable avec TanStack Query
|
||||
|
||||
Préférer TanStack Query pour les lectures (queries), le store Pinia pour les mutations et l'état global.
|
||||
|
||||
```javascript
|
||||
// presentation/composables/useXyzDetails.js
|
||||
export function useXyzDetails(xyzId) {
|
||||
const repo = new ApiXyzRepository()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['xyz', xyzId],
|
||||
queryFn: () => repo.getById(xyzId.value),
|
||||
enabled: computed(() => !!xyzId.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation
|
||||
export function useXyzEdit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data) => new ApiXyzRepository().edit(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['xyz'] }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Repository API
|
||||
|
||||
```javascript
|
||||
// infrastructure/api/apiXyzRepository.js
|
||||
export class ApiXyzRepository {
|
||||
async getAll() {
|
||||
const response = await fetch('/api/xyz')
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
const data = await response.json()
|
||||
return data.items.map(Xyz.fromApiData)
|
||||
}
|
||||
|
||||
async getById(id) {
|
||||
const response = await fetch(`/api/xyz/${id}`)
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
return Xyz.fromApiData(await response.json())
|
||||
}
|
||||
|
||||
async create(payload) {
|
||||
const response = await fetch('/api/xyz', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) throw new Error(await this.#extractError(response))
|
||||
return Xyz.fromApiData(await response.json())
|
||||
}
|
||||
|
||||
async #extractError(response) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return data.error || data.detail || `HTTP ${response.status}`
|
||||
} catch {
|
||||
return `HTTP ${response.status}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Entité
|
||||
|
||||
```javascript
|
||||
// domain/entities/xyz.js
|
||||
export class Xyz {
|
||||
constructor({ id, name, status }) {
|
||||
this.id = id
|
||||
this.name = name
|
||||
this.status = status
|
||||
}
|
||||
|
||||
static fromApiData(data) {
|
||||
return new Xyz(data)
|
||||
}
|
||||
|
||||
isActive() { return this.status === 'active' }
|
||||
isCompleted() { return this.status === 'completed' }
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern Page
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="error">{{ error }}</div>
|
||||
<ChildComponent v-else :data="data" @action="handleAction" />
|
||||
<FeatureModal :is-open="isModalOpen" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useFeatureComposable } from '../composables/useFeature'
|
||||
|
||||
const route = useRoute()
|
||||
const { data, isLoading, error } = useFeatureComposable(
|
||||
computed(() => route.params.id)
|
||||
)
|
||||
|
||||
const isModalOpen = ref(false)
|
||||
const closeModal = () => (isModalOpen.value = false)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Système de notifications (global)
|
||||
|
||||
```javascript
|
||||
import { useNotifications } from '@/shared/composables/useNotifications'
|
||||
|
||||
const { showSuccess, showError, showWarning, showInfo } = useNotifications()
|
||||
|
||||
showSuccess('Manga ajouté avec succès')
|
||||
showError('Erreur lors du chargement')
|
||||
```
|
||||
|
||||
## Configuration VueQuery (shared/plugin/vueQuery.js)
|
||||
|
||||
- `staleTime`: 5 minutes
|
||||
- `gcTime`: 10 minutes
|
||||
- `retry`: 1
|
||||
- `refetchOnWindowFocus`: true
|
||||
|
||||
## Upload de fichiers (FormData)
|
||||
|
||||
Ne pas définir `Content-Type` manuellement — le navigateur le gère automatiquement avec le boundary correct.
|
||||
|
||||
```javascript
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('mangaId', mangaId)
|
||||
|
||||
const response = await fetch('/api/xyz/import', {
|
||||
method: 'POST',
|
||||
body: formData, // pas de Content-Type header
|
||||
})
|
||||
```
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
make npm-run # Build dev one-shot — vérifie qu'il n'y a pas d'erreur de compilation
|
||||
make npm-watch # Watch + rebuild automatique pendant le développement
|
||||
make npm-add p=pkg # Ajouter une dépendance npm
|
||||
```
|
||||
|
||||
Après toute modification de composants Vue, stores ou repositories, lancer `make npm-run` pour valider le build.
|
||||
|
||||
## Règles à respecter
|
||||
|
||||
- **Domain** : entités JS pures, aucune dépendance Vue/fetch
|
||||
- **Application** : stores Pinia uniquement, pas d'appels fetch directs (passer par Infrastructure)
|
||||
- **Infrastructure** : repositories API, aucune logique Vue
|
||||
- **Presentation** : composants + composables, import uniquement depuis Application et Infrastructure
|
||||
- **Shared** : composants/composables transversaux, pas de dépendances vers les domaines
|
||||
- Préférer `useQuery`/`useMutation` (TanStack) pour les données serveur, Pinia pour l'état UI global
|
||||
- Un composable = une responsabilité, nommé `use{FeatureVerb}` (ex: `useMangaDelete`, `useMangaEdit`)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ yarn-error.log
|
||||
/public/images/
|
||||
src/Controller/TestController.php
|
||||
.phpunit.cache/test-results
|
||||
/tests/Fixtures/pages/
|
||||
|
||||
@@ -13,7 +13,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
error: null,
|
||||
pages: [],
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(), // Garder une trace des pages déjà chargées
|
||||
|
||||
// Paramètres pour les doubles pages
|
||||
doublePageSettings: {
|
||||
@@ -32,7 +31,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
|
||||
// Getters pour les doubles pages
|
||||
effectiveDoublePageMode: (state) => {
|
||||
// Si la détection automatique est désactivée, retourner 'normal'
|
||||
if (!state.doublePageSettings.autoDetect) {
|
||||
return 'normal';
|
||||
}
|
||||
@@ -55,28 +53,20 @@ export const useReaderStore = defineStore('reader', {
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
|
||||
// Charger les informations du chapitre
|
||||
const chapterData = await repository.getChapter(chapterId);
|
||||
const [chapterData, pagesData] = await Promise.all([
|
||||
repository.getChapter(chapterId),
|
||||
repository.getChapterPages(chapterId, 1, 9999),
|
||||
]);
|
||||
|
||||
this.currentChapter = Chapter.create(chapterData);
|
||||
|
||||
// Charger la liste des pages
|
||||
const pagesData = await repository.getChapterPages(chapterId);
|
||||
|
||||
// Initialiser le tableau avec des placeholders
|
||||
this.pages = new Array(pagesData.totalItems).fill(null);
|
||||
this.pages = pagesData.pages.map(p => ({
|
||||
id: p.id,
|
||||
pageNumber: p.pageNumber,
|
||||
url: p.url,
|
||||
dimensions: p.dimensions,
|
||||
}));
|
||||
this.totalPages = pagesData.totalItems;
|
||||
this.loadedPages.clear();
|
||||
|
||||
// Charger la première page
|
||||
if (this.totalPages > 0) {
|
||||
this.currentPage = 0;
|
||||
await this.loadPageData(0);
|
||||
|
||||
// En mode infini, précharger les premières pages
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(0);
|
||||
}
|
||||
}
|
||||
this.currentPage = 0;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
@@ -84,100 +74,28 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
async loadPageData(pageIndex) {
|
||||
if (!this.currentChapter || pageIndex < 0 || pageIndex >= this.totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la page est déjà chargée, ne rien faire
|
||||
if (this.loadedPages.has(pageIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = pageIndex + 1; // Convertir en 1-based pour l'API
|
||||
|
||||
// Marquer la page comme en cours de chargement
|
||||
const newPages = [...this.pages];
|
||||
newPages[pageIndex] = { loading: true };
|
||||
this.pages = newPages;
|
||||
|
||||
try {
|
||||
const repository = new ApiChapterRepository();
|
||||
const pageData = await repository.getChapterPage(this.currentChapter.id, pageNumber);
|
||||
|
||||
// Vérifier que les données sont valides
|
||||
if (!pageData || !pageData.base64Content) {
|
||||
throw new Error("Données de page invalides reçues de l'API");
|
||||
}
|
||||
|
||||
// Mettre à jour la page
|
||||
const updatedPages = [...this.pages];
|
||||
updatedPages[pageIndex] = {
|
||||
id: pageData.id,
|
||||
pageNumber: pageData.pageNumber,
|
||||
base64Content: pageData.base64Content,
|
||||
mimeType: pageData.mimeType,
|
||||
dimensions: pageData.dimensions
|
||||
};
|
||||
this.pages = updatedPages;
|
||||
this.loadedPages.add(pageIndex);
|
||||
} catch (error) {
|
||||
console.error(`Erreur lors du chargement de la page ${pageNumber}:`, error);
|
||||
// Marquer la page comme en erreur
|
||||
const errorPages = [...this.pages];
|
||||
errorPages[pageIndex] = { error: error.message };
|
||||
this.pages = errorPages;
|
||||
}
|
||||
},
|
||||
|
||||
async preloadNextPages(startIndex, count = 3) {
|
||||
const promises = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pageIndex = startIndex + i;
|
||||
if (pageIndex < this.totalPages) {
|
||||
promises.push(this.loadPageData(pageIndex));
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
},
|
||||
|
||||
async handlePageVisible(pageIndex) {
|
||||
handlePageVisible(pageIndex) {
|
||||
if (pageIndex !== this.currentPage) {
|
||||
this.currentPage = pageIndex;
|
||||
// Précharger les pages suivantes
|
||||
if (this.readingMode === 'infinite') {
|
||||
await this.preloadNextPages(pageIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nextPage() {
|
||||
nextPage() {
|
||||
if (!this.isLastPage) {
|
||||
this.currentPage++;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async previousPage() {
|
||||
previousPage() {
|
||||
if (!this.isFirstPage) {
|
||||
this.currentPage--;
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
async setReadingMode(mode) {
|
||||
if (mode === this.readingMode) return;
|
||||
|
||||
this.readingMode = mode;
|
||||
this.savePreferences();
|
||||
|
||||
// S'assurer que la page courante est chargée
|
||||
await this.loadPageData(this.currentPage);
|
||||
|
||||
// Si on passe en mode infini, précharger les pages suivantes
|
||||
if (mode === 'infinite') {
|
||||
await this.preloadNextPages(this.currentPage);
|
||||
}
|
||||
},
|
||||
|
||||
setReadingDirection(direction) {
|
||||
@@ -190,7 +108,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
this.savePreferences();
|
||||
},
|
||||
|
||||
// Nouvelles actions pour les doubles pages
|
||||
setDoublePageMode(mode) {
|
||||
if (['rotate', 'scroll', 'normal'].includes(mode)) {
|
||||
this.doublePageSettings.mobileMode = mode;
|
||||
@@ -225,16 +142,10 @@ export const useReaderStore = defineStore('reader', {
|
||||
async goToPreviousChapter() {
|
||||
if (this.currentChapter?.navigation?.previousChapter) {
|
||||
await this.loadChapter(this.currentChapter.navigation.previousChapter);
|
||||
// Aller à la dernière page du chapitre précédent
|
||||
this.currentPage = Math.max(0, this.totalPages - 1);
|
||||
// S'assurer que la page est chargée
|
||||
if (this.totalPages > 0) {
|
||||
await this.loadPageData(this.currentPage);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Gestion de la persistance des préférences
|
||||
savePreferences() {
|
||||
try {
|
||||
const preferences = {
|
||||
@@ -255,7 +166,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
if (stored) {
|
||||
const preferences = JSON.parse(stored);
|
||||
|
||||
// Appliquer les préférences sauvegardées
|
||||
if (preferences.readingMode) this.readingMode = preferences.readingMode;
|
||||
if (preferences.readingDirection) this.readingDirection = preferences.readingDirection;
|
||||
if (typeof preferences.zoom === 'number') this.zoom = preferences.zoom;
|
||||
@@ -277,7 +187,6 @@ export const useReaderStore = defineStore('reader', {
|
||||
}
|
||||
},
|
||||
|
||||
// Réinitialiser les préférences
|
||||
resetPreferences() {
|
||||
this.readingMode = 'single';
|
||||
this.readingDirection = 'ltr';
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 20) {
|
||||
async getChapterPages(chapterId, page = 1, itemsPerPage = 9999) {
|
||||
const response = await fetch(
|
||||
`/api/reader/chapter/${chapterId}/pages?page=${page}&itemsPerPage=${itemsPerPage}`
|
||||
);
|
||||
@@ -18,12 +18,4 @@ export class ApiChapterRepository extends ChapterRepositoryInterface {
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getChapterPage(chapterId, pageNumber) {
|
||||
const response = await fetch(`/api/reader/chapter/${chapterId}/page/${pageNumber}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch chapter page');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@
|
||||
</div>
|
||||
|
||||
<div v-for="(page, index) in pages" :key="index" class="page-wrapper">
|
||||
<div v-if="page?.loading" class="loading">
|
||||
<div v-if="!page?.url" class="loading">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
<div v-else-if="page?.error" class="error">
|
||||
{{ page.error }}
|
||||
</div>
|
||||
<ReaderPage v-else-if="page?.base64Content" :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" />
|
||||
<ReaderPage v-else :page-data="page" :page-number="index + 1" :zoom="zoom" :double-page-mode="doublePageMode" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation en bas -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page-container" :style="{ transform: `scale(${zoom})` }">
|
||||
<div v-if="!pageData" class="error">Aucune donnée d'image disponible</div>
|
||||
<div v-else-if="!pageData.base64Content" class="error">Contenu de l'image manquant</div>
|
||||
<div v-else-if="!pageData.url" class="error">URL de l'image manquante</div>
|
||||
|
||||
<!-- Affichage spécial pour les doubles pages sur mobile -->
|
||||
<div v-else-if="isDoublePage && isMobile && doublePageMode !== 'normal'" class="double-page-mobile">
|
||||
@@ -88,10 +88,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
const imageLoaded = ref(false);
|
||||
|
||||
const imageSource = computed(() => {
|
||||
if (!props.pageData?.base64Content || !props.pageData?.mimeType) {
|
||||
return '';
|
||||
}
|
||||
return `data:${props.pageData.mimeType};base64,${props.pageData.base64Content}`;
|
||||
return props.pageData?.url ?? '';
|
||||
});
|
||||
|
||||
// Détection des doubles pages basée sur le ratio largeur/hauteur et les dimensions API
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
||||
arguments:
|
||||
$storagePath: '%env(MANGA_DATA_PATH)%'
|
||||
$storagePath: '%kernel.project_dir%/public/images'
|
||||
|
||||
# Shared Manga Path/File Manager
|
||||
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Query;
|
||||
|
||||
final readonly class GetChapterPage
|
||||
{
|
||||
public function __construct(
|
||||
private string $chapterId,
|
||||
private int $pageNumber
|
||||
) {
|
||||
}
|
||||
|
||||
public function getChapterId(): string
|
||||
{
|
||||
return $this->chapterId;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class GetChapterPageHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ChapterRepositoryInterface $chapterRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(GetChapterPage $query): ChapterPageResponse
|
||||
{
|
||||
$chapterId = new ChapterId($query->getChapterId());
|
||||
$pageNumber = new PageNumber($query->getPageNumber());
|
||||
|
||||
$totalPages = $this->chapterRepository->getTotalPagesForChapter($chapterId);
|
||||
|
||||
if ($totalPages === 0) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
if ($pageNumber->getValue() > $totalPages) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$page = $this->chapterRepository->getPageContent($chapterId, $pageNumber);
|
||||
|
||||
return new ChapterPageResponse(
|
||||
$page->getId(),
|
||||
$page->getPageNumber()->getValue(),
|
||||
$page->getBase64Content(),
|
||||
$page->getMimeType(),
|
||||
$page->getDimensions()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Application\Response;
|
||||
|
||||
final readonly class ChapterPageResponse
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private int $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private array $dimensions
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): int
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return $this->dimensions;
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ namespace App\Domain\Reader\Domain\Contract\Repository;
|
||||
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
interface ChapterRepositoryInterface
|
||||
{
|
||||
@@ -24,6 +22,4 @@ interface ChapterRepositoryInterface
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getNextChapterId(ChapterId $chapterId): ?ChapterId;
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent;
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Domain\Model;
|
||||
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final readonly class PageContent
|
||||
{
|
||||
public function __construct(
|
||||
private string $id,
|
||||
private PageNumber $pageNumber,
|
||||
private string $base64Content,
|
||||
private string $mimeType,
|
||||
private int $width,
|
||||
private int $height
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPageNumber(): PageNumber
|
||||
{
|
||||
return $this->pageNumber;
|
||||
}
|
||||
|
||||
public function getBase64Content(): string
|
||||
{
|
||||
return $this->base64Content;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getDimensions(): array
|
||||
{
|
||||
return [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPageProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Reader',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/reader/chapter/{chapterId}/page/{pageNumber}',
|
||||
openapiContext: [
|
||||
'summary' => 'Récupère une page spécifique d\'un chapitre',
|
||||
'description' => 'Retourne le contenu d\'une page en base64 avec ses métadonnées',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'chapterId',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string'],
|
||||
'description' => 'L\'identifiant du chapitre'
|
||||
],
|
||||
[
|
||||
'name' => 'pageNumber',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'integer', 'minimum' => 1],
|
||||
'description' => 'Le numéro de la page à récupérer'
|
||||
],
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Page du chapitre',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string'],
|
||||
'pageNumber' => ['type' => 'integer'],
|
||||
'base64Content' => ['type' => 'string', 'description' => 'Contenu de l\'image en base64'],
|
||||
'mimeType' => ['type' => 'string', 'example' => 'image/jpeg'],
|
||||
'dimensions' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'width' => ['type' => 'integer'],
|
||||
'height' => ['type' => 'integer']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'404' => [
|
||||
'description' => 'Chapitre ou page non trouvé'
|
||||
]
|
||||
]
|
||||
],
|
||||
provider: ChapterPageProvider::class
|
||||
),
|
||||
],
|
||||
)]
|
||||
class ChapterPageResource
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\QueryHandler\GetChapterPageHandler;
|
||||
use App\Domain\Reader\Application\Response\ChapterPageResponse;
|
||||
|
||||
final readonly class ChapterPageProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetChapterPageHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ChapterPageResponse
|
||||
{
|
||||
return $this->handler->handle(
|
||||
new GetChapterPage(
|
||||
$uriVariables['chapterId'],
|
||||
(int) $uriVariables['pageNumber']
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,6 @@ use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
use App\Entity\Chapter as ChapterEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use ZipArchive;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
|
||||
readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
@@ -34,12 +31,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $this->getPagesFromDirectory($chapterId, $pagesDirectory, $page, $itemsPerPage);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->getPagesFromCbz($chapterId, $cbzPath, $page, $itemsPerPage);
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getChapterContext(ChapterId $chapterId): ChapterContext
|
||||
@@ -84,17 +76,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return count($this->getImageFiles($pagesDirectory));
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
$count = $zip->numFiles;
|
||||
$zip->close();
|
||||
|
||||
return $count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
|
||||
@@ -147,29 +129,6 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
|
||||
}
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
|
||||
'id' => $chapterId->getValue()
|
||||
]);
|
||||
|
||||
if (!$chapter) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
$pagesDirectory = $chapter->getPagesDirectory();
|
||||
if ($pagesDirectory && is_dir($pagesDirectory)) {
|
||||
return $this->getPageContentFromDirectory($chapterId, $pagesDirectory, $pageNumber);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
return $this->getPageContentFromCbz($chapterId, $cbzPath, $pageNumber);
|
||||
}
|
||||
|
||||
private function getImageFiles(string $pagesDirectory): array
|
||||
{
|
||||
$files = glob($pagesDirectory . '/*.{jpg,jpeg,png,webp,gif}', GLOB_BRACE) ?: [];
|
||||
@@ -181,17 +140,12 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
private function getPagesFromDirectory(ChapterId $chapterId, string $pagesDirectory, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$start = max(0, ($page - 1) * $itemsPerPage);
|
||||
$end = min($start + $itemsPerPage, count($files));
|
||||
$pages = [];
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$imageContent = file_get_contents($files[$i]);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
$imageSize = @getimagesize($files[$i]);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
@@ -199,7 +153,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
$pages[] = new Page(
|
||||
basename($files[$i]),
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
@@ -207,120 +161,4 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPagesFromCbz(ChapterId $chapterId, string $cbzPath, int $page, int $itemsPerPage): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
$pages = [];
|
||||
$start = ($page - 1) * $itemsPerPage;
|
||||
$end = min($start + $itemsPerPage, $zip->numFiles);
|
||||
|
||||
for ($i = $start; $i < $end; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($i);
|
||||
if ($imageContent === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pages[] = new Page(
|
||||
$stat['name'],
|
||||
new PageNumber($i + 1),
|
||||
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
private function getPageContentFromDirectory(ChapterId $chapterId, string $pagesDirectory, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$files = $this->getImageFiles($pagesDirectory);
|
||||
|
||||
if (!$files || $pageNumber->getValue() > count($files)) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$filePath = $files[$pageNumber->getValue() - 1];
|
||||
$imageContent = file_get_contents($filePath);
|
||||
|
||||
if ($imageContent === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
if ($imageSize === false) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
|
||||
return new PageContent(
|
||||
basename($filePath),
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
|
||||
private function getPageContentFromCbz(ChapterId $chapterId, string $cbzPath, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($cbzPath);
|
||||
|
||||
if ($pageNumber->getValue() > $zip->numFiles) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$index = $pageNumber->getValue() - 1;
|
||||
$stat = $zip->statIndex($index);
|
||||
|
||||
if ($stat === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageContent = $zip->getFromIndex($index);
|
||||
|
||||
if ($imageContent === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$imageSize = @getimagesizefromstring($imageContent);
|
||||
|
||||
if ($imageSize === false) {
|
||||
$zip->close();
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$mimeType = $imageSize['mime'] ?? 'image/jpeg';
|
||||
$zip->close();
|
||||
|
||||
return new PageContent(
|
||||
$stat['name'],
|
||||
$pageNumber,
|
||||
base64_encode($imageContent),
|
||||
$mimeType,
|
||||
$imageSize[0],
|
||||
$imageSize[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@ namespace App\Tests\Domain\Reader\Adapter;
|
||||
|
||||
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\Model\PageContent;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
|
||||
final class InMemoryChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
@@ -94,28 +91,4 @@ final class InMemoryChapterRepository implements ChapterRepositoryInterface
|
||||
return $nextChapter ? new ChapterId($nextChapter) : null;
|
||||
}
|
||||
|
||||
public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent
|
||||
{
|
||||
if (!isset($this->chapters[$chapterId->getValue()])) {
|
||||
throw ChapterNotFoundException::forChapter($chapterId);
|
||||
}
|
||||
|
||||
$pages = $this->chapters[$chapterId->getValue()]['pages'];
|
||||
$index = $pageNumber->getValue() - 1;
|
||||
|
||||
if (!isset($pages[$index])) {
|
||||
throw PageNotFoundException::forPage($chapterId, $pageNumber);
|
||||
}
|
||||
|
||||
$page = $pages[$index];
|
||||
|
||||
return new PageContent(
|
||||
$page->getId(),
|
||||
$page->getPageNumber(),
|
||||
base64_encode('fake-image-content'),
|
||||
'image/jpeg',
|
||||
800,
|
||||
600
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Reader\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Reader\Application\Query\GetChapterPage;
|
||||
use App\Domain\Reader\Application\QueryHandler\GetChapterPageHandler;
|
||||
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Reader\Domain\Exception\PageNotFoundException;
|
||||
use App\Domain\Reader\Domain\Model\ChapterContext;
|
||||
use App\Domain\Reader\Domain\Model\Page;
|
||||
use App\Domain\Reader\Domain\ValueObject\ChapterId;
|
||||
use App\Domain\Reader\Domain\ValueObject\PageNumber;
|
||||
use App\Tests\Domain\Reader\Adapter\InMemoryChapterRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetChapterPageHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryChapterRepository $repository;
|
||||
private GetChapterPageHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryChapterRepository();
|
||||
$this->handler = new GetChapterPageHandler($this->repository);
|
||||
|
||||
// Préparation des données de test
|
||||
$chapterId = new ChapterId('chapter-1');
|
||||
$context = new ChapterContext(
|
||||
$chapterId,
|
||||
null,
|
||||
null,
|
||||
'Test Manga',
|
||||
1.0,
|
||||
'Chapter 1',
|
||||
'path/to/cbz',
|
||||
1,
|
||||
10,
|
||||
true,
|
||||
new \DateTimeImmutable()
|
||||
);
|
||||
|
||||
$pages = [];
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$pages[] = new Page(
|
||||
sprintf('page-%d', $i),
|
||||
new PageNumber($i),
|
||||
sprintf('/api/chapters/chapter-1/pages/%d', $i),
|
||||
800,
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->addChapter($chapterId, $context, $pages);
|
||||
}
|
||||
|
||||
public function testItThrowsExceptionWhenChapterDoesNotExist(): void
|
||||
{
|
||||
$this->expectException(ChapterNotFoundException::class);
|
||||
$this->handler->handle(new GetChapterPage('invalid-id', 1));
|
||||
}
|
||||
|
||||
public function testItThrowsExceptionWhenPageNumberExceedsTotalPages(): void
|
||||
{
|
||||
$this->expectException(PageNotFoundException::class);
|
||||
$this->handler->handle(new GetChapterPage('chapter-1', 11));
|
||||
}
|
||||
|
||||
public function testItReturnsPageContentSuccessfully(): void
|
||||
{
|
||||
$response = $this->handler->handle(new GetChapterPage('chapter-1', 5));
|
||||
|
||||
$this->assertEquals('page-5', $response->getId());
|
||||
$this->assertEquals(5, $response->getPageNumber());
|
||||
$this->assertNotEmpty($response->getBase64Content());
|
||||
$this->assertEquals('image/jpeg', $response->getMimeType());
|
||||
$this->assertEquals(['width' => 800, 'height' => 600], $response->getDimensions());
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Feature\Reader;
|
||||
|
||||
use App\Factory\ChapterFactory;
|
||||
use App\Factory\MangaFactory;
|
||||
use App\Tests\Feature\AbstractApiTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
|
||||
final class GetChapterPageTest extends AbstractApiTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
|
||||
private int $chapterId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Création d'un manga et d'un chapitre avec les factories
|
||||
$manga = MangaFactory::createOne([
|
||||
'title' => 'Test Manga',
|
||||
'slug' => 'test-manga'
|
||||
]);
|
||||
|
||||
$chapter = ChapterFactory::createOne([
|
||||
'manga' => $manga,
|
||||
'title' => 'Chapter 1',
|
||||
'number' => 1.0,
|
||||
'volume' => 1,
|
||||
'visible' => true,
|
||||
'cbzPath' => __DIR__ . '/../../Fixtures/chapter.cbz'
|
||||
]);
|
||||
|
||||
$this->chapterId = $chapter->getId();
|
||||
}
|
||||
|
||||
public function testItReturnsNotFoundWhenChapterDoesNotExist(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', '/api/reader/chapter/999/page/1');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
$this->assertJsonContains([
|
||||
'detail' => 'Le chapitre 999 n\'existe pas'
|
||||
]);
|
||||
}
|
||||
|
||||
public function testItReturnsNotFoundWhenPageDoesNotExist(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', sprintf('/api/reader/chapter/%d/page/999', $this->chapterId));
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
$this->assertJsonContains([
|
||||
'detail' => sprintf('La page 999 du chapitre %d n\'existe pas', $this->chapterId)
|
||||
]);
|
||||
}
|
||||
|
||||
public function testItReturnsPageContentSuccessfully(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', sprintf('/api/reader/chapter/%d/page/1', $this->chapterId));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
|
||||
|
||||
// $this->assertJsonContains([
|
||||
// 'id' => '01.jpg',
|
||||
// 'pageNumber' => 1,
|
||||
// 'mimeType' => 'image/jpeg',
|
||||
// 'dimensions' => [
|
||||
// 'hydra:member' => [
|
||||
// 800,
|
||||
// 1169
|
||||
// ]
|
||||
// ]
|
||||
// ]);
|
||||
|
||||
$content = $response->toArray();
|
||||
$this->assertArrayHasKey('base64Content', $content);
|
||||
$this->assertNotEmpty($content['base64Content']);
|
||||
$this->assertTrue(base64_decode($content['base64Content'], true) !== false);
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,27 @@ use App\Factory\MangaFactory;
|
||||
use App\Tests\Feature\AbstractApiTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
use ZipArchive;
|
||||
|
||||
final class GetChapterPagesTest extends AbstractApiTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
|
||||
private int $chapterId;
|
||||
private string $pagesDirectory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Création d'un manga et d'un chapitre avec les factories
|
||||
// Extraire quelques images du CBZ dans un dossier temporaire
|
||||
$this->pagesDirectory = sys_get_temp_dir() . '/mangarr-test-pages-' . uniqid();
|
||||
mkdir($this->pagesDirectory);
|
||||
$zip = new ZipArchive();
|
||||
$zip->open(__DIR__ . '/../../Fixtures/chapter.cbz');
|
||||
$zip->extractTo($this->pagesDirectory, ['007.jpg', '008.jpg']);
|
||||
$zip->close();
|
||||
|
||||
$manga = MangaFactory::createOne([
|
||||
'title' => 'Test Manga',
|
||||
'slug' => 'test-manga'
|
||||
@@ -32,12 +41,22 @@ final class GetChapterPagesTest extends AbstractApiTestCase
|
||||
'number' => 1.0,
|
||||
'volume' => 1,
|
||||
'visible' => true,
|
||||
'cbzPath' => __DIR__ . '/../../Fixtures/chapter.cbz'
|
||||
'pagesDirectory' => $this->pagesDirectory
|
||||
]);
|
||||
|
||||
$this->chapterId = $chapter->getId();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
foreach (glob($this->pagesDirectory . '/*') as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->pagesDirectory);
|
||||
}
|
||||
|
||||
public function testItReturnsNotFoundWhenChapterDoesNotExist(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', '/api/reader/chapter/999/pages');
|
||||
|
||||
Reference in New Issue
Block a user