feat: ajout de la fonctionnalité de monitoring des mangas, incluant l'activation et la désactivation du suivi, la synchronisation des chapitres, et la mise à jour de l'API pour gérer ces nouvelles actions. Création de nouveaux composants Vue pour le rafraîchissement des chapitres et l'affichage des notifications. Intégration de tests unitaires pour valider le bon fonctionnement de ces fonctionnalités.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-07-22 15:57:25 +02:00
parent d9e78b5229
commit 00d63dffeb
45 changed files with 2021 additions and 264 deletions

View File

@@ -195,11 +195,37 @@ export const useMangaStore = defineStore('manga', {
this.chaptersError = null;
try {
// Déclenche la récupération initiale des chapitres depuis la source externe
await mangaRepository.fetchMangaChapters(mangaId);
this.mangaChapters[mangaId] = chaptersData;
console.log('Chapitres récupérés avec succès');
console.log('Récupération initiale des chapitres déclenchée avec succès');
// Note: Les nouveaux chapitres seront disponibles après traitement asynchrone
// Le MercureListener se chargera de mettre à jour l'interface
} catch (err) {
this.chaptersError = err.message;
console.error('Erreur lors de la récupération des chapitres:', err);
throw err;
} finally {
this.loadingChapters = false;
}
},
async refreshMangaChapters(mangaId) {
if (this.loadingChapters) return;
this.loadingChapters = true;
this.chaptersError = null;
try {
// Déclenche la synchronisation incrémentale avec scraping automatique
await mangaRepository.refreshMangaChapters(mangaId);
console.log('Synchronisation incrémentale déclenchée avec succès');
// Note: Les chapitres mis à jour seront disponibles après traitement asynchrone
// Le MercureListener se chargera de mettre à jour l'interface
} catch (err) {
this.chaptersError = err.message;
console.error('Erreur lors de la synchronisation des chapitres:', err);
throw err;
} finally {
this.loadingChapters = false;
}

View File

@@ -142,6 +142,26 @@ export class ApiMangaRepository {
}
}
async refreshMangaChapters(mangaId) {
try {
const response = await fetch(`/api/manga/${mangaId}/chapters/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error('Failed to refresh manga chapters');
}
// L'endpoint retourne 202 (Accepted), pas de contenu JSON à parser
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
async searchChapter(chapterId) {
try {
const response = await fetch('/api/scraping/chapters', {
@@ -321,4 +341,40 @@ export class ApiMangaRepository {
throw error;
}
}
async toggleMonitoring(mangaId, enabled) {
try {
const response = await fetch(`/api/manga/${mangaId}/monitoring/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled })
});
if (!response.ok) {
// Tenter de récupérer le message d'erreur détaillé de l'API
let errorMessage = 'Failed to toggle monitoring';
try {
const errorData = await response.json();
if (errorData.detail) {
errorMessage = errorData.detail;
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.violations && errorData.violations.length > 0) {
errorMessage = errorData.violations.map(v => v.message).join(', ');
}
} catch (parseError) {
console.warn('Could not parse error response:', parseError);
}
throw new Error(errorMessage);
}
// L'endpoint retourne un statut 204 (No Content), donc pas de données à retourner
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,67 @@
import { ref } from 'vue';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
const mangaRepository = new ApiMangaRepository();
export function useMangaMonitoring() {
const { showSuccess, showError } = useNotifications();
const isToggling = ref(false);
const toggleError = ref(null);
const toggleMonitoring = async (mangaId, enabled) => {
if (isToggling.value || !mangaId) return;
isToggling.value = true;
toggleError.value = null;
try {
console.log(`${enabled ? 'Activation' : 'Désactivation'} du monitoring pour le manga ${mangaId}`);
await mangaRepository.toggleMonitoring(mangaId, enabled);
const message = enabled
? 'Monitoring activé avec succès. Vous recevrez les nouveaux chapitres automatiquement.'
: 'Monitoring désactivé avec succès. Les nouveaux chapitres ne seront plus téléchargés automatiquement.';
showSuccess(message);
console.log(`Monitoring ${enabled ? 'activé' : 'désactivé'} avec succès`);
return true;
} catch (error) {
console.error('Erreur lors du changement de monitoring:', error);
toggleError.value = error.message || 'Erreur lors du changement de monitoring';
const errorMessage = enabled
? `Erreur lors de l'activation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`
: `Erreur lors de la désactivation du monitoring: ${error.message || 'Une erreur inattendue est survenue'}`;
showError(errorMessage);
throw error;
} finally {
isToggling.value = false;
}
};
const enableMonitoring = async (mangaId) => {
return await toggleMonitoring(mangaId, true);
};
const disableMonitoring = async (mangaId) => {
return await toggleMonitoring(mangaId, false);
};
const clearError = () => {
toggleError.value = null;
};
return {
isToggling,
toggleError,
toggleMonitoring,
enableMonitoring,
disableMonitoring,
clearError
};
}

View File

@@ -0,0 +1,48 @@
import { ref } from 'vue';
import { useNotifications } from '../../../../shared/composables/useNotifications';
import { useMangaStore } from '../../application/store/mangaStore';
export function useMangaRefresh() {
const mangaStore = useMangaStore();
const { showSuccess, showError } = useNotifications();
const isRefreshing = ref(false);
const refreshError = ref(null);
const refreshMetadata = async (mangaId) => {
if (isRefreshing.value || !mangaId) return;
isRefreshing.value = true;
refreshError.value = null;
try {
console.log(`Début du refresh des métadonnées pour le manga ${mangaId}`);
// Appel à l'endpoint de refresh des chapitres
await mangaStore.refreshMangaChapters(mangaId);
showSuccess('Refresh des métadonnées lancé avec succès. Les nouveaux chapitres apparaîtront sous peu.');
console.log('Refresh des métadonnées déclenché avec succès');
return true;
} catch (error) {
console.error('Erreur lors du refresh des métadonnées:', error);
refreshError.value = error.message || 'Erreur lors du refresh des métadonnées';
showError(`Erreur lors du refresh: ${error.message || 'Une erreur inattendue est survenue'}`);
throw error;
} finally {
isRefreshing.value = false;
}
};
const clearError = () => {
refreshError.value = null;
};
return {
isRefreshing,
refreshError,
refreshMetadata,
clearError
};
}

View File

