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 fa035bfbfa
10 changed files with 252 additions and 356 deletions

View File

@@ -3,11 +3,14 @@ import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository();
const ACTIVE_STATUSES = ['pending', 'in_progress'];
export const useActivityStore = defineStore('activity', {
state: () => ({
jobs: [],
loading: false,
error: null,
mercureEventSource: null,
// Pagination
currentPage: 1,
totalPages: 0,
@@ -15,21 +18,15 @@ export const useActivityStore = defineStore('activity', {
limit: 20,
hasNextPage: false,
hasPreviousPage: false,
// Filtres
filter: {
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les actifs
sortBy: 'createdAt',
sortOrder: 'DESC'
}
// Tri
sortBy: 'createdAt',
sortOrder: 'DESC',
}),
getters: {
activeJobs: state => state.jobs.filter(job => job.isActive()),
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
failedJobs: state => state.jobs.filter(job => job.hasError()),
isLoading: state => state.loading,
hasError: state => !!state.error,
// Getters pour la pagination
paginationInfo: state => ({
currentPage: state.currentPage,
totalPages: state.totalPages,
@@ -41,44 +38,25 @@ export const useActivityStore = defineStore('activity', {
},
actions: {
/**
* Charge la liste des jobs selon les filtres actuels
* @param {number} page - Numéro de page optionnel
*/
async loadJobs(page = null) {
this.loading = true;
this.error = null;
try {
const options = {
const jobCollection = await jobRepository.getJobs({
page: page || this.currentPage,
limit: this.limit,
sortBy: this.filter.sortBy,
sortOrder: this.filter.sortOrder,
status: this.filter.status
};
sortBy: this.sortBy,
sortOrder: this.sortOrder,
status: ACTIVE_STATUSES,
});
const jobCollection = await jobRepository.getJobs(options);
// Mettre à jour les données
this.jobs = jobCollection.items;
this.currentPage = jobCollection.page;
this.total = jobCollection.total;
this.hasNextPage = jobCollection.hasNextPage;
this.hasPreviousPage = jobCollection.hasPreviousPage;
// Calculer le nombre total de pages
this.totalPages = Math.ceil(this.total / this.limit);
console.log('Store updated with:', {
jobs: this.jobs.length,
currentPage: this.currentPage,
total: this.total,
limit: this.limit,
totalPages: this.totalPages,
hasNextPage: this.hasNextPage,
hasPreviousPage: this.hasPreviousPage
});
} catch (error) {
this.error = error.message;
console.error('Error loading jobs:', error);
@@ -87,10 +65,6 @@ export const useActivityStore = defineStore('activity', {
}
},
/**
* Va à une page spécifique
* @param {number} page
*/
async goToPage(page) {
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
this.currentPage = page;
@@ -98,39 +72,26 @@ export const useActivityStore = defineStore('activity', {
}
},
/**
* Met à jour les filtres et recharge la liste
* @param {Object} filter
*/
async updateFilter(filter) {
this.filter = { ...this.filter, ...filter };
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
async updateSort(sortBy, sortOrder) {
this.sortBy = sortBy;
this.sortOrder = sortOrder;
this.currentPage = 1;
await this.loadJobs(1);
},
/**
* Met à jour la limite par page
* @param {number} limit
*/
async updateLimit(limit) {
this.limit = limit;
this.currentPage = 1; // Retourner à la première page
this.currentPage = 1;
await this.loadJobs(1);
},
/**
* Supprime un job par son ID
* @param {string} id
*/
async deleteJob(id) {
this.loading = true;
this.error = null;
try {
await jobRepository.deleteJob(id);
// Supprimer le job de la liste locale
this.jobs = this.jobs.filter(job => job.id !== id);
// Recharger la page courante pour avoir les bons totaux
await this.loadJobs(this.currentPage);
} catch (error) {
this.error = error.message;
@@ -140,17 +101,37 @@ export const useActivityStore = defineStore('activity', {
}
},
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria
*/
updateJobProgress(jobId, progress) {
const job = this.jobs.find(j => j.id === jobId);
if (job) job.progress = progress;
},
subscribeMercure() {
if (this.mercureEventSource) return;
const url = new URL('/.well-known/mercure', window.location.origin);
url.searchParams.append('topic', 'jobs/activity');
this.mercureEventSource = new EventSource(url.toString());
this.mercureEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job.progress_updated') {
this.updateJobProgress(data.jobId, data.progress);
}
};
},
unsubscribeMercure() {
if (this.mercureEventSource) {
this.mercureEventSource.close();
this.mercureEventSource = null;
}
},
async deleteJobs(criteria = {}) {
this.loading = true;
this.error = null;
try {
const deleted = await jobRepository.deleteJobs(criteria);
// Recharger la liste après suppression
await this.loadJobs(this.currentPage);
return deleted;
} catch (error) {
@@ -160,26 +141,5 @@ export const useActivityStore = defineStore('activity', {
this.loading = false;
}
},
/**
* Supprime tous les jobs terminés
*/
async deleteCompletedJobs() {
return this.deleteJobs({ status: ['COMPLETED'] });
},
/**
* Supprime tous les jobs en erreur
*/
async deleteFailedJobs() {
return this.deleteJobs({ status: ['ERROR'] });
},
/**
* Supprime tous les jobs
*/
async deleteAllJobs() {
return this.deleteJobs({});
}
}
});

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>