refactor(scraping): job PENDING dès le POST HTTP, handler sans Doctrine

- ScrapingJob: mangaId/chapterNumber/sourceId optionnels (nullable) pour
  permettre la création en PENDING sans lookup DB dans le StateProcessor
- ScrapeChapter: ajoute jobId (pré-généré par le StateProcessor)
- ScrapeChapterStateProcessor: crée et persiste le job PENDING avant
  dispatch; injecte JobRepositoryInterface uniquement
- ScrapeChapterHandler: supprime EntityManagerInterface, beginTransaction/
  commit/rollback; charge le job existant via jobId, complete() sur succès
  seulement, fail() si toutes les sources échouent
- ScrapeChapterHandlerTest: pré-crée le job, passe jobId dans la commande,
  supprime le mock EntityManagerInterface
- ScrapeChapterTest: accès aux messages via static InMemoryMessageBus,
  vérifie la présence du jobId dans la commande dispatchée
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-17 15:33:20 +01:00
parent ec4a8be934
commit 84c4557abf
10 changed files with 252 additions and 356 deletions

View File

@@ -1,169 +1,153 @@
<template>
<div class="overflow-y-auto h-full">
<Toolbar :config="toolbarConfig" class="mb-6" />
<div class="flex flex-col h-full">
<Toolbar :config="toolbarConfig" />
<div v-if="activityStore.loading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
</div>
<div class="overflow-y-auto flex-1">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<div class="animate-spin h-10 w-10 border-b-2 border-indigo-500 rounded-full"></div>
</div>
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
<p>{{ activityStore.error }}</p>
</div>
<!-- Error -->
<div v-else-if="activityStore.error" class="px-6 py-8">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
</div>
</div>
<div v-else class="container mx-auto p-2">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div class="overflow-x-auto">
<table class="min-w-full bg-white dark:bg-gray-800">
<!-- Content -->
<section v-else class="border-t border-gray-200 dark:border-gray-700">
<!-- Empty -->
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
<ClockIcon class="w-12 h-12 mb-3" />
<p class="text-base">Aucun job en cours ou en attente.</p>
</div>
<!-- Table -->
<div v-else class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
<th class="w-1/12 py-3 px-4 text-left">
<input
type="checkbox"
class="form-checkbox h-5 w-5 text-green-600"
@change="toggleSelectAll" />
</th>
<th class="w-2/12 py-3 px-4 text-left">Type</th>
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
<th class="w-2/11 py-3 px-6 text-left">Type</th>
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody class="text-gray-700 dark:text-gray-300">
<template v-if="activityStore.jobs.length === 0">
<tr>
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
<div class="flex flex-col items-center">
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
</div>
</td>
</tr>
</template>
<template v-else>
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</template>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination
v-if="activityStore.total > activityStore.limit"
v-if="total > activityStore.limit"
:current-page="activityStore.currentPage"
:total-pages="activityStore.totalPages"
:total="activityStore.total"
:total="total"
:limit="activityStore.limit"
:has-next-page="activityStore.hasNextPage"
:has-previous-page="activityStore.hasPreviousPage"
@page-change="changePage" />
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { computed, onMounted, ref } from 'vue';
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, onUnmounted } from 'vue';
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useActivityStore } from '../../application/store/activityStore';
import JobItem from '../components/JobItem.vue';
const activityStore = useActivityStore();
const selectedAll = ref(false);
const activityStore = useActivityStore();
// Statuts disponibles pour le filtre
const statusOptions = [
{ value: ['pending', 'in_progress'], label: 'Actifs' },
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
{ value: ['completed'], label: 'Terminés' },
{ value: ['failed'], label: 'En erreur' },
{ value: ['pending'], label: 'En attente' },
{ value: ['in_progress'], label: 'En cours' }
];
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
// Index du statut actif (par défaut "Actifs")
const activeStatusIndex = ref(0);
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
// Configuration de la toolbar réactive
const toolbarConfig = computed(() => ({
leftSection: [
{
icon: FunnelIcon,
type: 'dropdown',
label: statusOptions[activeStatusIndex.value].label,
active: false,
items: statusOptions.map((option, index) => ({
label: option.label,
isSelected: index === activeStatusIndex.value,
onClick: () => setStatusFilter(index)
}))
}
],
rightSection: [
{
icon: ArrowPathIcon,
type: 'button',
label: 'Rafraîchir',
onClick: refreshJobs
},
{
icon: TrashIcon,
type: 'button',
label: 'Supprimer visibles',
onClick: deleteVisibleJobs
}
]
}));
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
],
rightSection: [
{
type: 'dropdown',
icon: BarsArrowDownIcon,
label: 'Trier',
items: [
{
label: 'Plus récent',
isSelected: isSortSelected('createdAt', 'DESC'),
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
},
{
label: 'Plus ancien',
isSelected: isSortSelected('createdAt', 'ASC'),
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
},
{
label: 'Par type',
isSelected: isSortSelected('type', 'ASC'),
onClick: () => activityStore.updateSort('type', 'ASC'),
},
{
label: 'Par statut',
isSelected: isSortSelected('status', 'ASC'),
onClick: () => activityStore.updateSort('status', 'ASC'),
},
],
},
{
type: 'button',
icon: ArrowPathIcon,
label: 'Rafraîchir',
disabled: loading.value,
onClick: () => activityStore.loadJobs(),
},
{
type: 'button',
icon: TrashIcon,
label: 'Supprimer visibles',
disabled: loading.value || total.value === 0,
onClick: deleteVisibleJobs,
},
],
}));
onMounted(() => {
loadJobs();
});
onMounted(() => {
activityStore.loadJobs();
activityStore.subscribeMercure();
});
function loadJobs() {
activityStore.loadJobs();
onUnmounted(() => {
activityStore.unsubscribeMercure();
});
function changePage(page) {
activityStore.goToPage(page);
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function refreshJobs() {
loadJobs();
}
function changePage(page) {
activityStore.goToPage(page);
}
function toggleSelectAll() {
selectedAll.value = !selectedAll.value;
// La logique pour sélectionner tous les jobs serait ajoutée ici
}
function setStatusFilter(index) {
if (index >= 0 && index < statusOptions.length) {
activeStatusIndex.value = index;
activityStore.updateFilter({ status: statusOptions[index].value });
}
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function deleteVisibleJobs() {
if (activityStore.jobs.length === 0) {
return;
}
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
activityStore.deleteJobs({ status: activityStore.filter.status });
}
function deleteVisibleJobs() {
if (activityStore.jobs.length === 0) return;
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
}
}
</script>