@@ -1,5 +1,8 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Notifications Toast -->
<NotificationToast />
<div v-if="errorDetails" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mx-4 mt-4">
{{ errorDetails.message || 'Une erreur est survenue lors du chargement des détails.' }}
</div>
@@ -63,6 +66,7 @@
import {
ArrowPathIcon,
BookmarkIcon,
BookmarkSlashIcon,
ChevronDoubleDownIcon,
Cog6ToothIcon,
DocumentArrowDownIcon,
@@ -75,7 +79,9 @@ import { useRoute } from 'vue-router';
import { useMangaDetails } from '../composables/useMangaDetails';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
import { useMangaRefresh } from '../composables/useMangaRefresh';
import { useMangaVolumes } from '../composables/useMangaVolumes';
import MangaEditModal from '../components/MangaEditModal.vue';
@@ -84,7 +90,8 @@ import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal
import MangaVolumeList from '../components/MangaVolumeList.vue';
import MercureListener from '../components/MercureListener.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import NotificationToast from '../../../../shared/components/ui/NotificationToast.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const route = useRoute();
@@ -99,7 +106,8 @@ import { useMangaStore } from '../../application/store/mangaStore';
data: currentManga,
isLoading: isLoadingDetails,
isFetching: isRefreshingDetails,
error: errorDetails
error: errorDetails,
refetch: refetchMangaDetails
} = useMangaDetails(mangaId);
const {
@@ -127,6 +135,19 @@ import { useMangaStore } from '../../application/store/mangaStore';
error: editError
} = useMangaEdit();
// Composable pour le refresh des métadonnées
const {
isRefreshing,
refreshMetadata
} = useMangaRefresh();
// Composable pour le monitoring
const {
isToggling: isTogglingMonitoring,
toggleMonitoring,
toggleError: monitoringError
} = useMangaMonitoring();
// Charger les chapitres dans le store quand le manga est chargé
watch(
mangaId,
@@ -164,13 +185,42 @@ import { useMangaStore } from '../../application/store/mangaStore';
}
};
// Fonction pour le refresh des métadonnées
const handleRefreshMetadata = async () => {
if (!mangaId.value) return;
try {
await refreshMetadata(mangaId.value);
} catch (error) {
// L'erreur est déjà gérée dans le composable avec les notifications
console.error('Erreur lors du refresh:', error);
}
};
// Fonction pour basculer le monitoring
const handleToggleMonitoring = async () => {
if (!mangaId.value || !currentManga.value) return;
try {
const newMonitoringState = !currentManga.value.monitored;
await toggleMonitoring(mangaId.value, newMonitoringState);
// Recharger les détails du manga pour mettre à jour l'état du monitoring
await refetchMangaDetails();
} catch (error) {
console.error('Erreur lors du changement de monitoring:', error);
}
};
const toolbarConfig = computed(() => ({
leftSection: [
{
icon: ArrowPathIcon,
label: 'Refresh metadata',
type: 'button',
onClick: () => console.log('Refresh metadata')
onClick: handleRefreshMetadata,
loading: isRefreshing.value,
disabled: isRefreshing.value
},
{
icon: PencilSquareIcon,
@@ -193,10 +243,13 @@ import { useMangaStore } from '../../application/store/mangaStore';
],
rightSection: [
{
icon: BookmarkIcon,
label: 'Monitoring',
icon: currentManga.value?.monitored ? BookmarkIcon : BookmarkSlashIcon,
label: currentManga.value?.monitored ? 'Désactiver monitoring' : 'Activer monitoring',
type: 'button',
onClick: () => console.log('Monitoring')
onClick: handleToggleMonitoring,
loading: isTogglingMonitoring.value,
disabled: isTogglingMonitoring.value,
variant: currentManga.value?.monitored ? 'active' : 'default'
},
{
icon: WrenchIcon,
@@ -220,7 +273,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
}));
const loading = computed(() => isLoadingDetails.value || isLoadingVolumes.value);
const isRefreshing = computed(() => isRefreshingDetails.value || isRefreshingVolumes.value);
const isRefreshingData = computed(() => isRefreshingDetails.value || isRefreshingVolumes.value || isRefreshing.value);
const error = computed(() => errorDetails.value || errorVolumes.value);
watch(

View File

@@ -0,0 +1,103 @@
<template>
<div class="fixed top-4 right-4 z-50 space-y-2">
<TransitionGroup
name="notification"
tag="div"
class="space-y-2"
>
<div
v-for="notification in notifications"
:key="notification.id"
:class="[
'max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden',
getNotificationClass(notification.type)
]"
>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<component :is="getIcon(notification.type)" :class="[
'h-6 w-6',
getIconClass(notification.type)
]" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-gray-900">
{{ notification.message }}
</p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button
@click="removeNotification(notification.id)"
class="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
import { useNotifications } from '../../composables/useNotifications';
const { notifications, removeNotification } = useNotifications();
const getIcon = (type) => {
const icons = {
success: CheckCircleIcon,
error: ExclamationCircleIcon,
warning: ExclamationTriangleIcon,
info: InformationCircleIcon
};
return icons[type] || InformationCircleIcon;
};
const getNotificationClass = (type) => {
const classes = {
success: 'border-l-4 border-green-400',
error: 'border-l-4 border-red-400',
warning: 'border-l-4 border-yellow-400',
info: 'border-l-4 border-blue-400'
};
return classes[type] || classes.info;
};
const getIconClass = (type) => {
const classes = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400'
};
return classes[type] || classes.info;
};
</script>
<style scoped>
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,65 @@
import { ref } from 'vue';
const notifications = ref([]);
let nextId = 1;
export function useNotifications() {
const addNotification = (message, type = 'info', duration = 4000) => {
const notification = {
id: nextId++,
message,
type, // 'success', 'error', 'warning', 'info'
duration,
timestamp: Date.now()
};
notifications.value.push(notification);
// Auto-remove après la durée spécifiée
if (duration > 0) {
setTimeout(() => {
removeNotification(notification.id);
}, duration);
}
return notification.id;
};
const removeNotification = (id) => {
const index = notifications.value.findIndex(n => n.id === id);
if (index > -1) {
notifications.value.splice(index, 1);
}
};
const clearAll = () => {
notifications.value = [];
};
const showSuccess = (message, duration = 4000) => {
return addNotification(message, 'success', duration);
};
const showError = (message, duration = 6000) => {
return addNotification(message, 'error', duration);
};
const showWarning = (message, duration = 5000) => {
return addNotification(message, 'warning', duration);
};
const showInfo = (message, duration = 4000) => {
return addNotification(message, 'info', duration);
};
return {
notifications,
addNotification,
removeNotification,
clearAll,
showSuccess,
showError,
showWarning,
showInfo
};
}

View File

@@ -26,10 +26,12 @@ framework:
# Commands
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
# Events
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
# Legacy messages (à garder si nécessaire)
'App\Message\DownloadChapter': commands

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250716105928 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE manga ADD last_monitoring_check TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE manga DROP last_monitoring_check');
}
}

View File

@@ -1105,9 +1105,7 @@
"properties": {
"mangaId": {
"type": "string",
"format": "uuid",
"description": "L'identifiant unique du manga",
"example": "123e4567-e89b-12d3-a456-426614174000"
"description": "L'identifiant unique du manga"
}
},
"required": [
@@ -1250,6 +1248,138 @@
},
"parameters": []
},
"/api/manga/{mangaId}/chapters/refresh": {
"post": {
"operationId": "api_manga_mangaIdchaptersrefresh_post",
"tags": [
"MangaRefresh"
],
"responses": {
"202": {
"description": "Demande de refresh accept\u00e9e et mise en file d'attente"
},
"404": {
"description": "Manga non trouv\u00e9"
}
},
"summary": "Rafra\u00eechir les chapitres d'un manga",
"description": "Lance la synchronisation incr\u00e9mentale avec scraping automatique des nouveaux chapitres",
"parameters": [
{
"name": "mangaId",
"in": "path",
"description": "L'identifiant unique du manga",
"required": true,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "simple",
"explode": false,
"allowReserved": false
}
],
"requestBody": {
"description": "The new MangaRefresh resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh.jsonhal"
}
},
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh"
}
},
"application/x-cbz": {
"schema": {
"$ref": "#/components/schemas/MangaRefresh"
}
}
},
"required": true
},
"deprecated": false
},
"parameters": []
},
"/api/manga/{mangaId}/monitoring/toggle": {
"post": {
"operationId": "api_manga_mangaIdmonitoringtoggle_post",
"tags": [
"MangaMonitoring"
],
"responses": {
"204": {
"description": "Monitoring modifi\u00e9 avec succ\u00e8s"
},
"404": {
"description": "Manga non trouv\u00e9"
},
"422": {
"description": "Donn\u00e9es de validation invalides"
}
},
"summary": "Activer/D\u00e9sactiver le monitoring d'un manga",
"description": "Active ou d\u00e9sactive le monitoring automatique pour recevoir les nouveaux chapitres",
"parameters": [
{
"name": "mangaId",
"in": "path",
"description": "L'identifiant unique du manga",
"required": true,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "simple",
"explode": false,
"allowReserved": false
}
],
"requestBody": {
"description": "\u00c9tat du monitoring \u00e0 appliquer",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "True pour activer le monitoring, false pour le d\u00e9sactiver",
"example": true
}
},
"required": [
"enabled"
]
}
}
},
"required": true
},
"deprecated": false
},
"parameters": []
},
"/api/mangadex-search": {
"get": {
"operationId": "api_mangadex-search_get",
@@ -4223,6 +4353,9 @@
"number",
"null"
]
},
"monitored": {
"type": "boolean"
}
}
},
@@ -4301,6 +4434,9 @@
"number",
"null"
]
},
"monitored": {
"type": "boolean"
}
}
},
@@ -4400,6 +4536,9 @@
"number",
"null"
]
},
"monitored": {
"type": "boolean"
}
}
},
@@ -4893,6 +5032,156 @@
}
}
},
"MangaMonitoring": {
"type": "object",
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
"deprecated": false,
"required": [
"enabled"
],
"properties": {
"enabled": {}
}
},
"MangaMonitoring.jsonhal": {
"type": "object",
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
"deprecated": false,
"required": [
"enabled"
],
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"enabled": {}
}
},
"MangaMonitoring.jsonld": {
"type": "object",
"description": "Active ou d\u00e9sactive le monitoring automatique d'un manga",
"deprecated": false,
"required": [
"enabled"
],
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"enabled": {}
}
},
"MangaRefresh": {
"type": "object",
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
"deprecated": false
},
"MangaRefresh.jsonhal": {
"type": "object",
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
"deprecated": false,
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
}
}
},
"MangaRefresh.jsonld": {
"type": "object",
"description": "D\u00e9clenche la synchronisation et le scraping des nouveaux chapitres d'un manga",
"deprecated": false,
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
}
}
},
"MangaSearchItem": {
"type": "object",
"description": "",

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
use DateTimeImmutable;
readonly class CheckMonitoredMangas
{
public function __construct(
public ?DateTimeImmutable $since = null
) {}
}

View File

@@ -2,9 +2,11 @@
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class FetchMangaChapters
{
public function __construct(
public string $mangaId
public MangaId $mangaId
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class RefreshMangaChapters
{
public function __construct(
public MangaId $mangaId
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Manga\Application\Command;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class ToggleMangaMonitoring
{
public function __construct(
public MangaId $mangaId,
public bool $enabled
) {}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class CheckMonitoredMangasHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private MessageBusInterface $commandBus
) {}
public function handle(CheckMonitoredMangas $command): void
{
$criteria = new MonitoringCriteria(
enabled: true,
lastCheckBefore: $command->since ?? new DateTimeImmutable('-1 hour')
);
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);
foreach ($monitoredMangas as $manga) {
$this->commandBus->dispatch(new RefreshMangaChapters($manga->getId()));
}
}
}

View File

@@ -3,249 +3,25 @@
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use Ramsey\Uuid\Uuid;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
readonly class FetchMangaChaptersHandler
{
public function __construct(
private MangadexClientInterface $mangadexClient,
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
) {}
public function handle(FetchMangaChapters $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId);
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
if ($manga->getExternalId() === null) {
throw new \RuntimeException('Manga has no external ID');
}
$externalId = $manga->getExternalId()->getValue();
$offset = 0;
$limit = 500;
$hasMore = true;
$chaptersByNumber = [];
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
$chapterNumbers = [];
while ($hasMore) {
$feed = $this->mangadexClient->getMangaFeed(
$externalId,
$offset,
$limit
);
foreach ($feed['data'] as $chapterData) {
$chapterNumber = (float) $chapterData['attributes']['chapter'];
$language = $chapterData['attributes']['translatedLanguage'];
$title = $chapterData['attributes']['title'];
// Pour les langues autres que français et anglais, on utilise un titre générique
if (!in_array($language, ['fr', 'en'])) {
$title = "Chapter {$chapterNumber}";
}
// Définir les règles de priorité des langues (fr > en > autres)
$shouldReplaceChapter = false;
if (!isset($chaptersByNumber[(string) $chapterNumber])) {
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
$shouldReplaceChapter = true;
$chapterNumbers[] = $chapterNumber;
} else if ($language === 'fr') {
// Le français est toujours prioritaire
$shouldReplaceChapter = true;
} else if ($language === 'en' && $chapterLanguages[(string) $chapterNumber] !== 'fr') {
// L'anglais est prioritaire sur les autres langues, sauf le français
$shouldReplaceChapter = true;
}
if ($shouldReplaceChapter) {
$chaptersByNumber[(string) $chapterNumber] = new Chapter(
new ChapterId((string) Uuid::uuid4()),
$manga->getId()->getValue(),
$chapterNumber,
$title,
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
true,
false,
new \DateTimeImmutable()
);
$chapterLanguages[(string) $chapterNumber] = $language;
}
}
$offset += $limit;
$hasMore = count($feed['data']) === $limit;
}
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
$this->harmonizeVolumes($chaptersByNumber);
// Récupère les chapitres existants
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
$manga->getId()->getValue(),
$chapterNumbers
);
// Sauvegarde uniquement les nouveaux chapitres
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) {
$this->mangaRepository->saveChapter($chapter);
}
}
}
/**
* Harmonise les volumes des chapitres:
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
* - Remplit les "trous" de volumes manquants dans une séquence
*/
private function harmonizeVolumes(array &$chaptersByNumber): void
{
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
uksort($chaptersByNumber, fn($a, $b) => (float)$a <=> (float)$b);
$chapterNumbers = array_keys($chaptersByNumber);
$count = count($chapterNumbers);
// Première passe : harmonisation locale (chapitres adjacents)
for ($i = 1; $i < $count - 1; $i++) {
$prevChapterNum = $chapterNumbers[$i - 1];
$currentChapterNum = $chapterNumbers[$i];
$nextChapterNum = $chapterNumbers[$i + 1];
$prevChapter = $chaptersByNumber[$prevChapterNum];
$currentChapter = $chaptersByNumber[$currentChapterNum];
$nextChapter = $chaptersByNumber[$nextChapterNum];
$shouldUpdateVolume = false;
$newVolume = $currentChapter->getVolume();
// Si les chapitres adjacents ont un volume null, alors le chapitre actuel aussi
if ($prevChapter->getVolume() === null && $nextChapter->getVolume() === null && $currentChapter->getVolume() !== null) {
$shouldUpdateVolume = true;
$newVolume = null;
}
// Si les chapitres adjacents ont le même volume non-null, alors le chapitre actuel aura ce volume
else if ($prevChapter->getVolume() !== null && $prevChapter->getVolume() === $nextChapter->getVolume() && $currentChapter->getVolume() !== $prevChapter->getVolume()) {
$shouldUpdateVolume = true;
$newVolume = $prevChapter->getVolume();
}
if ($shouldUpdateVolume) {
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $newVolume);
}
}
// Deuxième passe : remplissage des trous de volumes
$this->fillVolumeGaps($chaptersByNumber);
}
/**
* Remplit les "trous" de volumes dans une séquence de chapitres.
* Par exemple, si on a : Ch.317(Vol.34), Ch.318(Vol.34), Ch.319(null), Ch.320(null), Ch.321(Vol.34)
* Alors Ch.319 et Ch.320 seront assignés au Vol.34
*/
private function fillVolumeGaps(array &$chaptersByNumber): void
{
$chapterNumbers = array_keys($chaptersByNumber);
$count = count($chapterNumbers);
for ($i = 0; $i < $count; $i++) {
$currentChapterNum = $chapterNumbers[$i];
$currentChapter = $chaptersByNumber[$currentChapterNum];
// Si le chapitre actuel n'a pas de volume, on cherche à le combler
if ($currentChapter->getVolume() === null) {
$volumeToAssign = $this->findVolumeForGap($chaptersByNumber, $chapterNumbers, $i);
if ($volumeToAssign !== null) {
$chaptersByNumber[$currentChapterNum] = $this->createChapterWithNewVolume($currentChapter, $volumeToAssign);
}
}
}
}
/**
* Trouve le volume à assigner pour un chapitre sans volume en analysant son contexte
*/
private function findVolumeForGap(array $chaptersByNumber, array $chapterNumbers, int $currentIndex): ?int
{
$count = count($chapterNumbers);
// Cherche le volume précédent non-null
$prevVolume = null;
for ($i = $currentIndex - 1; $i >= 0; $i--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($prevChapter->getVolume() !== null) {
$prevVolume = $prevChapter->getVolume();
break;
}
}
// Cherche le volume suivant non-null
$nextVolume = null;
for ($i = $currentIndex + 1; $i < $count; $i++) {
$nextChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($nextChapter->getVolume() !== null) {
$nextVolume = $nextChapter->getVolume();
break;
}
}
// Si les volumes précédent et suivant sont identiques et non-null, on utilise ce volume
if ($prevVolume !== null && $prevVolume === $nextVolume) {
return $prevVolume;
}
// Si on a seulement un volume précédent, on vérifie s'il est raisonnable de l'utiliser
// (pas plus de 10 chapitres d'écart pour éviter les erreurs)
if ($prevVolume !== null && $nextVolume === null) {
$currentChapterNumber = (float) $chapterNumbers[$currentIndex];
// Trouve le numéro du chapitre qui a ce volume précédent
for ($i = $currentIndex - 1; $i >= 0; $i--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$i]];
if ($prevChapter->getVolume() === $prevVolume) {
$prevChapterNumber = (float) $chapterNumbers[$i];
// Si l'écart est raisonnable (moins de 10 chapitres), on assigne le volume
if ($currentChapterNumber - $prevChapterNumber <= 10) {
return $prevVolume;
}
break;
}
}
}
return null;
}
/**
* Crée un nouveau chapitre avec un volume différent (les chapitres sont immuables)
*/
private function createChapterWithNewVolume(Chapter $chapter, ?int $newVolume): Chapter
{
return new Chapter(
id: new ChapterId($chapter->getId()),
mangaId: $chapter->getMangaId(),
number: $chapter->getNumber(),
title: $chapter->getTitle(),
volume: $newVolume,
isVisible: $chapter->isVisible(),
cbzPath: $chapter->getCbzPath(),
createdAt: $chapter->getCreatedAt()
);
// Synchronisation initiale (pas d'événements)
$this->chapterSynchronizationService->synchronizeChapters($manga);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class RefreshMangaChaptersHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
private MessageBusInterface $eventBus
) {}
public function handle(RefreshMangaChapters $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
// Synchronisation + récupération des nouveaux IDs
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Mise à jour de la date de monitoring
$manga->updateLastMonitoringCheck(new DateTimeImmutable());
$this->mangaRepository->save($manga);
// Événement de scraping pour chaque nouveau chapitre
foreach ($newChapterIds as $chapterId) {
$this->eventBus->dispatch(
new ChapterReadyForScraping(new ChapterId($chapterId))
);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
readonly class ToggleMangaMonitoringHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
) {}
public function handle(ToggleMangaMonitoring $command): void
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if (!$manga) {
throw new MangaNotFoundException($command->mangaId->getValue());
}
if ($command->enabled) {
$manga->enableMonitoring();
} else {
$manga->disableMonitoring();
}
$this->mangaRepository->save($manga);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Manga\Application\Query;
use DateTimeImmutable;
readonly class MonitoringCriteria
{
public function __construct(
public bool $enabled,
public ?DateTimeImmutable $lastCheckBefore = null
) {}
}

View File

@@ -34,7 +34,8 @@ readonly class GetMangaByIdHandler
externalId: $manga->getExternalId()?->getValue(),
imageUrl: $manga->getImageUrl(),
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
rating: $manga->getRating()
rating: $manga->getRating(),
monitored: $manga->isMonitoringEnabled()
);
}
}

View File

@@ -17,6 +17,7 @@ readonly class MangaResponse
public ?string $externalId,
public ?string $imageUrl,
public ?string $thumbnailUrl,
public ?float $rating
public ?float $rating,
public bool $monitored
) {}
}

View File

@@ -2,9 +2,11 @@
namespace App\Domain\Manga\Domain\Contract\Repository;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
interface MangaRepositoryInterface
@@ -17,7 +19,7 @@ interface MangaRepositoryInterface
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
public function countChapters(string $mangaId): int;
public function findByExternalId(ExternalId $externalId): ?Manga;
public function saveChapter(Chapter $chapter): void;
public function saveChapter(Chapter $chapter): ChapterId;
public function findBySlug(MangaSlug $slug): ?Manga;
public function search(string $query, int $page = 1, int $limit = 20): array;
public function countSearch(string $query): int;
@@ -26,4 +28,9 @@ interface MangaRepositoryInterface
* @return array<float, Chapter>
*/
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array;
/**
* @return Manga[]
*/
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domain\Manga\Domain\Contract\Service;
use App\Domain\Manga\Domain\Model\Manga;
interface ChapterSynchronizationServiceInterface
{
/**
* Synchronise les chapitres d'un manga depuis la source externe
* @return string[] IDs des nouveaux chapitres ajoutés
*/
public function synchronizeChapters(Manga $manga): array;
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Domain\Event;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
readonly class ChapterReadyForScraping
{
public function __construct(
public ChapterId $chapterId
) {}
}

View File

@@ -7,6 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
use DateTimeImmutable;
final class Manga
@@ -26,7 +27,11 @@ final class Manga
private ?ImageUrls $imageUrls = null,
private array $alternativeSlugs = [],
private ?DateTimeImmutable $createdAt = null,
) {}
private ?MonitoringStatus $monitoringStatus = null,
private ?DateTimeImmutable $lastMonitoringCheck = null,
) {
$this->monitoringStatus = $this->monitoringStatus ?? MonitoringStatus::disabled();
}
public function getId(): MangaId
{
@@ -147,4 +152,36 @@ final class Manga
{
return $this->createdAt;
}
public function getMonitoringStatus(): MonitoringStatus
{
return $this->monitoringStatus;
}
public function isMonitoringEnabled(): bool
{
return $this->monitoringStatus->isEnabled();
}
public function enableMonitoring(): void
{
$this->monitoringStatus = MonitoringStatus::enabled();
$this->lastMonitoringCheck = new DateTimeImmutable();
}
public function disableMonitoring(): void
{
$this->monitoringStatus = MonitoringStatus::disabled();
$this->lastMonitoringCheck = null;
}
public function getLastMonitoringCheck(): ?DateTimeImmutable
{
return $this->lastMonitoringCheck;
}
public function updateLastMonitoringCheck(DateTimeImmutable $lastMonitoringCheck): void
{
$this->lastMonitoringCheck = $lastMonitoringCheck;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Manga\Domain\Model\ValueObject;
readonly class MonitoringStatus
{
public function __construct(
private bool $enabled
) {}
public static function enabled(): self
{
return new self(true);
}
public static function disabled(): self
{
return new self(false);
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function equals(self $other): bool
{
return $this->enabled === $other->enabled;
}
}

View File

@@ -20,6 +20,7 @@ readonly class MangaDetail
public ?string $externalId,
public ?string $imageUrl,
public ?string $thumbnailUrl,
public ?float $rating
public ?float $rating,
public bool $monitored
) {}
}

View File

@@ -28,9 +28,9 @@ use Symfony\Component\Validator\Constraints as Assert;
'properties' => [
'mangaId' => [
'type' => 'string',
'format' => 'uuid',
// 'format' => 'uuid',
'description' => 'L\'identifiant unique du manga',
'example' => '123e4567-e89b-12d3-a456-426614174000'
// 'example' => '123e4567-e89b-12d3-a456-426614174000'
]
],
'required' => ['mangaId']
@@ -54,7 +54,7 @@ class FetchMangaChaptersResource
{
public function __construct(
#[Assert\NotBlank(message: 'L\'identifiant du manga est obligatoire')]
#[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
// #[Assert\Uuid(message: 'L\'identifiant du manga doit être un UUID valide')]
public string $mangaId
) {}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\RefreshMangaChaptersProcessor;
#[ApiResource(
shortName: 'MangaRefresh',
operations: [
new Post(
uriTemplate: '/manga/{mangaId}/chapters/refresh',
processor: RefreshMangaChaptersProcessor::class,
status: 202,
description: 'Déclenche la synchronisation et le scraping des nouveaux chapitres d\'un manga',
openapiContext: [
'summary' => 'Rafraîchir les chapitres d\'un manga',
'description' => 'Lance la synchronisation incrémentale avec scraping automatique des nouveaux chapitres',
'parameters' => [
[
'name' => 'mangaId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'L\'identifiant unique du manga'
]
],
'responses' => [
'202' => [
'description' => 'Demande de refresh acceptée et mise en file d\'attente'
],
'404' => [
'description' => 'Manga non trouvé'
]
]
]
)
]
)]
class RefreshMangaChaptersResource
{
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\ToggleMonitoringProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'MangaMonitoring',
operations: [
new Post(
uriTemplate: '/manga/{mangaId}/monitoring/toggle',
processor: ToggleMonitoringProcessor::class,
read: false,
status: 204,
description: 'Active ou désactive le monitoring automatique d\'un manga',
openapiContext: [
'summary' => 'Activer/Désactiver le monitoring d\'un manga',
'description' => 'Active ou désactive le monitoring automatique pour recevoir les nouveaux chapitres',
'parameters' => [
[
'name' => 'mangaId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
'description' => 'L\'identifiant unique du manga'
]
],
'requestBody' => [
'description' => 'État du monitoring à appliquer',
'required' => true,
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'enabled' => [
'type' => 'boolean',
'description' => 'True pour activer le monitoring, false pour le désactiver',
'example' => true
]
],
'required' => ['enabled']
]
]
]
],
'responses' => [
'204' => [
'description' => 'Monitoring modifié avec succès'
],
'404' => [
'description' => 'Manga non trouvé'
],
'422' => [
'description' => 'Données de validation invalides'
]
]
]
)
]
)]
class ToggleMonitoringResource
{
#[Assert\NotNull(message: 'Le champ enabled est obligatoire')]
#[Assert\Type(type: 'boolean', message: 'Cette valeur doit être de type bool.')]
public mixed $enabled = null;
}

View File

@@ -5,6 +5,7 @@ namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -21,7 +22,7 @@ readonly class FetchMangaChaptersProcessor implements ProcessorInterface
}
$this->messageBus->dispatch(
new FetchMangaChapters($data->mangaId)
new FetchMangaChapters(new MangaId($data->mangaId))
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class RefreshMangaChaptersProcessor implements ProcessorInterface
{
public function __construct(
private MessageBusInterface $commandBus,
private MangaRepositoryInterface $mangaRepository
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
$mangaId = $uriVariables['mangaId'] ?? null;
if (!$mangaId) {
throw new \InvalidArgumentException('Manga ID is required');
}
// Vérifier que le manga existe
$manga = $this->mangaRepository->findById($mangaId);
if (!$manga) {
throw new MangaNotFoundException($mangaId);
}
$this->commandBus->dispatch(
new RefreshMangaChapters(new MangaId($mangaId))
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Application\CommandHandler\ToggleMangaMonitoringHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ToggleMonitoringResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class ToggleMonitoringProcessor implements ProcessorInterface
{
public function __construct(
private ToggleMangaMonitoringHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!$data instanceof ToggleMonitoringResource) {
throw new \InvalidArgumentException('Invalid resource type');
}
$mangaId = $uriVariables['mangaId'] ?? null;
if (!$mangaId) {
throw new \InvalidArgumentException('Manga ID is required');
}
// La validation Symfony s'assure que enabled est un booléen valide
if ($data->enabled === null) {
throw new \InvalidArgumentException('Enabled field is required');
}
try {
$command = new ToggleMangaMonitoring(
new MangaId($mangaId),
$data->enabled
);
$this->handler->handle($command);
} catch (MangaNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}

View File

@@ -32,7 +32,8 @@ readonly class GetMangaStateProvider implements ProviderInterface
externalId: $response->externalId,
imageUrl: $response->imageUrl,
thumbnailUrl: $response->thumbnailUrl,
rating: $response->rating
rating: $response->rating,
monitored: $response->monitored
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\CommandHandler\RefreshMangaChaptersHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyRefreshMangaChaptersHandler
{
public function __construct(
private RefreshMangaChaptersHandler $handler
) {}
public function __invoke(RefreshMangaChapters $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domain\Manga\Infrastructure\Persistence;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
@@ -9,6 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
use App\Entity\Manga as EntityManga;
use Doctrine\ORM\EntityManagerInterface;
use App\Domain\Manga\Domain\Model\Chapter;
@@ -50,7 +52,12 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
public function findById(string $id): ?DomainManga
{
$entity = $this->entityManager->find(EntityManga::class, $id);
// Convertir le string ID en integer pour la base de données
if (!is_numeric($id)) {
return null;
}
$entity = $this->entityManager->find(EntityManga::class, (int) $id);
return $entity ? $this->toDomain($entity) : null;
}
@@ -88,18 +95,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
->setStatus($manga->getStatus())
->setImageUrl($fullImageUrl ?? null)
->setThumbnailUrl($thumbnailUrl ?? null)
->setAlternativeSlugs($manga->getAlternativeSlugs());
->setAlternativeSlugs($manga->getAlternativeSlugs())
->setMonitored($manga->isMonitoringEnabled())
->setLastMonitoringCheck($manga->getLastMonitoringCheck());
// Only set externalId if it exists (to avoid setting null on update)
if ($manga->getExternalId()) {
$entity->setExternalId($manga->getExternalId()->getValue());
}
// Only set monitored for new entities
if (!$entity->getId()) {
$entity->setMonitored(false);
}
if ($manga->getRating() !== null) {
$entity->setRating($manga->getRating());
}
@@ -162,7 +166,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
return $entity ? $this->toDomain($entity) : null;
}
public function saveChapter(Chapter $chapter): void
public function saveChapter(Chapter $chapter): ChapterId
{
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
@@ -179,6 +183,8 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
$this->entityManager->persist($entity);
$this->entityManager->flush();
return new ChapterId((string) $entity->getId());
}
public function search(string $query, int $page = 1, int $limit = 20): array
@@ -241,6 +247,25 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
return $chaptersByNumber;
}
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
{
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('m')
->from(EntityManga::class, 'm')
->where('m.monitored = :enabled')
->setParameter('enabled', $criteria->enabled);
if ($criteria->lastCheckBefore) {
$queryBuilder->andWhere('(m.lastMonitoringCheck IS NULL OR m.lastMonitoringCheck < :lastCheckBefore)')
->setParameter('lastCheckBefore', $criteria->lastCheckBefore);
}
return array_map(
fn (EntityManga $entity) => $this->toDomain($entity),
$queryBuilder->getQuery()->getResult()
);
}
private function toDomain(EntityManga $entity): DomainManga
{
return new DomainManga(
@@ -258,6 +283,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
createdAt: $entity->getCreatedAt(),
monitoringStatus: $entity->isMonitored() ? MonitoringStatus::enabled() : MonitoringStatus::disabled()
);
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Manga\Infrastructure\Scheduler;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use DateTimeImmutable;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class MonitoringSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache
) {}
public function getSchedule(): Schedule
{
return (new Schedule())->add(
// Toutes les 2 heures, vérifie les mangas qui n'ont pas été vérifiés depuis 2 heures
RecurringMessage::every('2 hours', new CheckMonitoredMangas(
new DateTimeImmutable('-2 hours')
))
)->stateful($this->cache);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Domain\Manga\Infrastructure\Service;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use Ramsey\Uuid\Uuid;
readonly class MangadxChapterSynchronizationService implements ChapterSynchronizationServiceInterface
{
public function __construct(
private MangadexClientInterface $mangadxClient,
private MangaRepositoryInterface $mangaRepository
) {}
public function synchronizeChapters(Manga $manga): array
{
if ($manga->getExternalId() === null) {
throw new \RuntimeException('Manga has no external ID');
}
$externalId = $manga->getExternalId()->getValue();
$offset = 0;
$limit = 500;
$hasMore = true;
$chaptersByNumber = [];
$chapterLanguages = []; // Pour stocker la langue de chaque chapitre
$chapterNumbers = [];
while ($hasMore) {
$feed = $this->mangadxClient->getMangaFeed(
$externalId,
$offset,
$limit
);
foreach ($feed['data'] as $chapterData) {
$chapterNumber = (float) $chapterData['attributes']['chapter'];
$language = $chapterData['attributes']['translatedLanguage'];
$title = $chapterData['attributes']['title'];
// Pour les langues autres que français et anglais, on utilise un titre générique
if (!in_array($language, ['fr', 'en'])) {
$title = "Chapter {$chapterNumber}";
}
// Définir les règles de priorité des langues (fr > en > autres)
$shouldReplaceChapter = false;
if (!isset($chaptersByNumber[(string) $chapterNumber])) {
// Si c'est le premier chapitre avec ce numéro qu'on rencontre
$shouldReplaceChapter = true;
$chapterNumbers[] = $chapterNumber;
} else if ($language === 'fr') {
// Le français est toujours prioritaire
$shouldReplaceChapter = true;
} else if ($language === 'en' && $chapterLanguages[(string) $chapterNumber] !== 'fr') {
// L'anglais est prioritaire sur les autres langues, sauf le français
$shouldReplaceChapter = true;
}
if ($shouldReplaceChapter) {
$chaptersByNumber[(string) $chapterNumber] = new Chapter(
new ChapterId((string) Uuid::uuid4()),
$manga->getId()->getValue(),
$chapterNumber,
$title,
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
true,
null,
new \DateTimeImmutable()
);
$chapterLanguages[(string) $chapterNumber] = $language;
}
}
$offset += $limit;
$hasMore = count($feed['data']) === $limit;
}
// Harmonisation des volumes: si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
$this->harmonizeVolumes($chaptersByNumber);
// Récupère les chapitres existants
$existingChapters = $this->mangaRepository->findExistingChaptersByNumbers(
$manga->getId()->getValue(),
$chapterNumbers
);
$newChapterIds = [];
// Sauvegarde uniquement les nouveaux chapitres et collecte leurs IDs
foreach ($chaptersByNumber as $chapterNumber => $chapter) {
if (!isset($existingChapters[(float) $chapterNumber])) {
$newChapterId = $this->mangaRepository->saveChapter($chapter);
$newChapterIds[] = $newChapterId->getValue(); // ✨ Collecte des IDs
}
}
return $newChapterIds;
}
/**
* Harmonise les volumes des chapitres:
* - Si le chapitre précédent et suivant ont un volume null, alors le chapitre actuel aussi
* - Si le chapitre précédent et suivant ont le même volume, alors le chapitre actuel aura ce volume
* - Remplit les "trous" de volumes manquants dans une séquence
*/
private function harmonizeVolumes(array &$chaptersByNumber): void
{
// Trie les chapitres par numéro pour faciliter la recherche des adjacents
uksort($chaptersByNumber, fn($a, $b) => (float)$a <=> (float)$b);
$chapterNumbers = array_keys($chaptersByNumber);
$count = count($chapterNumbers);
// Première passe : harmonisation locale (chapitres adjacents)
for ($i = 1; $i < $count - 1; $i++) {
$prevChapterNum = $chapterNumbers[$i - 1];
$currentChapterNum = $chapterNumbers[$i];
$nextChapterNum = $chapterNumbers[$i + 1];
$prevChapter = $chaptersByNumber[$prevChapterNum];
$currentChapter = $chaptersByNumber[$currentChapterNum];
$nextChapter = $chaptersByNumber[$nextChapterNum];
$prevVolume = $prevChapter->getVolume();
$currentVolume = $currentChapter->getVolume();
$nextVolume = $nextChapter->getVolume();
// Règle 1: Si précédent et suivant sont null, alors actuel aussi
if ($prevVolume === null && $nextVolume === null && $currentVolume !== null) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
null, // volume = null
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
// Règle 2: Si précédent et suivant ont le même volume, alors actuel aussi
else if ($prevVolume !== null && $prevVolume === $nextVolume && $currentVolume !== $prevVolume) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$prevVolume, // prend le volume des adjacents
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
}
// Deuxième passe : comblement des trous de volumes
$this->fillVolumeGaps($chaptersByNumber, $chapterNumbers);
}
/**
* Remplit les "trous" de volumes manquants dans une séquence
*/
private function fillVolumeGaps(array &$chaptersByNumber, array $chapterNumbers): void
{
$count = count($chapterNumbers);
for ($i = 0; $i < $count; $i++) {
$currentChapterNum = $chapterNumbers[$i];
$currentChapter = $chaptersByNumber[$currentChapterNum];
if ($currentChapter->getVolume() !== null) {
continue; // Ce chapitre a déjà un volume
}
// Cherche le volume précédent non-null
$prevVolume = null;
for ($j = $i - 1; $j >= 0; $j--) {
$prevChapter = $chaptersByNumber[$chapterNumbers[$j]];
if ($prevChapter->getVolume() !== null) {
$prevVolume = $prevChapter->getVolume();
break;
}
}
// Cherche le volume suivant non-null
$nextVolume = null;
for ($k = $i + 1; $k < $count; $k++) {
$nextChapter = $chaptersByNumber[$chapterNumbers[$k]];
if ($nextChapter->getVolume() !== null) {
$nextVolume = $nextChapter->getVolume();
break;
}
}
// Si on a trouvé un volume précédent et que le suivant est le même ou null, alors utilise le précédent
if ($prevVolume !== null && ($nextVolume === null || $nextVolume === $prevVolume)) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$prevVolume,
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
// Si on a trouvé un volume suivant mais pas de précédent, utilise le suivant
else if ($nextVolume !== null && $prevVolume === null) {
$chaptersByNumber[$currentChapterNum] = new Chapter(
new ChapterId($currentChapter->getId()),
$currentChapter->getMangaId(),
$currentChapter->getNumber(),
$currentChapter->getTitle(),
$nextVolume,
$currentChapter->isVisible(),
$currentChapter->getCbzPath(),
$currentChapter->getCreatedAt()
);
}
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener
{
public function __construct(
private readonly ScrapeChapterHandler $scrapeChapterHandler
) {}
#[AsMessageHandler]
public function onChapterReadyForScraping(ChapterReadyForScraping $event): void
{
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
}
}

View File

@@ -59,6 +59,9 @@ class Manga
#[ORM\Column]
private ?bool $monitored = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lastMonitoringCheck = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $AlternativeSlugs = null;
@@ -318,4 +321,16 @@ class Manga
return $this;
}
public function getLastMonitoringCheck(): ?\DateTimeImmutable
{
return $this->lastMonitoringCheck;
}
public function setLastMonitoringCheck(?\DateTimeImmutable $lastMonitoringCheck): self
{
$this->lastMonitoringCheck = $lastMonitoringCheck;
return $this;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
@@ -190,4 +191,27 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
fn (Chapter $chapter) => in_array($chapter->getNumber(), $chapterNumbers)
);
}
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
{
return array_filter(
array_values($this->mangas),
function (Manga $manga) use ($criteria) {
// Vérifier si le monitoring est activé selon le critère
if ($manga->getMonitoringStatus()->isEnabled() !== $criteria->enabled) {
return false;
}
// Vérifier la date de dernière vérification si spécifiée
if ($criteria->lastCheckBefore !== null) {
$lastCheck = $manga->getLastMonitoringCheck();
if ($lastCheck === null || $lastCheck >= $criteria->lastCheckBefore) {
return false;
}
}
return true;
}
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Tests\Domain\Manga\Application\Command;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use PHPUnit\Framework\TestCase;
class ToggleMangaMonitoringTest extends TestCase
{
public function testCreateCommandWithValidData(): void
{
// Arrange & Act
$mangaId = new MangaId('manga-123');
$enabled = true;
$command = new ToggleMangaMonitoring($mangaId, $enabled);
// Assert
$this->assertEquals($mangaId, $command->mangaId);
$this->assertTrue($command->enabled);
}
public function testCreateCommandWithDisabled(): void
{
// Arrange & Act
$mangaId = new MangaId('manga-456');
$enabled = false;
$command = new ToggleMangaMonitoring($mangaId, $enabled);
// Assert
$this->assertEquals($mangaId, $command->mangaId);
$this->assertFalse($command->enabled);
}
public function testCommandIsReadonly(): void
{
// Arrange
$command = new ToggleMangaMonitoring(new MangaId('manga-123'), true);
// Act & Assert - Tenter de modifier les propriétés devrait être impossible
$reflection = new \ReflectionClass($command);
$this->assertTrue($reflection->isReadOnly());
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ToggleMangaMonitoring;
use App\Domain\Manga\Application\CommandHandler\ToggleMangaMonitoringHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase;
class ToggleMangaMonitoringHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private ToggleMangaMonitoringHandler $handler;
protected function setUp(): void
{
$this->mangaRepository = new InMemoryMangaRepository();
$this->handler = new ToggleMangaMonitoringHandler($this->mangaRepository);
}
public function testEnableMonitoringForManga(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('external-123')
);
$this->mangaRepository->save($manga);
// Act
$command = new ToggleMangaMonitoring(new MangaId($mangaId), true);
$this->handler->handle($command);
// Assert
$updatedManga = $this->mangaRepository->findById($mangaId);
$this->assertNotNull($updatedManga);
$this->assertTrue($updatedManga->getMonitoringStatus()->isEnabled());
$this->assertNotNull($updatedManga->getLastMonitoringCheck());
}
public function testDisableMonitoringForManga(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('external-123')
);
// Activer d'abord le monitoring
$manga->enableMonitoring();
$this->mangaRepository->save($manga);
// Act
$command = new ToggleMangaMonitoring(new MangaId($mangaId), false);
$this->handler->handle($command);
// Assert
$updatedManga = $this->mangaRepository->findById($mangaId);
$this->assertNotNull($updatedManga);
$this->assertFalse($updatedManga->getMonitoringStatus()->isEnabled());
}
public function testToggleMonitoringWithNonExistingManga(): void
{
// Arrange
$nonExistingMangaId = 'non-existing-manga';
// Act & Assert
$this->expectException(\App\Domain\Manga\Domain\Exception\MangaNotFoundException::class);
$this->expectExceptionMessage($nonExistingMangaId);
$command = new ToggleMangaMonitoring(new MangaId($nonExistingMangaId), true);
$this->handler->handle($command);
}
public function testEnableMonitoringWhenAlreadyEnabled(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('external-123')
);
$manga->enableMonitoring();
$firstActivationTime = $manga->getLastMonitoringCheck();
$this->mangaRepository->save($manga);
// Wait a bit to ensure time difference
sleep(1);
// Act
$command = new ToggleMangaMonitoring(new MangaId($mangaId), true);
$this->handler->handle($command);
// Assert
$updatedManga = $this->mangaRepository->findById($mangaId);
$this->assertNotNull($updatedManga);
$this->assertTrue($updatedManga->getMonitoringStatus()->isEnabled());
// Le timestamp devrait être mis à jour même si déjà activé
$this->assertGreaterThan(
$firstActivationTime->getTimestamp(),
$updatedManga->getLastMonitoringCheck()->getTimestamp()
);
}
public function testDisableMonitoringWhenAlreadyDisabled(): void
{
// Arrange
$mangaId = 'manga-123';
$manga = new Manga(
new MangaId($mangaId),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId('external-123')
);
$this->mangaRepository->save($manga);
// Act
$command = new ToggleMangaMonitoring(new MangaId($mangaId), false);
$this->handler->handle($command);
// Assert
$updatedManga = $this->mangaRepository->findById($mangaId);
$this->assertNotNull($updatedManga);
$this->assertFalse($updatedManga->getMonitoringStatus()->isEnabled());
$this->assertNull($updatedManga->getLastMonitoringCheck());
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Tests\Feature\Manga;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Feature\AbstractApiTestCase;
use Zenstruck\Foundry\Test\ResetDatabase;
class ToggleMonitoringTest extends AbstractApiTestCase
{
use ResetDatabase;
public function testEnableMonitoringForExistingManga(): void
{
// Arrange
$externalId = 'external-123';
$manga = new Manga(
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$entity = $this->toEntity($manga);
$this->entityManager->persist($entity);
$this->entityManager->flush();
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
// Act
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
'json' => [
'enabled' => true
]
]);
// Assert
$this->assertResponseStatusCodeSame(204);
// Vérifier que le statut de monitoring a été mis à jour dans la base de données
$updatedEntity = $this->entityManager->find(\App\Entity\Manga::class, $mangaId);
$this->assertNotNull($updatedEntity);
$this->assertTrue($updatedEntity->isMonitored(), 'Le manga devrait être monitoré');
}
public function testDisableMonitoringForExistingManga(): void
{
// Arrange
$externalId = 'external-123';
$manga = new Manga(
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$entity = $this->toEntity($manga);
$entity->setMonitored(true); // Initialiser à true pour tester la désactivation
$this->entityManager->persist($entity);
$this->entityManager->flush();
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
// Act
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
'json' => [
'enabled' => false
]
]);
// Assert
$this->assertResponseStatusCodeSame(204);
// Vérifier que le statut de monitoring a été mis à jour dans la base de données
$updatedEntity = $this->entityManager->find(\App\Entity\Manga::class, $mangaId);
$this->assertNotNull($updatedEntity);
$this->assertFalse($updatedEntity->isMonitored(), 'Le manga ne devrait plus être monitoré');
}
public function testToggleMonitoringWithNonExistingManga(): void
{
// Act & Assert
static::createClient()->request('POST', '/api/manga/99999/monitoring/toggle', [
'json' => [
'enabled' => true
]
]);
$this->assertResponseStatusCodeSame(404);
}
public function testToggleMonitoringWithMissingEnabledField(): void
{
// Arrange
$externalId = 'external-123';
$manga = new Manga(
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$entity = $this->toEntity($manga);
$this->entityManager->persist($entity);
$this->entityManager->flush();
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
// Act & Assert
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
'json' => []
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'enabled',
'message' => 'Le champ enabled est obligatoire'
]
]
]);
}
public function testToggleMonitoringWithInvalidEnabledValue(): void
{
// Arrange
$externalId = 'external-123';
$manga = new Manga(
new MangaId('temp-id'), // ID temporaire, sera remplacé par l'auto-généré
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$entity = $this->toEntity($manga);
$this->entityManager->persist($entity);
$this->entityManager->flush();
$mangaId = $entity->getId(); // Récupère l'ID auto-généré
// Act & Assert
static::createClient()->request('POST', "/api/manga/{$mangaId}/monitoring/toggle", [
'json' => [
'enabled' => 'invalid'
]
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'enabled',
'message' => 'Cette valeur doit être de type bool.'
]
]
]);
}
private function toEntity(Manga $manga): \App\Entity\Manga
{
$entity = new \App\Entity\Manga();
$entity->setTitle($manga->getTitle()->getValue())
->setSlug($manga->getSlug()->getValue())
->setDescription($manga->getDescription())
->setAuthor($manga->getAuthor())
->setPublicationYear($manga->getPublicationYear())
->setGenres($manga->getGenres())
->setStatus($manga->getStatus())
->setExternalId($manga->getExternalId()->getValue())
->setMonitored(false);
return $entity;
}
}