Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Froidefond
7ce8057c6b chore(dependencies): update Next.js to version 15.5.7, NextAuth to version 4.24.13, and various other dependencies for improved performance and security 2025-12-05 11:50:41 +01:00
Julien Froidefond
5415247f47 chore(dependencies): update eslint-config-next to version 15.5.7 and various TypeScript ESLint packages to version 8.48.1 for improved compatibility and security 2025-12-05 08:23:04 +01:00
Julien Froidefond
f57ea205c7 test(JiraSync): improve test coverage for synchronization scenarios and enhance assertions for change detection 2025-11-26 08:40:48 +01:00
Julien Froidefond
4fc41a5b2c refactor(ManagerWeeklySummary): replace AchievementCard and ChallengeCard with TaskCard, implement tag filtering for accomplishments and challenges, and enhance UI for better data presentation 2025-11-26 08:40:42 +01:00
Julien Froidefond
4c0f227e27 test(JiraSync): further refine test coverage for synchronization and change detection scenarios 2025-11-21 16:47:04 +01:00
Julien Froidefond
ddba4eca37 test(JiraSync): expand test coverage for synchronization and change detection scenarios 2025-11-21 16:41:33 +01:00
Julien Froidefond
411bac8162 test(JiraSync): further enhance test coverage for synchronization and change detection logic 2025-11-21 15:14:54 +01:00
Julien Froidefond
4496cd97f9 test(JiraSync): improve test coverage for synchronization logic and change detection 2025-11-21 14:58:31 +01:00
Julien Froidefond
5c9b2b9d8f test(JiraSync): enhance test coverage for change detection logic and preserved fields 2025-11-21 14:56:16 +01:00
Julien Froidefond
a1d631037e test(JiraSync): add assertion for preserved fields in project change scenario 2025-11-21 14:16:57 +01:00
32 changed files with 7653 additions and 1164 deletions

View File

@@ -43,8 +43,8 @@
"emoji-regex": "^10.5.0",
"lucide-react": "^0.544.0",
"mermaid": "^11.12.0",
"next": "15.5.3",
"next-auth": "^4.24.11",
"next": "15.5.7",
"next-auth": "^4.24.12",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.16.1",
"react": "19.1.0",
@@ -60,24 +60,30 @@
"twemoji": "^14.0.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.17",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^15.5.3",
"eslint-config-next": "^15.5.7",
"husky": "^9.1.7",
"knip": "^5.64.0",
"knip": "^5.71.0",
"lint-staged": "^15.5.2",
"prettier": "^3.6.2",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.14",
"tailwindcss": "^4.1.17",
"tsx": "^4.19.2",
"typescript": "^5",
"vitest": "^2.1.8"
},
"pnpm": {
"overrides": {
"esbuild": ">=0.25.0",
"mdast-util-to-hast": ">=13.2.1"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"

1107
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { ManagerSummary } from '@/services/analytics/manager-summary';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Tabs, TabItem } from '@/components/ui/Tabs';
import { AchievementCard } from '@/components/ui/AchievementCard';
import { ChallengeCard } from '@/components/ui/ChallengeCard';
import { TaskCard } from '@/components/ui/TaskCard';
import { FilterChip } from '@/components/ui/FilterChip';
import { useTasksContext } from '@/contexts/TasksContext';
import { MetricsTab } from './MetricsTab';
import { format } from 'date-fns';
@@ -22,15 +22,19 @@ export default function ManagerWeeklySummary({
initialSummary,
}: ManagerWeeklySummaryProps) {
const [summary] = useState<ManagerSummary>(initialSummary);
const [activeView, setActiveView] = useState<
'narrative' | 'accomplishments' | 'challenges' | 'metrics'
>('narrative');
const [activeView, setActiveView] = useState<'narrative' | 'metrics'>(
'narrative'
);
const [selectedAccomplishmentTags, setSelectedAccomplishmentTags] = useState<
string[]
>([]);
const [selectedChallengeTags, setSelectedChallengeTags] = useState<string[]>(
[]
);
const { tags: availableTags } = useTasksContext();
const handleTabChange = (tabId: string) => {
setActiveView(
tabId as 'narrative' | 'accomplishments' | 'challenges' | 'metrics'
);
setActiveView(tabId as 'narrative' | 'metrics');
};
const handleRefresh = () => {
@@ -42,21 +46,99 @@ export default function ManagerWeeklySummary({
return `7 derniers jours (${format(summary.period.start, 'dd MMM', { locale: fr })} - ${format(summary.period.end, 'dd MMM yyyy', { locale: fr })})`;
};
// Calculer les compteurs pour chaque tag basés uniquement sur les accomplishments
const accomplishmentTagCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableTags.forEach((tag) => {
counts[tag.name] = summary.keyAccomplishments.filter((a) =>
a.tags.includes(tag.name)
).length;
});
return counts;
}, [availableTags, summary.keyAccomplishments]);
// Calculer les compteurs pour chaque tag basés uniquement sur les challenges
const challengeTagCounts = useMemo(() => {
const counts: Record<string, number> = {};
availableTags.forEach((tag) => {
counts[tag.name] = summary.upcomingChallenges.filter((c) =>
c.tags.includes(tag.name)
).length;
});
return counts;
}, [availableTags, summary.upcomingChallenges]);
// Trier les tags pour les accomplishments par nombre d'utilisation (décroissant)
const sortedAccomplishmentTags = useMemo(() => {
return [...availableTags]
.filter((tag) => (accomplishmentTagCounts[tag.name] || 0) > 0)
.sort((a, b) => {
const countA = accomplishmentTagCounts[a.name] || 0;
const countB = accomplishmentTagCounts[b.name] || 0;
return countB - countA;
});
}, [availableTags, accomplishmentTagCounts]);
// Trier les tags pour les challenges par nombre d'utilisation (décroissant)
const sortedChallengeTags = useMemo(() => {
return [...availableTags]
.filter((tag) => (challengeTagCounts[tag.name] || 0) > 0)
.sort((a, b) => {
const countA = challengeTagCounts[a.name] || 0;
const countB = challengeTagCounts[b.name] || 0;
return countB - countA;
});
}, [availableTags, challengeTagCounts]);
// Filtrer les accomplishments selon les tags sélectionnés
const filteredAccomplishments = useMemo(() => {
if (selectedAccomplishmentTags.length === 0) {
return summary.keyAccomplishments;
}
return summary.keyAccomplishments.filter((accomplishment) =>
selectedAccomplishmentTags.some((tag) =>
accomplishment.tags.includes(tag)
)
);
}, [summary.keyAccomplishments, selectedAccomplishmentTags]);
// Filtrer les challenges selon les tags sélectionnés
const filteredChallenges = useMemo(() => {
if (selectedChallengeTags.length === 0) {
return summary.upcomingChallenges;
}
return summary.upcomingChallenges.filter((challenge) =>
selectedChallengeTags.some((tag) => challenge.tags.includes(tag))
);
}, [summary.upcomingChallenges, selectedChallengeTags]);
const handleAccomplishmentTagToggle = (tagName: string) => {
setSelectedAccomplishmentTags((prev) =>
prev.includes(tagName)
? prev.filter((t) => t !== tagName)
: [...prev, tagName]
);
};
const handleChallengeTagToggle = (tagName: string) => {
setSelectedChallengeTags((prev) =>
prev.includes(tagName)
? prev.filter((t) => t !== tagName)
: [...prev, tagName]
);
};
const handleClearAccomplishmentFilters = () => {
setSelectedAccomplishmentTags([]);
};
const handleClearChallengeFilters = () => {
setSelectedChallengeTags([]);
};
// Configuration des onglets
const tabItems: TabItem[] = [
{ id: 'narrative', label: 'Vue Executive', icon: '📝' },
{
id: 'accomplishments',
label: 'Accomplissements',
icon: '✅',
count: summary.keyAccomplishments.length,
},
{
id: 'challenges',
label: 'Enjeux à venir',
icon: '🎯',
count: summary.upcomingChallenges.length,
},
{ id: 'metrics', label: 'Métriques', icon: '📊' },
];
@@ -197,7 +279,7 @@ export default function ManagerWeeklySummary({
</Card>
</div>
{/* Top accomplissements */}
{/* Accomplissements détaillés */}
<Card variant="elevated">
<CardHeader
className="pb-4"
@@ -211,34 +293,98 @@ export default function ManagerWeeklySummary({
className="text-lg font-semibold flex items-center gap-2"
style={{ color: 'var(--success)' }}
>
<Emoji emoji="🏆" /> Top accomplissements
<Emoji emoji="🏆" /> Accomplissements
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{filteredAccomplishments.length} accomplissement
{filteredAccomplishments.length > 1 ? 's' : ''} affiché
{selectedAccomplishmentTags.length > 0 && (
<span className="ml-2">
(sur {summary.keyAccomplishments.length} total)
</span>
)}
{selectedAccomplishmentTags.length === 0 && (
<>
{' '}
{summary.metrics.totalTasksCompleted} tâches {' '}
{summary.metrics.totalCheckboxesCompleted} todos complétés
</>
)}
</p>
</CardHeader>
{/* Filtres par tags pour les accomplishments */}
{sortedAccomplishmentTags.length > 0 && (
<CardContent className="pb-4 border-b border-[var(--border)]">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold flex items-center gap-2 text-[var(--muted-foreground)]">
<Emoji emoji="🏷️" /> Filtres
</h3>
{selectedAccomplishmentTags.length > 0 && (
<Button
onClick={handleClearAccomplishmentFilters}
variant="ghost"
size="sm"
className="text-xs h-6"
>
Réinitialiser ({selectedAccomplishmentTags.length})
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{sortedAccomplishmentTags.map((tag) => (
<FilterChip
key={tag.id}
onClick={() => handleAccomplishmentTagToggle(tag.name)}
variant={
selectedAccomplishmentTags.includes(tag.name)
? 'selected'
: 'tag'
}
color={tag.color}
count={accomplishmentTagCounts[tag.name]}
>
{tag.name}
</FilterChip>
))}
</div>
</CardContent>
)}
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.length === 0 ? (
{filteredAccomplishments.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>
<div className="text-4xl mb-4">
<Emoji emoji="📭" />
</div>
<p className="text-lg mb-2">
Aucun accomplissement significatif trouvé cette semaine.
</p>
<p className="text-sm mt-2">
<p className="text-sm">
Ajoutez des tâches avec priorité haute/medium ou des
meetings.
</p>
</div>
) : (
summary.keyAccomplishments
.slice(0, 6)
.map((accomplishment, index) => (
<AchievementCard
filteredAccomplishments.map((accomplishment) => (
<TaskCard
key={accomplishment.id}
achievement={accomplishment}
availableTags={
availableTags as (Tag & { usage: number })[]
variant="detailed"
title={accomplishment.title}
description={accomplishment.description}
tags={accomplishment.tags}
priority={
accomplishment.impact === 'high'
? 'high'
: accomplishment.impact === 'medium'
? 'medium'
: 'low'
}
index={index}
showDescription={true}
maxTags={2}
status="done"
completedAt={accomplishment.completedAt}
todosCount={accomplishment.todosCount}
availableTags={availableTags as Tag[]}
source="manual"
style={{ opacity: 1 }}
/>
))
)}
@@ -246,7 +392,7 @@ export default function ManagerWeeklySummary({
</CardContent>
</Card>
{/* Top challenges */}
{/* Enjeux à venir détaillés */}
<Card variant="elevated">
<CardHeader
className="pb-4"
@@ -260,32 +406,104 @@ export default function ManagerWeeklySummary({
className="text-lg font-semibold flex items-center gap-2"
style={{ color: 'var(--destructive)' }}
>
<Emoji emoji="🎯" /> Top enjeux à venir
<Emoji emoji="🎯" /> Enjeux à venir
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-1">
{filteredChallenges.length} défi
{filteredChallenges.length > 1 ? 's' : ''} affiché
{selectedChallengeTags.length > 0 && (
<span className="ml-2">
(sur {summary.upcomingChallenges.length} total)
</span>
)}
{selectedChallengeTags.length === 0 && (
<>
{' '}
{' '}
{
summary.upcomingChallenges.filter(
(c) => c.priority === 'high'
).length
}{' '}
priorité haute {' '}
{
summary.upcomingChallenges.filter(
(c) => c.blockers.length > 0
).length
}{' '}
avec blockers
</>
)}
</p>
</CardHeader>
<CardContent>
{/* Filtres par tags pour les challenges */}
{sortedChallengeTags.length > 0 && (
<CardContent className="pb-4 border-b border-[var(--border)]">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold flex items-center gap-2 text-[var(--muted-foreground)]">
<Emoji emoji="🏷️" /> Filtres
</h3>
{selectedChallengeTags.length > 0 && (
<Button
onClick={handleClearChallengeFilters}
variant="ghost"
size="sm"
className="text-xs h-6"
>
Réinitialiser ({selectedChallengeTags.length})
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{sortedChallengeTags.map((tag) => (
<FilterChip
key={tag.id}
onClick={() => handleChallengeTagToggle(tag.name)}
variant={
selectedChallengeTags.includes(tag.name)
? 'selected'
: 'tag'
}
color={tag.color}
count={challengeTagCounts[tag.name]}
>
{tag.name}
</FilterChip>
))}
</div>
</CardContent>
)}
<CardContent
className={sortedChallengeTags.length > 0 ? 'pt-4' : ''}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.upcomingChallenges.length === 0 ? (
{filteredChallenges.length === 0 ? (
<div className="col-span-3 text-center py-8 text-[var(--muted-foreground)]">
<p>Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm mt-2">
<div className="text-4xl mb-4">
<Emoji emoji="🎯" />
</div>
<p className="text-lg mb-2">
Aucun enjeu prioritaire trouvé.
</p>
<p className="text-sm">
Ajoutez des tâches non complétées avec priorité
haute/medium.
</p>
</div>
) : (
summary.upcomingChallenges
.slice(0, 6)
.map((challenge, index) => (
<ChallengeCard
filteredChallenges.map((challenge) => (
<TaskCard
key={challenge.id}
challenge={challenge}
availableTags={
availableTags as (Tag & { usage: number })[]
}
index={index}
showDescription={true}
maxTags={2}
variant="detailed"
title={challenge.title}
description={challenge.description}
tags={challenge.tags}
priority={challenge.priority}
status="todo"
dueDate={challenge.deadline}
todosCount={challenge.todosCount}
availableTags={availableTags as Tag[]}
source="manual"
/>
))
)}
@@ -295,118 +513,6 @@ export default function ManagerWeeklySummary({
</div>
)}
{/* Vue détaillée des accomplissements */}
{activeView === 'accomplishments' && (
<Card variant="elevated">
<CardHeader>
<h2 className="text-lg font-semibold">
<Emoji emoji="✅" /> Accomplissements des 7 derniers jours
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.keyAccomplishments.length} accomplissements significatifs
{summary.metrics.totalTasksCompleted} tâches {' '}
{summary.metrics.totalCheckboxesCompleted} todos complétés
</p>
</CardHeader>
<CardContent className="space-y-6">
{summary.keyAccomplishments.length === 0 ? (
<div
className="p-8 text-center rounded-xl border-2"
style={{
backgroundColor:
'color-mix(in srgb, var(--muted) 15%, transparent)',
borderColor:
'color-mix(in srgb, var(--muted) 40%, var(--border))',
color: 'var(--muted-foreground)',
}}
>
<div className="text-4xl mb-4">
<Emoji emoji="📭" />
</div>
<p className="text-lg mb-2">
Aucun accomplissement significatif trouvé cette semaine.
</p>
<p className="text-sm">
Ajoutez des tâches avec priorité haute/medium ou des meetings.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.keyAccomplishments.map((accomplishment, index) => (
<AchievementCard
key={accomplishment.id}
achievement={accomplishment}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={3}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Vue détaillée des challenges */}
{activeView === 'challenges' && (
<Card variant="elevated">
<CardHeader>
<h2 className="text-lg font-semibold">
<Emoji emoji="🎯" /> Enjeux et défis à venir
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{summary.upcomingChallenges.length} défis identifiés {' '}
{
summary.upcomingChallenges.filter((c) => c.priority === 'high')
.length
}{' '}
priorité haute {' '}
{
summary.upcomingChallenges.filter((c) => c.blockers.length > 0)
.length
}{' '}
avec blockers
</p>
</CardHeader>
<CardContent className="space-y-6">
{summary.upcomingChallenges.length === 0 ? (
<div
className="p-8 text-center rounded-xl border-2"
style={{
backgroundColor:
'color-mix(in srgb, var(--muted) 15%, transparent)',
borderColor:
'color-mix(in srgb, var(--muted) 40%, var(--border))',
color: 'var(--muted-foreground)',
}}
>
<div className="text-4xl mb-4">
<Emoji emoji="🎯" />
</div>
<p className="text-lg mb-2">Aucun enjeu prioritaire trouvé.</p>
<p className="text-sm">
Ajoutez des tâches non complétées avec priorité haute/medium.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summary.upcomingChallenges.map((challenge, index) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
availableTags={availableTags as (Tag & { usage: number })[]}
index={index}
showDescription={true}
maxTags={3}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Vue Métriques */}
{activeView === 'metrics' && <MetricsTab />}
</div>

View File

@@ -6,58 +6,9 @@ import { StatCard } from '@/components/ui/StatCard';
import { ActionCard } from '@/components/ui/ActionCard';
import { TaskCard } from '@/components/ui/TaskCard';
import { MetricCard } from '@/components/ui/MetricCard';
import { AchievementCard } from '@/components/ui/AchievementCard';
import { ChallengeCard } from '@/components/ui/ChallengeCard';
import { SkeletonCard } from '@/components/ui/SkeletonCard';
import { AchievementData } from '@/components/ui/AchievementCard';
import { ChallengeData } from '@/components/ui/ChallengeCard';
export function CardsSection() {
const sampleAchievements: AchievementData[] = [
{
id: '1',
title: 'Refactoring de la page Daily',
description: 'Migration vers les composants UI génériques',
impact: 'high',
completedAt: new Date(),
updatedAt: new Date(),
tags: ['refactoring', 'ui'],
todosCount: 8,
},
{
id: '2',
title: 'Implémentation du système de thèmes',
description: 'Ajout de 10 nouveaux thèmes avec CSS variables',
impact: 'medium',
completedAt: new Date(Date.now() - 86400000),
updatedAt: new Date(Date.now() - 86400000),
tags: ['themes', 'css'],
todosCount: 3,
},
];
const sampleChallenges: ChallengeData[] = [
{
id: '1',
title: 'Migration vers Next.js 15',
description: 'Mise à jour majeure avec nouvelles fonctionnalités',
priority: 'high',
deadline: new Date(Date.now() + 7 * 86400000),
tags: ['migration', 'nextjs'],
todosCount: 12,
blockers: ['Tests à mettre à jour'],
},
{
id: '2',
title: 'Optimisation des performances',
description: 'Réduction du temps de chargement',
priority: 'medium',
deadline: new Date(Date.now() + 14 * 86400000),
tags: ['performance', 'optimization'],
todosCount: 5,
},
];
return (
<section id="cards" className="space-y-8">
<h2 className="text-2xl font-mono font-semibold text-[var(--foreground)] border-b border-[var(--border)] pb-3">
@@ -154,40 +105,6 @@ export function CardsSection() {
</div>
</div>
{/* Achievement Cards */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-[var(--foreground)]">
Achievement Cards
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sampleAchievements.map((achievement, index) => (
<AchievementCard
key={achievement.id}
achievement={achievement}
availableTags={[]}
index={index}
/>
))}
</div>
</div>
{/* Challenge Cards */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-[var(--foreground)]">
Challenge Cards
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sampleChallenges.map((challenge, index) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
availableTags={[]}
index={index}
/>
))}
</div>
</div>
{/* Metric Cards */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-[var(--foreground)]">

View File

@@ -1,116 +0,0 @@
'use client';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { PriorityBadge } from '@/components/ui/PriorityBadge';
import { Tag } from '@/lib/types';
import { Emoji } from '@/components/ui/Emoji';
export interface AchievementData {
id: string;
title: string;
description?: string;
impact: 'low' | 'medium' | 'high';
completedAt: Date;
updatedAt: Date;
tags?: string[];
todosCount?: number;
}
interface AchievementCardProps {
achievement: AchievementData;
availableTags: (Tag & { usage: number })[];
index: number;
showDescription?: boolean;
maxTags?: number;
className?: string;
}
export function AchievementCard({
achievement,
availableTags,
index,
showDescription = true,
maxTags = 2,
className = '',
}: AchievementCardProps) {
// Détecter si c'est un todo (ID commence par "todo-")
const isTodo = achievement.id.startsWith('todo-');
return (
<div
className={`relative border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}
style={{
backgroundColor: isTodo
? 'color-mix(in srgb, var(--accent) 8%, var(--card))'
: 'color-mix(in srgb, var(--success) 5%, var(--card))',
}}
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-green-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--success)] bg-[var(--success)]/15 border border-[var(--success)]/25">
#{index + 1}
</span>
<PriorityBadge priority={achievement.impact} />
</div>
<div className="text-xs text-[var(--muted-foreground)] text-right">
<div>
Terminé: {format(achievement.completedAt, 'dd/MM', { locale: fr })}
</div>
{achievement.updatedAt &&
achievement.updatedAt.getTime() !==
achievement.completedAt.getTime() && (
<div>
Mis à jour:{' '}
{format(achievement.updatedAt, 'dd/MM', { locale: fr })}
</div>
)}
</div>
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{achievement.title}
</h4>
{/* Tags */}
{achievement.tags && achievement.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={achievement.tags}
availableTags={availableTags as Tag[]}
size="sm"
maxTags={maxTags}
/>
</div>
)}
{/* Description si disponible */}
{showDescription && achievement.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{achievement.description}
</p>
)}
{/* Count de todos - seulement pour les tâches, pas pour les todos standalone */}
{!isTodo &&
achievement.todosCount !== undefined &&
achievement.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>
<Emoji emoji="📋" />
</span>
<span>
{achievement.todosCount} todo
{achievement.todosCount > 1 ? 's' : ''}
</span>
</div>
)}
</div>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { PriorityBadge } from '@/components/ui/PriorityBadge';
import { Tag } from '@/lib/types';
import { Emoji } from '@/components/ui/Emoji';
export interface ChallengeData {
id: string;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
deadline?: Date;
tags?: string[];
todosCount?: number;
blockers?: string[];
}
interface ChallengeCardProps {
challenge: ChallengeData;
availableTags: (Tag & { usage: number })[];
index: number;
showDescription?: boolean;
maxTags?: number;
className?: string;
}
export function ChallengeCard({
challenge,
availableTags,
index,
showDescription = true,
maxTags = 2,
className = '',
}: ChallengeCardProps) {
return (
<div
className={`relative border border-[var(--border)] rounded-lg p-3 transition-all duration-200 group ${className}`}
style={{
backgroundColor:
'color-mix(in srgb, var(--destructive) 5%, var(--card))',
}}
>
{/* Barre colorée gauche */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-lg"></div>
{/* Header compact */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center text-[var(--accent)] bg-[var(--accent)]/15 border border-[var(--accent)]/25">
#{index + 1}
</span>
<PriorityBadge priority={challenge.priority} />
</div>
{challenge.deadline && (
<span className="text-xs text-[var(--muted-foreground)]">
{format(challenge.deadline, 'dd/MM', { locale: fr })}
</span>
)}
</div>
{/* Titre */}
<h4 className="font-semibold text-sm text-[var(--foreground)] mb-2 line-clamp-2">
{challenge.title}
</h4>
{/* Tags */}
{challenge.tags && challenge.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={challenge.tags}
availableTags={availableTags as Tag[]}
size="sm"
maxTags={maxTags}
/>
</div>
)}
{/* Description si disponible */}
{showDescription && challenge.description && (
<p className="text-xs text-[var(--muted-foreground)] line-clamp-2 leading-relaxed mb-2">
{challenge.description}
</p>
)}
{/* Count de todos */}
{challenge.todosCount !== undefined && challenge.todosCount > 0 && (
<div className="flex items-center gap-1 text-xs text-[var(--muted-foreground)]">
<span>
<Emoji emoji="📋" />
</span>
<span>
{challenge.todosCount} todo{challenge.todosCount > 1 ? 's' : ''}
</span>
</div>
)}
</div>
);
}

View File

@@ -27,8 +27,6 @@ export { DropZone } from './DropZone';
export { Tabs } from './Tabs';
export { PriorityBadge } from './PriorityBadge';
export { StatusBadge } from './StatusBadge';
export { AchievementCard } from './AchievementCard';
export { ChallengeCard } from './ChallengeCard';
// Composants Daily
export { CheckboxItem } from './CheckboxItem';

View File

@@ -0,0 +1,345 @@
/**
* Tests unitaires pour NotesService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NotesService } from '../notes';
import { prisma } from '@/services/core/database';
import { tagsService } from '../task-management/tags';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
note: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
noteTag: {
createMany: vi.fn(),
deleteMany: vi.fn(),
findMany: vi.fn(),
},
},
}));
// Mock de tagsService
vi.mock('../task-management/tags', () => ({
tagsService: {
ensureTagsExist: vi.fn(),
},
}));
describe('NotesService', () => {
let service: NotesService;
const mockUserId = 'user-123';
beforeEach(() => {
vi.clearAllMocks();
service = new NotesService();
});
describe('getNotes', () => {
it('devrait récupérer toutes les notes pour un utilisateur', async () => {
const mockNotes = [
{
id: 'note-1',
title: 'Note 1',
content: 'Content 1',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
},
];
vi.mocked(prisma.note.findMany).mockResolvedValue(mockNotes as any);
const notes = await service.getNotes(mockUserId);
expect(notes).toHaveLength(1);
expect(prisma.note.findMany).toHaveBeenCalledWith({
where: { userId: mockUserId },
include: {
noteTags: {
include: {
tag: true,
},
},
task: {
include: {
taskTags: {
include: {
tag: true,
},
},
primaryTag: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
});
});
describe('getNoteById', () => {
it('devrait récupérer une note par son ID', async () => {
const mockNote = {
id: 'note-1',
title: 'Note 1',
content: 'Content 1',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
};
vi.mocked(prisma.note.findFirst).mockResolvedValue(mockNote as any);
const note = await service.getNoteById('note-1', mockUserId);
expect(note).toBeDefined();
expect(note?.id).toBe('note-1');
expect(prisma.note.findFirst).toHaveBeenCalledWith({
where: {
id: 'note-1',
userId: mockUserId,
},
include: {
noteTags: {
include: {
tag: true,
},
},
task: {
include: {
taskTags: {
include: {
tag: true,
},
},
primaryTag: true,
},
},
},
});
});
it("devrait retourner null si la note n'existe pas", async () => {
vi.mocked(prisma.note.findFirst).mockResolvedValue(null);
const note = await service.getNoteById('non-existent', mockUserId);
expect(note).toBeNull();
});
});
describe('createNote', () => {
it('devrait créer une nouvelle note', async () => {
const mockNote = {
id: 'note-1',
title: 'New Note',
content: 'Content',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
};
vi.mocked(prisma.note.create).mockResolvedValue(mockNote as any);
vi.mocked(prisma.note.findUnique).mockResolvedValue(mockNote as any);
vi.mocked(tagsService.ensureTagsExist).mockResolvedValue([]);
const note = await service.createNote({
title: 'New Note',
content: 'Content',
userId: mockUserId,
});
expect(note).toBeDefined();
expect(prisma.note.create).toHaveBeenCalled();
});
it('devrait créer une note avec des tags', async () => {
const mockNote = {
id: 'note-1',
title: 'New Note',
content: 'Content',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
};
const mockTags = [
{ id: 'tag-1', name: 'Tag 1', color: '#ff0000', isPinned: false },
];
vi.mocked(prisma.note.create).mockResolvedValue(mockNote as any);
vi.mocked(prisma.note.findUnique).mockResolvedValue({
...mockNote,
noteTags: [{ tag: mockTags[0] }],
} as any);
vi.mocked(tagsService.ensureTagsExist).mockResolvedValue(mockTags as any);
vi.mocked(prisma.noteTag.createMany).mockResolvedValue({
count: 1,
} as any);
await service.createNote({
title: 'New Note',
content: 'Content',
userId: mockUserId,
tags: ['Tag 1'],
});
expect(tagsService.ensureTagsExist).toHaveBeenCalledWith(
['Tag 1'],
mockUserId
);
});
});
describe('updateNote', () => {
it('devrait mettre à jour une note', async () => {
const mockNote = {
id: 'note-1',
title: 'Updated Note',
content: 'Updated Content',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
};
vi.mocked(prisma.note.findFirst).mockResolvedValue({
id: 'note-1',
userId: mockUserId,
} as any);
vi.mocked(prisma.note.update).mockResolvedValue(mockNote as any);
vi.mocked(prisma.note.findUnique).mockResolvedValue({
...mockNote,
noteTags: [],
} as any);
vi.mocked(prisma.noteTag.deleteMany).mockResolvedValue({
count: 0,
} as any);
vi.mocked(tagsService.ensureTagsExist).mockResolvedValue([]);
const note = await service.updateNote('note-1', mockUserId, {
title: 'Updated Note',
content: 'Updated Content',
});
expect(note).toBeDefined();
expect(prisma.note.update).toHaveBeenCalled();
});
it("devrait lancer une erreur si la note n'existe pas", async () => {
vi.mocked(prisma.note.findFirst).mockResolvedValue(null);
await expect(
service.updateNote('non-existent', mockUserId, {
title: 'Updated',
})
).rejects.toThrow();
});
});
describe('deleteNote', () => {
it('devrait supprimer une note', async () => {
vi.mocked(prisma.note.findFirst).mockResolvedValue({
id: 'note-1',
userId: mockUserId,
} as any);
vi.mocked(prisma.note.delete).mockResolvedValue({} as any);
await service.deleteNote('note-1', mockUserId);
expect(prisma.note.delete).toHaveBeenCalledWith({
where: { id: 'note-1' },
});
});
it("devrait lancer une erreur si la note n'existe pas", async () => {
vi.mocked(prisma.note.findFirst).mockResolvedValue(null);
await expect(
service.deleteNote('non-existent', mockUserId)
).rejects.toThrow();
});
});
describe('searchNotes', () => {
it('devrait rechercher dans les notes', async () => {
const mockNotes = [
{
id: 'note-1',
title: 'Test Note',
content: 'Content',
userId: mockUserId,
taskId: null,
createdAt: new Date(),
updatedAt: new Date(),
noteTags: [],
task: null,
},
];
vi.mocked(prisma.note.findMany).mockResolvedValue(mockNotes as any);
const notes = await service.searchNotes(mockUserId, 'Test');
expect(notes).toHaveLength(1);
expect(prisma.note.findMany).toHaveBeenCalledWith({
where: {
userId: mockUserId,
OR: [
{ title: { contains: 'Test' } },
{ content: { contains: 'Test' } },
],
},
orderBy: { updatedAt: 'desc' },
});
});
});
describe('getNotesStats', () => {
it('devrait retourner les statistiques des notes', async () => {
const mockNotes = [
{
id: 'note-1',
content: 'This is a test note with words',
updatedAt: new Date(),
},
{
id: 'note-2',
content: 'Another note',
updatedAt: new Date(),
},
];
vi.mocked(prisma.note.findMany).mockResolvedValue(mockNotes as any);
const stats = await service.getNotesStats(mockUserId);
expect(stats.totalNotes).toBeGreaterThanOrEqual(0);
expect(stats.totalWords).toBeGreaterThanOrEqual(0);
expect(stats.lastUpdated).toBeDefined();
});
});
});

View File

@@ -0,0 +1,276 @@
/**
* Tests unitaires pour usersService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usersService } from '../users';
import { prisma } from '../core/database';
import bcrypt from 'bcryptjs';
// Mock de prisma
vi.mock('../core/database', () => ({
prisma: {
user: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
// Mock de bcrypt
vi.mock('bcryptjs', () => ({
default: {
hash: vi.fn(),
compare: vi.fn(),
},
}));
describe('usersService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createUser', () => {
it('devrait créer un nouvel utilisateur', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
firstName: 'Test',
lastName: 'User',
avatar: null,
role: 'user',
isActive: true,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(bcrypt.hash).mockResolvedValue('hashedPassword' as any);
vi.mocked(prisma.user.create).mockResolvedValue(mockUser as any);
const user = await usersService.createUser({
email: 'test@example.com',
name: 'Test User',
firstName: 'Test',
lastName: 'User',
password: 'password123',
});
expect(user).toEqual(mockUser);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 12);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
email: 'test@example.com',
name: 'Test User',
firstName: 'Test',
lastName: 'User',
avatar: undefined,
role: 'user',
password: 'hashedPassword',
},
select: expect.any(Object),
});
});
it('devrait utiliser le rôle par défaut si non spécifié', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: null,
firstName: null,
lastName: null,
avatar: null,
role: 'user',
isActive: true,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(bcrypt.hash).mockResolvedValue('hashedPassword' as any);
vi.mocked(prisma.user.create).mockResolvedValue(mockUser as any);
await usersService.createUser({
email: 'test@example.com',
password: 'password123',
});
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
role: 'user',
}),
})
);
});
});
describe('getUserByEmail', () => {
it('devrait récupérer un utilisateur par email', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
firstName: 'Test',
lastName: 'User',
avatar: null,
role: 'user',
isActive: true,
lastLoginAt: null,
password: 'hashedPassword',
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
const user = await usersService.getUserByEmail('test@example.com');
expect(user).toEqual(mockUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { email: 'test@example.com' },
select: expect.any(Object),
});
});
it("devrait retourner null si l'utilisateur n'existe pas", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const user = await usersService.getUserByEmail('nonexistent@example.com');
expect(user).toBeNull();
});
});
describe('getUserById', () => {
it('devrait récupérer un utilisateur par ID', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
firstName: 'Test',
lastName: 'User',
avatar: null,
role: 'user',
isActive: true,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
const user = await usersService.getUserById('user-1');
expect(user).toEqual(mockUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
select: expect.any(Object),
});
});
});
describe('verifyPassword', () => {
it('devrait vérifier un mot de passe correct', async () => {
vi.mocked(bcrypt.compare).mockResolvedValue(true as any);
const isValid = await usersService.verifyPassword(
'password123',
'hashedPassword'
);
expect(isValid).toBe(true);
expect(bcrypt.compare).toHaveBeenCalledWith(
'password123',
'hashedPassword'
);
});
it('devrait retourner false pour un mot de passe incorrect', async () => {
vi.mocked(bcrypt.compare).mockResolvedValue(false as any);
const isValid = await usersService.verifyPassword(
'wrongPassword',
'hashedPassword'
);
expect(isValid).toBe(false);
});
});
describe('emailExists', () => {
it("devrait retourner true si l'email existe", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: 'user-1',
email: 'test@example.com',
} as any);
const exists = await usersService.emailExists('test@example.com');
expect(exists).toBe(true);
});
it("devrait retourner false si l'email n'existe pas", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const exists = await usersService.emailExists('nonexistent@example.com');
expect(exists).toBe(false);
});
});
describe('updateLastLogin', () => {
it('devrait mettre à jour la date de dernière connexion', async () => {
vi.mocked(prisma.user.update).mockResolvedValue({} as any);
await usersService.updateLastLogin('user-1');
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: {
lastLoginAt: expect.any(Date),
},
});
});
});
describe('updateUser', () => {
it('devrait mettre à jour un utilisateur', async () => {
const mockUser = {
id: 'user-1',
email: 'test@example.com',
name: 'Updated Name',
firstName: 'Updated',
lastName: 'Name',
avatar: null,
role: 'user',
isActive: true,
lastLoginAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const user = await usersService.updateUser('user-1', {
name: 'Updated Name',
firstName: 'Updated',
lastName: 'Name',
});
expect(user).toEqual(mockUser);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: {
name: 'Updated Name',
firstName: 'Updated',
lastName: 'Name',
},
select: expect.any(Object),
});
});
});
});

View File

@@ -0,0 +1,278 @@
/**
* Tests unitaires pour AnalyticsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { AnalyticsService } from '../analytics';
import { TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
findMany: vi.fn(),
},
},
}));
describe('AnalyticsService', () => {
const userId = 'test-user-id';
const mockDate = new Date('2024-01-15T12:00:00Z');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getProductivityMetrics', () => {
it('devrait calculer les métriques de productivité avec des tâches', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche 1',
description: 'Description 1',
status: 'done' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
completedAt: new Date('2024-01-12'),
dueDate: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
{
id: '2',
title: 'Tâche 2',
description: 'Description 2',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'jira' as TaskSource,
sourceId: 'JIRA-123',
ownerId: userId,
createdAt: new Date('2024-01-13'),
updatedAt: new Date('2024-01-13'),
completedAt: null,
dueDate: null,
jiraProject: 'PROJ',
jiraKey: 'JIRA-123',
jiraType: 'Task',
assignee: null,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await AnalyticsService.getProductivityMetrics(userId);
expect(result).toHaveProperty('completionTrend');
expect(result).toHaveProperty('velocityData');
expect(result).toHaveProperty('priorityDistribution');
expect(result).toHaveProperty('statusFlow');
expect(result).toHaveProperty('weeklyStats');
expect(result.completionTrend.length).toBeGreaterThan(0);
expect(result.priorityDistribution.length).toBeGreaterThan(0);
expect(result.statusFlow.length).toBeGreaterThan(0);
});
it('devrait gérer une liste vide de tâches', async () => {
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
const result = await AnalyticsService.getProductivityMetrics(userId);
expect(result.completionTrend.length).toBeGreaterThan(0);
expect(result.velocityData).toEqual([]);
expect(result.priorityDistribution).toEqual([]);
expect(result.statusFlow).toEqual([]);
});
it('devrait filtrer par sources si spécifié', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche Jira',
status: 'done' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'jira' as TaskSource,
sourceId: 'JIRA-1',
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: new Date('2024-01-10'),
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
{
id: '2',
title: 'Tâche Manual',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await AnalyticsService.getProductivityMetrics(
userId,
undefined,
['jira']
);
// Vérifier que seules les tâches Jira sont incluses dans les calculs
const allStatuses = result.statusFlow.map((s) => s.status);
expect(allStatuses).toContain('Terminé'); // Seule la tâche Jira est done
});
it('devrait utiliser une période personnalisée si fournie', async () => {
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-01-10');
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
await AnalyticsService.getProductivityMetrics(userId, {
start: startDate,
end: endDate,
});
expect(prisma.task.findMany).toHaveBeenCalled();
});
it('devrait calculer correctement la tendance de completion', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche complétée',
status: 'done' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
completedAt: new Date('2024-01-12'),
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await AnalyticsService.getProductivityMetrics(userId);
const completionDay = result.completionTrend.find(
(day) => day.date === '2024-01-12'
);
expect(completionDay).toBeDefined();
expect(completionDay?.completed).toBe(1);
});
it('devrait calculer correctement la distribution des priorités', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche haute priorité',
status: 'todo' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
{
id: '2',
title: 'Tâche moyenne priorité',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await AnalyticsService.getProductivityMetrics(userId);
const highPriority = result.priorityDistribution.find(
(p) => p.priority === 'Élevée'
);
const mediumPriority = result.priorityDistribution.find(
(p) => p.priority === 'Moyenne'
);
expect(highPriority).toBeDefined();
expect(highPriority?.count).toBe(1);
expect(highPriority?.percentage).toBe(50);
expect(mediumPriority).toBeDefined();
expect(mediumPriority?.count).toBe(1);
expect(mediumPriority?.percentage).toBe(50);
});
it('devrait gérer les erreurs de base de données', async () => {
vi.mocked(prisma.task.findMany).mockRejectedValue(
new Error('Database error')
);
await expect(
AnalyticsService.getProductivityMetrics(userId)
).rejects.toThrow('Impossible de calculer les métriques de productivité');
});
});
});

View File

@@ -0,0 +1,380 @@
/**
* Tests unitaires pour DeadlineAnalyticsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DeadlineAnalyticsService } from '../deadline-analytics';
import { TaskStatus } from '@/lib/types';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
findMany: vi.fn(),
},
},
}));
describe('DeadlineAnalyticsService', () => {
const userId = 'test-user-id';
const mockDate = new Date('2024-01-15T12:00:00Z');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getDeadlineMetrics', () => {
it('devrait catégoriser les tâches par urgence', async () => {
const overdueDate = new Date('2024-01-10'); // 5 jours en retard
const criticalDate = new Date('2024-01-16'); // 1 jour restant
const warningDate = new Date('2024-01-20'); // 5 jours restants
const upcomingDate = new Date('2024-01-25'); // 10 jours restants
const mockTasks = [
{
id: '1',
title: 'Tâche en retard',
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: overdueDate,
source: 'manual',
ownerId: userId,
taskTags: [],
},
{
id: '2',
title: 'Tâche critique',
status: 'in_progress' as TaskStatus,
priority: 'urgent',
dueDate: criticalDate,
source: 'jira',
ownerId: userId,
taskTags: [],
},
{
id: '3',
title: 'Tâche warning',
status: 'todo' as TaskStatus,
priority: 'medium',
dueDate: warningDate,
source: 'manual',
ownerId: userId,
taskTags: [],
},
{
id: '4',
title: 'Tâche à venir',
status: 'todo' as TaskStatus,
priority: 'low',
dueDate: upcomingDate,
source: 'manual',
ownerId: userId,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await DeadlineAnalyticsService.getDeadlineMetrics(userId);
expect(result.overdue).toHaveLength(1);
expect(result.overdue[0].id).toBe('1');
expect(result.critical).toHaveLength(1);
expect(result.critical[0].id).toBe('2');
expect(result.warning).toHaveLength(1);
expect(result.warning[0].id).toBe('3');
expect(result.upcoming).toHaveLength(1);
expect(result.upcoming[0].id).toBe('4');
expect(result.summary.overdueCount).toBe(1);
expect(result.summary.criticalCount).toBe(1);
expect(result.summary.warningCount).toBe(1);
expect(result.summary.upcomingCount).toBe(1);
expect(result.summary.totalWithDeadlines).toBe(4);
});
it('devrait exclure les tâches terminées ou annulées', async () => {
// Le service filtre déjà les tâches terminées dans la requête Prisma
// donc le mock devrait retourner une liste vide
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
const result = await DeadlineAnalyticsService.getDeadlineMetrics(userId);
expect(result.summary.totalWithDeadlines).toBe(0);
});
it('devrait filtrer par sources si spécifié', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche Jira',
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: new Date('2024-01-16'),
source: 'jira',
ownerId: userId,
taskTags: [],
},
{
id: '2',
title: 'Tâche Manual',
status: 'todo' as TaskStatus,
priority: 'medium',
dueDate: new Date('2024-01-17'),
source: 'manual',
ownerId: userId,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await DeadlineAnalyticsService.getDeadlineMetrics(userId, [
'jira',
]);
const allTaskIds = [
...result.overdue,
...result.critical,
...result.warning,
...result.upcoming,
].map((t) => t.id);
expect(allTaskIds).toContain('1');
expect(allTaskIds).not.toContain('2');
});
it('devrait calculer correctement les jours restants', async () => {
// Utiliser une date normalisée pour éviter les problèmes de timezone
// La date mockée est le 15 janvier à 12:00, donc le 20 janvier à 00:00
// donne environ 4-5 jours selon le calcul avec Math.ceil
const dueDate = new Date('2024-01-20T00:00:00Z'); // 5 jours dans le futur
const mockTasks = [
{
id: '1',
title: 'Tâche test',
status: 'todo' as TaskStatus,
priority: 'medium',
dueDate,
source: 'manual',
ownerId: userId,
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result = await DeadlineAnalyticsService.getDeadlineMetrics(userId);
// Le calcul utilise Math.ceil, donc avec les heures ça peut donner 5 ou 6 jours
// Vérifions que c'est dans la plage warning (3-7 jours)
expect(result.warning[0].daysRemaining).toBeGreaterThanOrEqual(5);
expect(result.warning[0].daysRemaining).toBeLessThanOrEqual(6);
expect(result.warning[0].urgencyLevel).toBe('warning');
});
it('devrait gérer les erreurs de base de données', async () => {
vi.mocked(prisma.task.findMany).mockRejectedValue(
new Error('Database error')
);
await expect(
DeadlineAnalyticsService.getDeadlineMetrics(userId)
).rejects.toThrow("Impossible d'analyser les échéances");
});
});
describe('getCriticalDeadlines', () => {
it('devrait retourner les 10 tâches les plus critiques', async () => {
const mockTasks = Array.from({ length: 15 }, (_, i) => ({
id: `${i + 1}`,
title: `Tâche ${i + 1}`,
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: new Date(`2024-01-${10 + i}`), // Dates en retard
source: 'manual',
ownerId: userId,
taskTags: [],
}));
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
const result =
await DeadlineAnalyticsService.getCriticalDeadlines(userId);
expect(result.length).toBeLessThanOrEqual(10);
});
});
describe('analyzeImpactByPriority', () => {
it("devrait analyser l'impact par priorité", () => {
const tasks = [
{
id: '1',
title: 'Tâche haute priorité en retard',
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: new Date('2024-01-10'),
daysRemaining: -5,
urgencyLevel: 'overdue' as const,
source: 'manual',
tags: [],
},
{
id: '2',
title: 'Tâche haute priorité critique',
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: new Date('2024-01-16'),
daysRemaining: 1,
urgencyLevel: 'critical' as const,
source: 'manual',
tags: [],
},
{
id: '3',
title: 'Tâche moyenne priorité',
status: 'todo' as TaskStatus,
priority: 'medium',
dueDate: new Date('2024-01-20'),
daysRemaining: 5,
urgencyLevel: 'warning' as const,
source: 'manual',
tags: [],
},
];
const result = DeadlineAnalyticsService.analyzeImpactByPriority(tasks);
expect(result.length).toBeGreaterThan(0);
const highPriority = result.find((p) => p.priority === 'high');
expect(highPriority).toBeDefined();
expect(highPriority?.overdueCount).toBe(1);
expect(highPriority?.criticalCount).toBe(1);
});
it('devrait trier par impact décroissant', () => {
const tasks = [
{
id: '1',
title: 'Tâche haute priorité',
status: 'todo' as TaskStatus,
priority: 'high',
dueDate: new Date('2024-01-10'),
daysRemaining: -5,
urgencyLevel: 'overdue' as const,
source: 'manual',
tags: [],
},
{
id: '2',
title: 'Tâche basse priorité',
status: 'todo' as TaskStatus,
priority: 'low',
dueDate: new Date('2024-01-20'),
daysRemaining: 5,
urgencyLevel: 'warning' as const,
source: 'manual',
tags: [],
},
];
const result = DeadlineAnalyticsService.analyzeImpactByPriority(tasks);
expect(result[0].priority).toBe('high'); // Plus d'impact
});
});
describe('calculateRiskMetrics', () => {
it('devrait calculer un score de risque faible', () => {
const metrics = {
overdue: [],
critical: [],
warning: [],
upcoming: [],
summary: {
overdueCount: 0,
criticalCount: 0,
warningCount: 1,
upcomingCount: 2,
totalWithDeadlines: 3,
},
};
const result = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
expect(result.riskScore).toBeLessThan(25);
expect(result.riskLevel).toBe('low');
});
it('devrait calculer un score de risque critique', () => {
const metrics = {
overdue: [],
critical: [],
warning: [],
upcoming: [],
summary: {
overdueCount: 3,
criticalCount: 2,
warningCount: 0,
upcomingCount: 0,
totalWithDeadlines: 5,
},
};
const result = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
expect(result.riskScore).toBeGreaterThanOrEqual(75);
expect(result.riskLevel).toBe('critical');
});
it('devrait limiter le score à 100', () => {
const metrics = {
overdue: [],
critical: [],
warning: [],
upcoming: [],
summary: {
overdueCount: 10,
criticalCount: 10,
warningCount: 10,
upcomingCount: 10,
totalWithDeadlines: 40,
},
};
const result = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
expect(result.riskScore).toBeLessThanOrEqual(100);
});
it('devrait fournir des recommandations appropriées', () => {
const metrics = {
overdue: [],
critical: [],
warning: [],
upcoming: [],
summary: {
overdueCount: 2,
criticalCount: 1,
warningCount: 2,
upcomingCount: 1,
totalWithDeadlines: 6,
},
};
const result = DeadlineAnalyticsService.calculateRiskMetrics(metrics);
expect(result.recommendation).toBeDefined();
expect(result.recommendation.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,314 @@
/**
* Tests unitaires pour ManagerSummaryService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ManagerSummaryService } from '../manager-summary';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
findMany: vi.fn(),
},
dailyCheckbox: {
findMany: vi.fn(),
count: vi.fn(),
},
},
}));
describe('ManagerSummaryService', () => {
const userId = 'test-user-id';
const mockDate = new Date('2024-01-15T12:00:00Z');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getManagerSummary', () => {
it('devrait générer un résumé pour les 7 derniers jours', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche complétée',
description: 'Description',
priority: 'high',
completedAt: new Date('2024-01-12'),
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
taskTags: [],
},
];
const mockCheckboxes = [
{
id: '1',
text: 'Todo complété',
isChecked: true,
type: 'task',
date: new Date('2024-01-13'),
createdAt: new Date('2024-01-13'),
task: null,
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue(
mockCheckboxes as any
);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
expect(result).toHaveProperty('period');
expect(result).toHaveProperty('keyAccomplishments');
expect(result).toHaveProperty('upcomingChallenges');
expect(result).toHaveProperty('metrics');
expect(result).toHaveProperty('narrative');
expect(result.period.start).toBeInstanceOf(Date);
expect(result.period.end).toBeInstanceOf(Date);
expect(result.keyAccomplishments.length).toBeGreaterThan(0);
});
it('devrait extraire les accomplissements clés des tâches', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche haute priorité',
description: 'Description importante',
priority: 'high',
completedAt: new Date('2024-01-12'),
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
taskTags: [
{
tag: {
name: 'important',
},
},
],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue([]);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
const accomplishment = result.keyAccomplishments.find(
(a) => a.id === 'task-1'
);
expect(accomplishment).toBeDefined();
expect(accomplishment?.impact).toBe('high');
expect(accomplishment?.tags).toContain('important');
});
it('devrait inclure les todos standalone comme accomplissements', async () => {
const mockCheckboxes = [
{
id: '1',
text: 'Réunion importante',
isChecked: true,
type: 'meeting',
date: new Date('2024-01-13'),
createdAt: new Date('2024-01-13'),
task: null,
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue(
mockCheckboxes as any
);
const result = await ManagerSummaryService.getManagerSummary(userId);
const accomplishment = result.keyAccomplishments.find(
(a) => a.id === 'todo-1'
);
expect(accomplishment).toBeDefined();
expect(accomplishment?.title).toContain('Réunion importante');
expect(accomplishment?.impact).toBe('low');
});
it('devrait identifier les défis à venir', async () => {
const mockUpcomingTasks = [
{
id: '1',
title: 'Tâche à venir',
description: 'Description',
priority: 'high',
createdAt: new Date('2024-01-10'),
taskTags: [],
},
];
const mockUpcomingCheckboxes = [
{
id: '1',
text: 'Todo à venir',
isChecked: false,
type: 'task',
date: new Date('2024-01-20'),
createdAt: new Date('2024-01-10'),
task: {
id: '1',
title: 'Tâche à venir',
priority: 'high',
taskTags: [],
},
},
];
// Mock pour getCompletedTasks
vi.mocked(prisma.task.findMany)
.mockResolvedValueOnce([]) // Tâches complétées
.mockResolvedValueOnce(mockUpcomingTasks as any); // Tâches à venir
vi.mocked(prisma.dailyCheckbox.findMany)
.mockResolvedValueOnce([]) // Checkboxes complétées
.mockResolvedValueOnce(mockUpcomingCheckboxes as any); // Checkboxes à venir
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
expect(result.upcomingChallenges.length).toBeGreaterThan(0);
const challenge = result.upcomingChallenges.find(
(c) => c.id === 'task-1'
);
expect(challenge).toBeDefined();
expect(challenge?.priority).toBe('high');
});
it('devrait calculer les métriques correctement', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche haute priorité',
description: 'Description',
priority: 'high',
completedAt: new Date('2024-01-12'),
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
taskTags: [],
},
];
const mockCheckboxes = [
{
id: '1',
text: 'Réunion',
isChecked: true,
type: 'meeting',
date: new Date('2024-01-13'),
createdAt: new Date('2024-01-13'),
task: null,
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue(
mockCheckboxes as any
);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
expect(result.metrics.totalTasksCompleted).toBe(1);
expect(result.metrics.totalCheckboxesCompleted).toBe(1);
expect(result.metrics.highPriorityTasksCompleted).toBe(1);
expect(result.metrics.meetingCheckboxesCompleted).toBe(1);
});
it('devrait générer un narratif', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche importante',
description: 'Description',
priority: 'high',
completedAt: new Date('2024-01-12'),
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
taskTags: [
{
tag: {
name: 'important',
},
},
],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue([]);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
expect(result.narrative).toHaveProperty('weekHighlight');
expect(result.narrative).toHaveProperty('mainChallenges');
expect(result.narrative).toHaveProperty('nextWeekFocus');
expect(result.narrative.weekHighlight.length).toBeGreaterThan(0);
});
it('devrait utiliser une date personnalisée si fournie', async () => {
const customDate = new Date('2024-02-01');
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue([]);
const result = await ManagerSummaryService.getManagerSummary(
userId,
customDate
);
expect(result.period.end).toEqual(customDate);
});
it('devrait trier les accomplissements par impact', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche haute priorité',
description: 'Description',
priority: 'high',
completedAt: new Date('2024-01-12'),
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
taskTags: [],
},
{
id: '2',
title: 'Tâche basse priorité',
description: 'Description',
priority: 'low',
completedAt: new Date('2024-01-13'),
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-13'),
taskTags: [],
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue([]);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const result = await ManagerSummaryService.getManagerSummary(userId);
expect(result.keyAccomplishments[0].impact).toBe('high');
});
});
});

View File

@@ -0,0 +1,234 @@
/**
* Tests unitaires pour MetricsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MetricsService } from '../metrics';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
count: vi.fn(),
groupBy: vi.fn(),
},
},
}));
describe('MetricsService', () => {
const userId = 'test-user-id';
const mockDate = new Date('2024-01-15T12:00:00Z');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getWeeklyMetrics', () => {
it('devrait retourner les métriques hebdomadaires', async () => {
// Mock des comptages pour chaque jour
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(5) // completed
.mockResolvedValueOnce(3) // inProgress
.mockResolvedValueOnce(1) // blocked
.mockResolvedValueOnce(2) // pending
.mockResolvedValueOnce(2) // newTasks
.mockResolvedValueOnce(10) // totalTasks
.mockResolvedValueOnce(5) // completed jour 2
.mockResolvedValueOnce(3) // inProgress jour 2
.mockResolvedValueOnce(1) // blocked jour 2
.mockResolvedValueOnce(2) // pending jour 2
.mockResolvedValueOnce(2) // newTasks jour 2
.mockResolvedValueOnce(10); // totalTasks jour 2
// Mock pour les autres jours (répéter pour 7 jours)
for (let i = 0; i < 5; i++) {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
}
// Mock pour statusDistribution
vi.mocked(prisma.task.groupBy).mockResolvedValueOnce([
{
status: 'done',
_count: { status: 5 },
},
{
status: 'in_progress',
_count: { status: 3 },
},
] as any);
// Mock pour priorityBreakdown (3 priorités × 1 count each)
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(2) // high completed
.mockResolvedValueOnce(1) // high inProgress
.mockResolvedValueOnce(2) // medium completed
.mockResolvedValueOnce(1) // medium inProgress
.mockResolvedValueOnce(1) // low completed
.mockResolvedValueOnce(1); // low inProgress
const result = await MetricsService.getWeeklyMetrics(userId);
expect(result).toHaveProperty('period');
expect(result).toHaveProperty('dailyBreakdown');
expect(result).toHaveProperty('summary');
expect(result).toHaveProperty('statusDistribution');
expect(result).toHaveProperty('priorityBreakdown');
expect(result.dailyBreakdown).toHaveLength(7);
expect(result.summary).toHaveProperty('totalTasksCompleted');
expect(result.summary).toHaveProperty('totalTasksCreated');
expect(result.summary).toHaveProperty('averageCompletionRate');
});
it('devrait calculer correctement le taux de completion', async () => {
// Mock pour un jour avec des tâches complétées
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(5) // completed
.mockResolvedValueOnce(0) // inProgress
.mockResolvedValueOnce(0) // blocked
.mockResolvedValueOnce(0) // pending
.mockResolvedValueOnce(0) // newTasks
.mockResolvedValueOnce(10); // totalTasks
// Mock pour les autres jours
for (let i = 0; i < 6; i++) {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
}
vi.mocked(prisma.task.groupBy).mockResolvedValueOnce([] as any);
// Mock priorityBreakdown
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
const result = await MetricsService.getWeeklyMetrics(userId);
expect(result.dailyBreakdown[0].completionRate).toBe(50);
});
it('devrait identifier le jour de pic de productivité', async () => {
// Jour 1: 10 complétées
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(10)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(10);
// Jour 2-7: 0 complétées
for (let i = 0; i < 6; i++) {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
}
vi.mocked(prisma.task.groupBy).mockResolvedValueOnce([] as any);
// Mock priorityBreakdown
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(0);
const result = await MetricsService.getWeeklyMetrics(userId);
expect(result.summary.totalTasksCompleted).toBe(10);
expect(result.summary.peakProductivityDay).toBeDefined();
});
it('devrait utiliser une date personnalisée si fournie', async () => {
const customDate = new Date('2024-02-01');
// Mock minimal pour éviter les erreurs
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.task.groupBy).mockResolvedValue([]);
await MetricsService.getWeeklyMetrics(userId, customDate);
expect(prisma.task.count).toHaveBeenCalled();
});
});
describe('getVelocityTrends', () => {
it('devrait retourner les tendances de vélocité', async () => {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(5) // completed semaine 1
.mockResolvedValueOnce(3) // created semaine 1
.mockResolvedValueOnce(7) // completed semaine 2
.mockResolvedValueOnce(4) // created semaine 2
.mockResolvedValueOnce(6) // completed semaine 3
.mockResolvedValueOnce(5) // created semaine 3
.mockResolvedValueOnce(8) // completed semaine 4
.mockResolvedValueOnce(6); // created semaine 4
const result = await MetricsService.getVelocityTrends(userId, 4);
expect(result).toHaveLength(4);
expect(result[0]).toHaveProperty('date');
expect(result[0]).toHaveProperty('completed');
expect(result[0]).toHaveProperty('created');
expect(result[0]).toHaveProperty('velocity');
});
it('devrait calculer correctement la vélocité', async () => {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(10) // completed
.mockResolvedValueOnce(5); // created
const result = await MetricsService.getVelocityTrends(userId, 1);
expect(result[0].velocity).toBe(200); // 10/5 * 100
});
it("devrait gérer le cas où aucune tâche n'est créée", async () => {
vi.mocked(prisma.task.count)
.mockResolvedValueOnce(5) // completed
.mockResolvedValueOnce(0); // created
const result = await MetricsService.getVelocityTrends(userId, 1);
expect(result[0].velocity).toBe(0);
});
it('devrait utiliser le nombre de semaines personnalisé', async () => {
vi.mocked(prisma.task.count).mockResolvedValue(0);
const result = await MetricsService.getVelocityTrends(userId, 8);
expect(result).toHaveLength(8);
});
});
});

View File

@@ -0,0 +1,502 @@
/**
* Tests unitaires pour TagAnalyticsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TagAnalyticsService } from '../tag-analytics';
import { TaskStatus, TaskPriority, TaskSource } from '@/lib/types';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
findMany: vi.fn(),
},
tag: {
findMany: vi.fn(),
},
},
}));
describe('TagAnalyticsService', () => {
const userId = 'test-user-id';
const mockDate = new Date('2024-01-15T12:00:00Z');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe('getTagDistributionMetrics', () => {
it('devrait calculer la distribution des tags', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche avec tag',
description: 'Description',
status: 'done' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
completedAt: new Date('2024-01-12'),
dueDate: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
{
id: '2',
title: 'Autre tâche',
description: 'Description',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-11'),
completedAt: null,
dueDate: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
{
tag: {
name: 'urgent',
color: '#00ff00',
},
},
],
},
];
const mockTags = [
{ name: 'important', color: '#ff0000' },
{ name: 'urgent', color: '#00ff00' },
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result =
await TagAnalyticsService.getTagDistributionMetrics(userId);
expect(result).toHaveProperty('tagDistribution');
expect(result).toHaveProperty('topTags');
expect(result).toHaveProperty('tagTrends');
expect(result).toHaveProperty('tagStats');
expect(result.tagDistribution.length).toBeGreaterThan(0);
const importantTag = result.tagDistribution.find(
(t) => t.tagName === 'important'
);
expect(importantTag).toBeDefined();
expect(importantTag?.count).toBe(2);
expect(importantTag?.percentage).toBe(100); // 2 tâches sur 2
});
it('devrait calculer les top tags avec métriques', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche complétée',
status: 'done' as TaskStatus,
priority: 'high' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-12'),
completedAt: new Date('2024-01-12'),
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
{
id: '2',
title: 'Tâche non complétée',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-11'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
];
const mockTags = [{ name: 'important', color: '#ff0000' }];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result =
await TagAnalyticsService.getTagDistributionMetrics(userId);
const importantTag = result.topTags.find(
(t) => t.tagName === 'important'
);
expect(importantTag).toBeDefined();
expect(importantTag?.usage).toBe(2);
expect(importantTag?.completionRate).toBe(50); // 1 complétée sur 2
});
it('devrait calculer les tendances des tags', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
];
const mockTags = [{ name: 'important', color: '#ff0000' }];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result =
await TagAnalyticsService.getTagDistributionMetrics(userId);
expect(result.tagTrends.length).toBeGreaterThan(0);
const trend = result.tagTrends.find((t) => t.tagName === 'important');
expect(trend).toBeDefined();
expect(trend?.count).toBe(1);
});
it('devrait calculer les statistiques des tags', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche 1',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
{
id: '2',
title: 'Tâche 2',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-11'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
{
tag: {
name: 'urgent',
color: '#00ff00',
},
},
],
},
];
const mockTags = [
{ name: 'important', color: '#ff0000' },
{ name: 'urgent', color: '#00ff00' },
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result =
await TagAnalyticsService.getTagDistributionMetrics(userId);
expect(result.tagStats.totalTags).toBe(2);
expect(result.tagStats.activeTags).toBe(2);
expect(result.tagStats.mostUsedTag).toBe('important');
expect(result.tagStats.avgTasksPerTag).toBeGreaterThan(0);
});
it('devrait filtrer par sources si spécifié', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche Jira',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'jira' as TaskSource,
sourceId: 'JIRA-1',
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: 'PROJ',
jiraKey: 'JIRA-1',
jiraType: 'Task',
assignee: null,
taskTags: [
{
tag: {
name: 'important',
color: '#ff0000',
},
},
],
},
{
id: '2',
title: 'Tâche Manual',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-11'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'urgent',
color: '#00ff00',
},
},
],
},
];
const mockTags = [
{ name: 'important', color: '#ff0000' },
{ name: 'urgent', color: '#00ff00' },
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result = await TagAnalyticsService.getTagDistributionMetrics(
userId,
undefined,
['jira']
);
const tagNames = result.tagDistribution.map((t) => t.tagName);
expect(tagNames).toContain('important');
expect(tagNames).not.toContain('urgent');
});
it('devrait utiliser une période personnalisée si fournie', async () => {
const startDate = new Date('2024-01-01');
const endDate = new Date('2024-01-10');
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
vi.mocked(prisma.tag.findMany).mockResolvedValue([]);
await TagAnalyticsService.getTagDistributionMetrics(userId, {
start: startDate,
end: endDate,
});
expect(prisma.task.findMany).toHaveBeenCalled();
});
it('devrait gérer les erreurs de base de données', async () => {
vi.mocked(prisma.task.findMany).mockRejectedValue(
new Error('Database error')
);
vi.mocked(prisma.tag.findMany).mockResolvedValue([]);
await expect(
TagAnalyticsService.getTagDistributionMetrics(userId)
).rejects.toThrow(
'Impossible de calculer les métriques de distribution par tags'
);
});
it('devrait trier les tags par utilisation décroissante', async () => {
const mockTasks = [
{
id: '1',
title: 'Tâche',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'frequent',
color: '#0000ff',
},
},
],
},
{
id: '2',
title: 'Tâche',
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
source: 'manual' as TaskSource,
sourceId: null,
ownerId: userId,
createdAt: new Date('2024-01-11'),
updatedAt: new Date('2024-01-11'),
completedAt: null,
dueDate: null,
description: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
assignee: null,
taskTags: [
{
tag: {
name: 'frequent',
color: '#0000ff',
},
},
{
tag: {
name: 'rare',
color: '#ffff00',
},
},
],
},
];
const mockTags = [
{ name: 'frequent', color: '#0000ff' },
{ name: 'rare', color: '#ffff00' },
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const result =
await TagAnalyticsService.getTagDistributionMetrics(userId);
expect(result.tagDistribution[0].tagName).toBe('frequent');
expect(result.tagDistribution[0].count).toBeGreaterThan(
result.tagDistribution[1]?.count || 0
);
});
});
});

View File

@@ -254,12 +254,16 @@ export class ManagerSummaryService {
}
// Compter TOUS les todos associés à cette tâche (pas seulement ceux de la période)
// Exclure les todos de type "meeting" (réunion)
// car l'accomplissement c'est la tâche complétée, pas seulement les todos de la période
const allRelatedTodos = await prisma.dailyCheckbox.count({
where: {
task: {
id: task.id,
},
type: {
not: 'meeting', // Exclure les réunions
},
},
});
@@ -278,18 +282,19 @@ export class ManagerSummaryService {
// AJOUTER les todos standalone avec la nouvelle règle de priorité
// Exclure les todos déjà comptés dans les tâches complétées
// Exclure les todos de type "meeting" (réunion)
const standaloneTodos = checkboxes.filter(
(checkbox) => !checkbox.task // Todos non liés à une tâche
(checkbox) => !checkbox.task && checkbox.type !== 'meeting' // Todos non liés à une tâche et pas de type meeting
);
standaloneTodos.forEach((todo) => {
// Appliquer la nouvelle règle de priorité :
// Si pas de tâche associée, priorité faible (même pour les meetings)
// Si pas de tâche associée, priorité faible
const impact: 'high' | 'medium' | 'low' = 'low';
accomplishments.push({
id: `todo-${todo.id}`,
title: todo.type === 'meeting' ? `📅 ${todo.text}` : todo.text,
title: todo.text,
tags: [], // Todos standalone n'ont pas de tags par défaut
impact,
completedAt: todo.date,

View File

@@ -0,0 +1,93 @@
/**
* Tests unitaires pour database.ts
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
testDatabaseConnection,
closeDatabaseConnection,
clearDatabase,
prisma,
} from '../database';
describe('database utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('testDatabaseConnection', () => {
it('devrait retourner true si la connexion réussit', async () => {
vi.spyOn(prisma, '$connect').mockResolvedValue(undefined);
const result = await testDatabaseConnection();
expect(result).toBe(true);
expect(prisma.$connect).toHaveBeenCalled();
});
it('devrait retourner false si la connexion échoue', async () => {
vi.spyOn(prisma, '$connect').mockRejectedValue(
new Error('Connection failed')
);
const result = await testDatabaseConnection();
expect(result).toBe(false);
});
});
describe('closeDatabaseConnection', () => {
it('devrait fermer la connexion avec succès', async () => {
vi.spyOn(prisma, '$disconnect').mockResolvedValue(undefined);
await closeDatabaseConnection();
expect(prisma.$disconnect).toHaveBeenCalled();
});
it('devrait gérer les erreurs lors de la fermeture', async () => {
vi.spyOn(prisma, '$disconnect').mockRejectedValue(
new Error('Disconnect failed')
);
// Ne devrait pas throw
await expect(closeDatabaseConnection()).resolves.not.toThrow();
});
});
describe('clearDatabase', () => {
beforeEach(() => {
// S'assurer qu'on est pas en production
vi.stubEnv('NODE_ENV', 'test');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('devrait nettoyer toutes les tables', async () => {
const deleteManySpy = vi.fn().mockResolvedValue({ count: 0 });
// Mock des méthodes deleteMany directement
(prisma.taskTag as any) = { deleteMany: deleteManySpy };
(prisma.task as any) = { deleteMany: deleteManySpy };
(prisma.tag as any) = { deleteMany: deleteManySpy };
(prisma.syncLog as any) = { deleteMany: deleteManySpy };
await clearDatabase();
expect(deleteManySpy).toHaveBeenCalledTimes(4);
});
it('ne devrait pas permettre le nettoyage en production', async () => {
vi.stubEnv('NODE_ENV', 'production');
await expect(clearDatabase()).rejects.toThrow(
'Cannot clear database in production'
);
vi.unstubAllEnvs();
});
});
});

View File

@@ -0,0 +1,256 @@
/**
* Tests unitaires pour SystemInfoService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SystemInfoService } from '../system-info';
import { prisma } from '../database';
import { readFile } from 'fs/promises';
import { stat } from 'fs/promises';
// Mock de prisma
vi.mock('../database', () => ({
prisma: {
task: {
count: vi.fn(),
},
userPreferences: {
count: vi.fn(),
},
tag: {
count: vi.fn(),
},
dailyCheckbox: {
count: vi.fn(),
},
},
}));
// Mock de fs/promises
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
stat: vi.fn(),
}));
// Mock de path
vi.mock('path', () => ({
join: (...args: string[]) => args.join('/'),
resolve: (...args: string[]) => args.join('/'),
}));
// Mock du service backup
vi.mock('@/services/data-management/backup', () => ({
backupService: {
listBackups: vi.fn(),
},
}));
describe('SystemInfoService', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('NODE_ENV', 'test');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('getSystemInfo', () => {
it('devrait retourner les informations système complètes', async () => {
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockResolvedValue(10);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(1);
vi.mocked(prisma.tag.count).mockResolvedValue(5);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(20);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([
{ id: '1', filename: 'backup1.db.gz' },
{ id: '2', filename: 'backup2.db.gz' },
] as any);
vi.mocked(stat).mockResolvedValue({
size: 1024 * 1024, // 1 MB
} as any);
const result = await SystemInfoService.getSystemInfo();
expect(result).toHaveProperty('version');
expect(result).toHaveProperty('environment');
expect(result).toHaveProperty('database');
expect(result).toHaveProperty('backups');
expect(result).toHaveProperty('app');
expect(result).toHaveProperty('uptime');
expect(result).toHaveProperty('lastUpdate');
expect(result.version).toBe('1.0.0');
expect(result.environment).toBe('test');
expect(result.database.totalTasks).toBe(10);
expect(result.database.totalUsers).toBe(1);
expect(result.database.totalBackups).toBe(2);
expect(result.database.totalTags).toBe(5);
expect(result.database.totalDailies).toBe(20);
});
it('devrait gérer les erreurs lors de la lecture du package.json', async () => {
vi.mocked(readFile).mockRejectedValue(new Error('File not found'));
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(0);
vi.mocked(prisma.tag.count).mockResolvedValue(0);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
const result = await SystemInfoService.getSystemInfo();
expect(result.version).toBe('1.0.0'); // Valeur par défaut
expect(result.database.databaseSize).toBe('N/A');
});
it('devrait calculer correctement la taille de la base de données', async () => {
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(0);
vi.mocked(prisma.tag.count).mockResolvedValue(0);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
// Test différentes tailles
vi.mocked(stat).mockResolvedValue({
size: 1024, // 1 KB
} as any);
const result = await SystemInfoService.getSystemInfo();
expect(result.database.databaseSize).toContain('KB');
});
it('devrait utiliser DATABASE_URL pour trouver la base de données', async () => {
vi.stubEnv('DATABASE_URL', 'file:/path/to/db.db');
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(0);
vi.mocked(prisma.tag.count).mockResolvedValue(0);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
vi.mocked(stat).mockResolvedValue({
size: 1024,
} as any);
await SystemInfoService.getSystemInfo();
expect(stat).toHaveBeenCalled();
});
it('devrait utiliser BACKUP_DATABASE_PATH si défini', async () => {
vi.stubEnv('BACKUP_DATABASE_PATH', '/custom/path/db.db');
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(0);
vi.mocked(prisma.tag.count).mockResolvedValue(0);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
vi.mocked(stat).mockResolvedValue({
size: 1024,
} as any);
await SystemInfoService.getSystemInfo();
expect(stat).toHaveBeenCalled();
});
it('devrait gérer les erreurs de base de données', async () => {
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockRejectedValue(
new Error('Database error')
);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
const result = await SystemInfoService.getSystemInfo();
expect(result.database.totalTasks).toBe(0);
expect(result.database.totalUsers).toBe(0);
});
it("devrait calculer correctement l'uptime", async () => {
const mockPackageJson = {
name: 'towercontrol',
version: '1.0.0',
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
vi.mocked(prisma.task.count).mockResolvedValue(0);
vi.mocked(prisma.userPreferences.count).mockResolvedValue(0);
vi.mocked(prisma.tag.count).mockResolvedValue(0);
vi.mocked(prisma.dailyCheckbox.count).mockResolvedValue(0);
const { backupService } = await import(
'@/services/data-management/backup'
);
vi.mocked(backupService.listBackups).mockResolvedValue([]);
vi.mocked(stat).mockResolvedValue({
size: 1024,
} as any);
const result = await SystemInfoService.getSystemInfo();
expect(result.uptime).toBeDefined();
expect(result.uptime.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,836 @@
/**
* Tests unitaires pour UserPreferencesService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { userPreferencesService } from '../user-preferences';
import { prisma } from '../database';
import { getConfig } from '@/lib/config';
import {
KanbanFilters,
ViewPreferences,
ColumnVisibility,
JiraConfig,
} from '@/lib/types';
import { TfsConfig } from '@/services/integrations/tfs/tfs';
// Mock de prisma
vi.mock('../database', () => ({
prisma: {
user: {
upsert: vi.fn(),
},
userPreferences: {
upsert: vi.fn(),
update: vi.fn(),
findUnique: vi.fn(),
},
$executeRaw: vi.fn(),
$queryRaw: vi.fn(),
},
}));
// Mock de getConfig
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(),
}));
describe('UserPreferencesService', () => {
const userId = 'test-user-id';
beforeEach(() => {
vi.clearAllMocks();
});
describe('Kanban Filters', () => {
it('devrait sauvegarder les filtres Kanban', async () => {
const filters: KanbanFilters = {
search: 'test',
tags: ['important'],
priorities: ['high'],
showCompleted: false,
sortBy: 'priority',
showWithDueDate: true,
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: filters,
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
kanbanFilters: filters,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveKanbanFilters(userId, filters);
expect(prisma.userPreferences.update).toHaveBeenCalledWith({
where: { userId },
data: { kanbanFilters: filters },
});
});
it('devrait récupérer les filtres Kanban avec valeurs par défaut', async () => {
const filters: KanbanFilters = {
search: 'test',
tags: ['important'],
priorities: [],
showCompleted: true,
sortBy: '',
showWithDueDate: false,
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: filters,
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
const result = await userPreferencesService.getKanbanFilters(userId);
expect(result).toHaveProperty('search');
expect(result).toHaveProperty('tags');
expect(result).toHaveProperty('priorities');
expect(result).toHaveProperty('showCompleted');
});
it("devrait retourner les valeurs par défaut en cas d'erreur", async () => {
vi.mocked(prisma.user.upsert).mockRejectedValue(
new Error('Database error')
);
const result = await userPreferencesService.getKanbanFilters(userId);
expect(result).toHaveProperty('search');
expect(result.search).toBe('');
});
});
describe('View Preferences', () => {
it('devrait sauvegarder les préférences de vue', async () => {
const preferences: ViewPreferences = {
compactView: true,
swimlanesByTags: true,
swimlanesMode: 'tags',
showObjectives: false,
showFilters: true,
objectivesCollapsed: false,
theme: 'light',
fontSize: 'large',
backgroundImage: undefined,
backgroundBlur: 0,
backgroundOpacity: 100,
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: preferences,
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
viewPreferences: preferences,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveViewPreferences(userId, preferences);
expect(prisma.userPreferences.update).toHaveBeenCalledWith({
where: { userId },
data: { viewPreferences: preferences },
});
});
it('devrait récupérer le thème', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: { theme: 'light' },
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
const theme = await userPreferencesService.getTheme(userId);
expect(theme).toBe('light');
});
});
describe('Column Visibility', () => {
it('devrait sauvegarder la visibilité des colonnes', async () => {
const visibility: ColumnVisibility = {
hiddenStatuses: ['archived', 'cancelled'],
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: visibility,
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
columnVisibility: visibility,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveColumnVisibility(userId, visibility);
expect(prisma.userPreferences.update).toHaveBeenCalledWith({
where: { userId },
data: { columnVisibility: visibility },
});
});
it("devrait toggle la visibilité d'une colonne", async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: { hiddenStatuses: [] },
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
columnVisibility: { hiddenStatuses: ['archived'] },
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.toggleColumnVisibility(userId, 'archived');
expect(prisma.userPreferences.update).toHaveBeenCalled();
});
});
describe('Jira Config', () => {
it('devrait sauvegarder la configuration Jira', async () => {
const config: JiraConfig = {
enabled: true,
baseUrl: 'https://jira.example.com',
email: 'user@example.com',
apiToken: 'token123',
ignoredProjects: [],
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: config,
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
jiraConfig: config,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveJiraConfig(userId, config);
expect(prisma.userPreferences.update).toHaveBeenCalledWith({
where: { userId },
data: { jiraConfig: config },
});
});
it('devrait récupérer la configuration Jira depuis la DB', async () => {
const config: JiraConfig = {
enabled: true,
baseUrl: 'https://jira.example.com',
email: 'user@example.com',
apiToken: 'token123',
ignoredProjects: [],
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: config,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
const result = await userPreferencesService.getJiraConfig(userId);
expect(result.baseUrl).toBe('https://jira.example.com');
expect(result.email).toBe('user@example.com');
});
it("devrait utiliser les variables d'environnement si pas de config DB", async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: null,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
vi.mocked(getConfig).mockReturnValue({
integrations: {
jira: {
baseUrl: 'https://env-jira.example.com',
email: 'env@example.com',
enabled: true,
},
},
} as any);
const result = await userPreferencesService.getJiraConfig(userId);
expect(result.baseUrl).toBe('https://env-jira.example.com');
expect(result.email).toBe('env@example.com');
});
});
describe('TFS Config', () => {
it('devrait sauvegarder la configuration TFS', async () => {
const config: TfsConfig = {
enabled: true,
organizationUrl: 'https://dev.azure.com/org',
projectName: 'MyProject',
personalAccessToken: 'token123',
repositories: ['repo1', 'repo2'],
ignoredRepositories: [],
maxSyncPeriod: '90d',
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
tfsConfig: config,
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
tfsConfig: config,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveTfsConfig(userId, config);
expect(prisma.userPreferences.update).toHaveBeenCalledWith({
where: { userId },
data: { tfsConfig: config },
});
});
it('devrait récupérer la configuration TFS', async () => {
const config: TfsConfig = {
enabled: true,
organizationUrl: 'https://dev.azure.com/org',
projectName: 'MyProject',
personalAccessToken: 'token123',
repositories: [],
ignoredRepositories: [],
maxSyncPeriod: '90d',
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
tfsConfig: config,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
const result = await userPreferencesService.getTfsConfig(userId);
expect(result.organizationUrl).toBe('https://dev.azure.com/org');
expect(result.projectName).toBe('MyProject');
});
});
describe('Scheduler Config', () => {
it('devrait sauvegarder la configuration scheduler Jira', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveJiraSchedulerConfig(
userId,
true,
'hourly'
);
expect(prisma.$executeRaw).toHaveBeenCalled();
});
it('devrait récupérer la configuration scheduler Jira', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ jiraAutoSync: 1, jiraSyncInterval: 'hourly' },
] as any);
const result =
await userPreferencesService.getJiraSchedulerConfig(userId);
expect(result.jiraAutoSync).toBe(true);
expect(result.jiraSyncInterval).toBe('hourly');
});
it('devrait sauvegarder la configuration scheduler TFS', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveTfsSchedulerConfig(
userId,
true,
'daily'
);
expect(prisma.$executeRaw).toHaveBeenCalled();
});
it('devrait récupérer la configuration scheduler TFS', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ tfsAutoSync: 1, tfsSyncInterval: 'daily' },
] as any);
const result = await userPreferencesService.getTfsSchedulerConfig(userId);
expect(result.tfsAutoSync).toBe(true);
expect(result.tfsSyncInterval).toBe('daily');
});
});
describe('All Preferences', () => {
it('devrait récupérer toutes les préférences', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ jiraAutoSync: 0, jiraSyncInterval: 'daily' },
{ tfsAutoSync: 0, tfsSyncInterval: 'daily' },
] as any);
vi.mocked(getConfig).mockReturnValue({
integrations: {
jira: {
baseUrl: '',
email: '',
enabled: false,
},
},
} as any);
const result = await userPreferencesService.getAllPreferences(userId);
expect(result).toHaveProperty('kanbanFilters');
expect(result).toHaveProperty('viewPreferences');
expect(result).toHaveProperty('columnVisibility');
expect(result).toHaveProperty('jiraConfig');
expect(result).toHaveProperty('jiraAutoSync');
expect(result).toHaveProperty('jiraSyncInterval');
expect(result).toHaveProperty('tfsConfig');
expect(result).toHaveProperty('tfsAutoSync');
expect(result).toHaveProperty('tfsSyncInterval');
});
it('devrait sauvegarder toutes les préférences', async () => {
const preferences = {
kanbanFilters: {
search: '',
tags: [],
priorities: [],
showCompleted: true,
sortBy: '',
showWithDueDate: false,
},
viewPreferences: {
compactView: false,
swimlanesByTags: false,
swimlanesMode: 'tags' as const,
showObjectives: true,
showFilters: true,
objectivesCollapsed: false,
theme: 'dark' as const,
fontSize: 'medium' as const,
backgroundImage: undefined,
backgroundBlur: 0,
backgroundOpacity: 100,
},
columnVisibility: {
hiddenStatuses: [],
},
jiraConfig: {
enabled: false,
baseUrl: '',
email: '',
apiToken: '',
ignoredProjects: [],
},
jiraAutoSync: false,
jiraSyncInterval: 'daily' as const,
tfsConfig: {
enabled: false,
organizationUrl: '',
projectName: '',
personalAccessToken: '',
repositories: [],
ignoredRepositories: [],
maxSyncPeriod: '90d' as const,
},
tfsAutoSync: false,
tfsSyncInterval: 'daily' as const,
};
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.saveAllPreferences(userId, preferences);
expect(prisma.userPreferences.update).toHaveBeenCalled();
expect(prisma.$executeRaw).toHaveBeenCalled();
});
it('devrait remettre à zéro toutes les préférences', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.resetAllPreferences(userId);
expect(prisma.userPreferences.update).toHaveBeenCalled();
});
});
describe('Update methods', () => {
it('devrait mettre à jour partiellement les filtres Kanban', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: { search: '', tags: [] },
viewPreferences: {},
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
kanbanFilters: { search: 'new', tags: [] },
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.updateKanbanFilters(userId, {
search: 'new',
});
expect(prisma.userPreferences.update).toHaveBeenCalled();
});
it('devrait mettre à jour partiellement les préférences de vue', async () => {
vi.mocked(prisma.user.upsert).mockResolvedValue({
id: userId,
email: `${userId}@towercontrol.local`,
name: 'Default User',
role: 'user',
password: 'default',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId,
kanbanFilters: {},
viewPreferences: { theme: 'dark' },
columnVisibility: {},
jiraConfig: {},
} as any);
vi.mocked(prisma.userPreferences.update).mockResolvedValue({
userId,
viewPreferences: { theme: 'light' },
} as any);
vi.mocked(prisma.$executeRaw).mockResolvedValue(0);
await userPreferencesService.updateViewPreferences(userId, {
theme: 'light',
});
expect(prisma.userPreferences.update).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,319 @@
/**
* Tests unitaires pour BackupScheduler
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { BackupScheduler } from '../backup-scheduler';
import { backupService } from '../backup';
import { getToday, addMinutes } from '@/lib/date-utils';
// Mock de backupService
vi.mock('../backup', () => ({
backupService: {
getConfigSync: vi.fn(),
createBackup: vi.fn(),
},
}));
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getToday: vi.fn(),
addMinutes: vi.fn(),
}));
describe('BackupScheduler', () => {
let scheduler: BackupScheduler;
beforeEach(() => {
vi.clearAllMocks();
scheduler = new BackupScheduler();
vi.useFakeTimers();
});
afterEach(() => {
scheduler.stop();
vi.useRealTimers();
});
describe('start', () => {
it('devrait démarrer le scheduler si activé', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
});
it('ne devrait pas démarrer si désactivé', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: false,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
expect(scheduler.isActive()).toBe(false);
});
it('ne devrait pas démarrer deux fois', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
const firstCall = scheduler.isActive();
scheduler.start();
const secondCall = scheduler.isActive();
expect(firstCall).toBe(true);
expect(secondCall).toBe(true);
});
});
describe('stop', () => {
it('devrait arrêter le scheduler', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
it("devrait gérer l'arrêt si pas démarré", () => {
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
});
describe('restart', () => {
it('devrait redémarrer le scheduler', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
scheduler.restart();
expect(scheduler.isActive()).toBe(true);
});
});
describe('isActive', () => {
it('devrait retourner false si pas démarré', () => {
expect(scheduler.isActive()).toBe(false);
});
it('devrait retourner true si démarré', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
expect(scheduler.isActive()).toBe(true);
});
});
describe('performScheduledBackup', () => {
it('devrait exécuter une sauvegarde automatique', async () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
vi.mocked(backupService.createBackup).mockResolvedValue({
id: '1',
filename: 'backup.db.gz',
size: 1024,
createdAt: new Date(),
type: 'automatic',
status: 'success',
} as any);
scheduler.start();
// Avancer le timer pour déclencher la sauvegarde une seule fois
vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure
// Attendre que la sauvegarde soit exécutée
await vi.waitFor(() => {
expect(backupService.createBackup).toHaveBeenCalledWith('automatic');
});
scheduler.stop();
});
it('devrait gérer les sauvegardes ignorées', async () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
vi.mocked(backupService.createBackup).mockResolvedValue(null);
scheduler.start();
vi.advanceTimersByTime(60 * 60 * 1000);
await vi.waitFor(() => {
expect(backupService.createBackup).toHaveBeenCalled();
});
scheduler.stop();
});
it('devrait gérer les erreurs de sauvegarde', async () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
vi.mocked(backupService.createBackup).mockRejectedValue(
new Error('Backup failed')
);
scheduler.start();
vi.advanceTimersByTime(60 * 60 * 1000);
await vi.waitFor(() => {
expect(backupService.createBackup).toHaveBeenCalled();
});
scheduler.stop();
});
});
describe('getIntervalMs', () => {
it('devrait convertir hourly en millisecondes', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
const status = scheduler.getStatus();
expect(status.interval).toBe('hourly');
});
it('devrait convertir daily en millisecondes', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'daily',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
const status = scheduler.getStatus();
expect(status.interval).toBe('daily');
});
it('devrait convertir weekly en millisecondes', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'weekly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
const status = scheduler.getStatus();
expect(status.interval).toBe('weekly');
});
});
describe('getNextBackupTime', () => {
it('devrait retourner null si pas démarré', () => {
const nextTime = scheduler.getNextBackupTime();
expect(nextTime).toBeNull();
});
it('devrait calculer le prochain moment de sauvegarde', () => {
const mockDate = new Date('2024-01-15T12:00:00Z');
vi.mocked(getToday).mockReturnValue(mockDate);
vi.mocked(addMinutes).mockReturnValue(new Date('2024-01-15T13:00:00Z'));
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
scheduler.start();
const nextTime = scheduler.getNextBackupTime();
expect(nextTime).not.toBeNull();
expect(addMinutes).toHaveBeenCalled();
});
});
describe('getStatus', () => {
it('devrait retourner le statut du scheduler', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: true,
interval: 'daily',
maxBackups: 10,
backupPath: '/custom/backups',
} as any);
scheduler.start();
const status = scheduler.getStatus();
expect(status.isRunning).toBe(true);
expect(status.isEnabled).toBe(true);
expect(status.interval).toBe('daily');
expect(status.maxBackups).toBe(10);
expect(status.backupPath).toBe('/custom/backups');
});
it('devrait retourner le statut si pas démarré', () => {
vi.mocked(backupService.getConfigSync).mockReturnValue({
enabled: false,
interval: 'hourly',
maxBackups: 5,
backupPath: '/backups',
} as any);
const status = scheduler.getStatus();
expect(status.isRunning).toBe(false);
expect(status.isEnabled).toBe(false);
});
});
});

View File

@@ -0,0 +1,400 @@
/**
* Tests unitaires pour BackupService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { BackupService } from '../backup';
import { prisma } from '@/services/core/database';
import { userPreferencesService } from '@/services/core/user-preferences';
import { BackupUtils } from '@/lib/backup-utils';
import { promises as fs } from 'fs';
import path from 'path';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
$disconnect: vi.fn(),
$connect: vi.fn(),
$queryRaw: vi.fn(),
userPreferences: {
upsert: vi.fn(),
},
},
}));
// Mock de userPreferencesService
vi.mock('@/services/core/user-preferences', () => ({
userPreferencesService: {
getAllPreferences: vi.fn(),
getViewPreferences: vi.fn(),
},
}));
// Mock de BackupUtils
vi.mock('@/lib/backup-utils', () => ({
BackupUtils: {
resolveBackupStoragePath: vi.fn(),
resolveDatabasePath: vi.fn(),
calculateFileHash: vi.fn(),
ensureDirectory: vi.fn(),
createSQLiteBackup: vi.fn(),
compressFile: vi.fn(),
decompressFileTemp: vi.fn(),
writeLogEntry: vi.fn(),
parseBackupFilename: vi.fn(),
generateBackupFilename: vi.fn(),
},
}));
// Mock de fs (pour promises)
vi.mock('fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
stat: vi.fn(),
readdir: vi.fn(),
unlink: vi.fn(),
access: vi.fn(),
copyFile: vi.fn(),
},
}));
describe('BackupService', () => {
let backupService: BackupService;
beforeEach(() => {
vi.clearAllMocks();
backupService = new BackupService();
// Mocks par défaut
vi.mocked(BackupUtils.resolveBackupStoragePath).mockReturnValue('/backups');
vi.mocked(BackupUtils.resolveDatabasePath).mockReturnValue('/db/dev.db');
vi.mocked(BackupUtils.calculateFileHash).mockResolvedValue('hash123');
vi.mocked(BackupUtils.ensureDirectory).mockResolvedValue(undefined);
vi.mocked(BackupUtils.createSQLiteBackup).mockResolvedValue(undefined);
vi.mocked(BackupUtils.compressFile).mockResolvedValue(
'/backups/file.db.gz'
);
vi.mocked(BackupUtils.parseBackupFilename).mockReturnValue({
type: 'manual',
date: new Date(),
});
vi.mocked(BackupUtils.generateBackupFilename).mockReturnValue(
'towercontrol_manual_2024-01-01.db'
);
vi.mocked(fs.stat).mockResolvedValue({
size: 1024,
birthtime: new Date(),
} as any);
vi.mocked(fs.readdir).mockResolvedValue([]);
vi.mocked(userPreferencesService.getAllPreferences).mockResolvedValue({
viewPreferences: {},
} as any);
vi.mocked(userPreferencesService.getViewPreferences).mockResolvedValue(
{} as any
);
});
describe('createBackup', () => {
it('devrait créer une sauvegarde manuelle', async () => {
vi.mocked(BackupUtils.compressFile).mockResolvedValue(
'/backups/towercontrol_manual_2024-01-01.db.gz'
);
vi.mocked(fs.stat).mockResolvedValue({
size: 2048,
birthtime: new Date(),
} as any);
const result = await backupService.createBackup('manual', true);
expect(result).not.toBeNull();
expect(result?.type).toBe('manual');
expect(result?.status).toBe('success');
expect(result?.filename).toContain('towercontrol_manual');
});
it('devrait créer une sauvegarde automatique', async () => {
vi.mocked(BackupUtils.compressFile).mockResolvedValue(
'/backups/towercontrol_automatic_2024-01-01.db.gz'
);
const result = await backupService.createBackup('automatic', true);
expect(result).not.toBeNull();
expect(result?.type).toBe('automatic');
});
it('devrait retourner null si pas de changements et forceCreate=false', async () => {
// Mock pour simuler qu'il n'y a pas de changements
vi.mocked(fs.readdir).mockResolvedValue([
'towercontrol_manual_2024-01-01.db.gz',
] as any);
vi.mocked(BackupUtils.calculateFileHash).mockResolvedValue('samehash');
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({ databaseHash: 'samehash' })
);
const result = await backupService.createBackup('automatic', false);
// Si pas de changements, devrait retourner null
expect(result).toBeNull();
});
it('devrait gérer les erreurs lors de la création', async () => {
vi.mocked(BackupUtils.createSQLiteBackup).mockRejectedValue(
new Error('Backup failed')
);
const result = await backupService.createBackup('manual', true);
expect(result).not.toBeNull();
expect(result?.status).toBe('failed');
expect(result?.error).toBeDefined();
});
});
describe('listBackups', () => {
it('devrait lister les sauvegardes disponibles', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
'towercontrol_manual_2024-01-01.db.gz',
'towercontrol_automatic_2024-01-02.db.gz',
] as any);
const backups = await backupService.listBackups();
expect(backups).toHaveLength(2);
expect(backups[0].filename).toContain('towercontrol_');
});
it('devrait retourner une liste vide si aucun backup', async () => {
vi.mocked(fs.readdir).mockResolvedValue([]);
const backups = await backupService.listBackups();
expect(backups).toHaveLength(0);
});
it('devrait filtrer les fichiers non-backup', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
'towercontrol_manual_2024-01-01.db.gz',
'other_file.txt',
'backup.log',
] as any);
const backups = await backupService.listBackups();
expect(backups).toHaveLength(1);
});
it('devrait gérer les erreurs', async () => {
(fs.readdir as any).mockRejectedValue(new Error('Read error'));
const backups = await backupService.listBackups();
expect(backups).toHaveLength(0);
});
});
describe('deleteBackup', () => {
it('devrait supprimer un backup et ses métadonnées', async () => {
await backupService.deleteBackup('backup.db.gz');
expect(fs.unlink).toHaveBeenCalledWith(
path.join('/backups', 'backup.db.gz')
);
expect(fs.unlink).toHaveBeenCalledWith(
path.join('/backups', 'backup.db.gz.meta.json')
);
});
it('devrait gérer les erreurs lors de la suppression', async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error('Delete failed'));
await expect(
backupService.deleteBackup('backup.db.gz')
).rejects.toThrow();
});
});
describe('restoreBackup', () => {
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ integrity_check: 'ok' },
]);
vi.spyOn(backupService, 'createBackup').mockResolvedValue({
id: 'pre-restore',
filename: 'pre-restore.db.gz',
size: 1024,
createdAt: new Date(),
type: 'manual',
status: 'success',
} as any);
});
it('devrait restaurer un backup non compressé', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
await backupService.restoreBackup('backup.db');
expect(fs.copyFile).toHaveBeenCalled();
expect(prisma.$connect).toHaveBeenCalled();
});
it('devrait décompresser et restaurer un backup compressé', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(BackupUtils.decompressFileTemp).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await backupService.restoreBackup('backup.db.gz');
expect(BackupUtils.decompressFileTemp).toHaveBeenCalled();
expect(fs.copyFile).toHaveBeenCalled();
});
it('devrait gérer les erreurs lors de la restauration', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('File not found'));
await expect(backupService.restoreBackup('backup.db')).rejects.toThrow();
});
});
describe('verifyDatabaseHealth', () => {
it("devrait vérifier l'intégrité de la base de données", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ integrity_check: 'ok' },
]);
await backupService.verifyDatabaseHealth();
expect(prisma.$queryRaw).toHaveBeenCalled();
});
it("devrait échouer si l'intégrité est compromise", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ integrity_check: 'error in database' },
]);
await expect(backupService.verifyDatabaseHealth()).rejects.toThrow();
});
});
describe('hasChangedSinceLastBackup', () => {
it('devrait retourner true si aucun backup précédent', async () => {
vi.mocked(fs.readdir).mockResolvedValue([]);
const hasChanged = await backupService.hasChangedSinceLastBackup();
expect(hasChanged).toBe(true);
});
it('devrait retourner true si le hash a changé', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
'towercontrol_manual_2024-01-01.db.gz',
] as any);
vi.mocked(BackupUtils.calculateFileHash)
.mockResolvedValueOnce('newhash')
.mockResolvedValueOnce('oldhash');
vi.mocked(fs.readFile).mockResolvedValue(
JSON.stringify({ databaseHash: 'oldhash' })
);
const hasChanged = await backupService.hasChangedSinceLastBackup();
expect(hasChanged).toBe(true);
});
});
describe('getBackupLogs', () => {
it('devrait lire les logs de backup', async () => {
vi.mocked(fs.readFile).mockResolvedValue(
'2024-01-01: Backup created\n2024-01-02: Backup created'
);
const logs = await backupService.getBackupLogs();
expect(logs).toHaveLength(2);
});
it("devrait retourner une liste vide si le fichier n'existe pas", async () => {
(fs.readFile as any).mockRejectedValue(new Error('File not found'));
const logs = await backupService.getBackupLogs();
expect(logs).toHaveLength(0);
});
it('devrait limiter le nombre de lignes', async () => {
const manyLines = Array.from({ length: 200 }, (_, i) => `Line ${i}`).join(
'\n'
);
vi.mocked(fs.readFile).mockResolvedValue(manyLines);
const logs = await backupService.getBackupLogs(50);
expect(logs.length).toBeLessThanOrEqual(50);
});
});
describe('getBackupStats', () => {
it('devrait calculer les statistiques par jour', async () => {
const now = new Date('2024-01-15');
vi.useFakeTimers();
vi.setSystemTime(now);
vi.mocked(fs.readdir).mockResolvedValue([
'towercontrol_manual_2024-01-15.db.gz',
'towercontrol_automatic_2024-01-15.db.gz',
'towercontrol_manual_2024-01-14.db.gz',
] as any);
vi.mocked(BackupUtils.parseBackupFilename)
.mockReturnValueOnce({
type: 'manual',
date: new Date('2024-01-15'),
})
.mockReturnValueOnce({
type: 'automatic',
date: new Date('2024-01-15'),
})
.mockReturnValueOnce({
type: 'manual',
date: new Date('2024-01-14'),
});
const stats = await backupService.getBackupStats(7);
expect(stats.length).toBeGreaterThan(0);
const todayStats = stats.find((s) => s.date === '2024-01-15');
expect(todayStats).toBeDefined();
expect(todayStats?.manual).toBe(1);
expect(todayStats?.automatic).toBe(1);
vi.useRealTimers();
});
});
describe('getConfig', () => {
it('devrait retourner la configuration', async () => {
const config = await backupService.getConfig();
expect(config).toHaveProperty('enabled');
expect(config).toHaveProperty('interval');
expect(config).toHaveProperty('maxBackups');
expect(config).toHaveProperty('backupPath');
});
});
describe('updateConfig', () => {
it('devrait mettre à jour la configuration', async () => {
vi.mocked(prisma.userPreferences.upsert).mockResolvedValue({
userId: 'default',
} as any);
await backupService.updateConfig({ maxBackups: 10 });
expect(prisma.userPreferences.upsert).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,207 @@
/**
* Tests unitaires pour JiraAdvancedFiltersService
*/
import { describe, it, expect } from 'vitest';
import { JiraAdvancedFiltersService } from '../advanced-filters';
import { JiraTask, JiraAnalytics } from '@/lib/types';
describe('JiraAdvancedFiltersService', () => {
const mockIssues: JiraTask[] = [
{
id: '1',
key: 'TEST-1',
summary: 'Task 1',
status: { name: 'In Progress', category: 'inprogress' },
issuetype: { name: 'Bug' },
priority: { name: 'High' },
assignee: { displayName: 'John Doe', emailAddress: 'john@example.com' },
components: [{ name: 'Frontend' }],
fixVersions: [{ name: 'v1.0' }],
labels: ['urgent', 'bug'],
project: { key: 'TEST', name: 'Test Project' },
created: '2024-01-01',
updated: '2024-01-02',
},
{
id: '2',
key: 'TEST-2',
summary: 'Task 2',
status: { name: 'Done', category: 'done' },
issuetype: { name: 'Story' },
priority: { name: 'Medium' },
assignee: { displayName: 'Jane Smith', emailAddress: 'jane@example.com' },
components: [{ name: 'Backend' }],
fixVersions: [{ name: 'v2.0' }],
labels: ['feature'],
project: { key: 'TEST', name: 'Test Project' },
created: '2024-01-03',
updated: '2024-01-04',
},
];
const mockAnalytics: JiraAnalytics = {
project: {
key: 'TEST',
name: 'Test Project',
totalIssues: 2,
},
teamMetrics: {
totalAssignees: 2,
activeAssignees: 2,
issuesDistribution: [],
},
velocityMetrics: {
currentSprintPoints: 0,
averageVelocity: 0,
sprintHistory: [],
},
cycleTimeMetrics: {
averageCycleTime: 0,
cycleTimeByType: [],
},
workInProgress: {
byStatus: [],
byAssignee: [],
},
};
describe('extractAvailableFilters', () => {
it('devrait extraire tous les filtres disponibles', () => {
const filters =
JiraAdvancedFiltersService.extractAvailableFilters(mockIssues);
expect(filters.components).toHaveLength(2);
expect(filters.fixVersions).toHaveLength(2);
expect(filters.issueTypes).toHaveLength(2);
expect(filters.statuses).toHaveLength(2);
expect(filters.assignees).toHaveLength(2);
expect(filters.labels).toHaveLength(3);
expect(filters.priorities).toHaveLength(2);
});
it('devrait compter les occurrences correctement', () => {
const filters =
JiraAdvancedFiltersService.extractAvailableFilters(mockIssues);
const frontendComponent = filters.components.find(
(c) => c.value === 'Frontend'
);
expect(frontendComponent?.count).toBe(1);
const urgentLabel = filters.labels.find((l) => l.value === 'urgent');
expect(urgentLabel?.count).toBe(1);
});
it('devrait gérer les issues sans assignee', () => {
const issuesWithoutAssignee: JiraTask[] = [
{
...mockIssues[0],
assignee: undefined,
},
];
const filters = JiraAdvancedFiltersService.extractAvailableFilters(
issuesWithoutAssignee
);
const unassigned = filters.assignees.find(
(a) => a.value === 'Non assigné'
);
expect(unassigned).toBeDefined();
});
it('devrait retourner des filtres vides pour une liste vide', () => {
const filters = JiraAdvancedFiltersService.extractAvailableFilters([]);
expect(filters.components).toHaveLength(0);
expect(filters.fixVersions).toHaveLength(0);
expect(filters.issueTypes).toHaveLength(0);
});
});
describe('applyFiltersToAnalytics', () => {
it('devrait filtrer par composant', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ components: ['Frontend'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait filtrer par type de ticket', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ issueTypes: ['Bug'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait filtrer par statut', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ statuses: ['In Progress'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait filtrer par assignee', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ assignees: ['John Doe'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait filtrer par label', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ labels: ['urgent'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait appliquer plusieurs filtres simultanément', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{
components: ['Frontend'],
issueTypes: ['Bug'],
},
mockIssues
);
expect(filtered.project.totalIssues).toBe(1);
});
it('devrait retourner analytics vide si aucun match', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{ components: ['NonExistent'] },
mockIssues
);
expect(filtered.project.totalIssues).toBe(0);
});
it('devrait retourner analytics original si pas de filtres', () => {
const filtered = JiraAdvancedFiltersService.applyFiltersToAnalytics(
mockAnalytics,
{},
mockIssues
);
expect(filtered.project.totalIssues).toBe(2);
});
});
});

View File

@@ -0,0 +1,221 @@
/**
* Tests unitaires pour JiraAnalyticsCacheService
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { jiraAnalyticsCache } from '../analytics-cache';
import { JiraAnalytics } from '@/lib/types';
describe('JiraAnalyticsCacheService', () => {
const mockConfig = {
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
projectKey: 'TEST',
};
const mockAnalytics: JiraAnalytics = {
project: {
key: 'TEST',
name: 'Test Project',
totalIssues: 10,
},
teamMetrics: {
totalAssignees: 5,
activeAssignees: 5,
issuesDistribution: [],
},
velocityMetrics: {
currentSprintPoints: 0,
averageVelocity: 0,
sprintHistory: [],
},
cycleTimeMetrics: {
averageCycleTime: 0,
cycleTimeByType: [],
},
workInProgress: {
byStatus: [],
byAssignee: [],
},
};
beforeEach(() => {
jiraAnalyticsCache.invalidateAll();
vi.useFakeTimers();
});
afterEach(() => {
jiraAnalyticsCache.invalidateAll();
jiraAnalyticsCache.stopCleanupInterval();
vi.useRealTimers();
});
describe('set et get', () => {
it('devrait stocker et récupérer les analytics', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toEqual(mockAnalytics);
});
it('devrait retourner null si pas de cache', () => {
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toBeNull();
});
it('devrait utiliser un TTL personnalisé', () => {
const customTTL = 60 * 60 * 1000; // 1 heure
jiraAnalyticsCache.set(mockConfig, mockAnalytics, customTTL);
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toEqual(mockAnalytics);
});
});
describe('invalidate', () => {
it('devrait invalider le cache pour un projet spécifique', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
jiraAnalyticsCache.invalidate(mockConfig);
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toBeNull();
});
it('devrait invalider tout le cache', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
jiraAnalyticsCache.invalidateAll();
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toBeNull();
});
});
describe('expiration', () => {
it('devrait expirer après le TTL', () => {
const shortTTL = 1000; // 1 seconde
jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL);
// Avancer le temps de 2 secondes
vi.advanceTimersByTime(2000);
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toBeNull();
});
it('ne devrait pas expirer avant le TTL', () => {
const shortTTL = 5000; // 5 secondes
jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL);
// Avancer le temps de 2 secondes seulement
vi.advanceTimersByTime(2000);
const result = jiraAnalyticsCache.get(mockConfig);
expect(result).toEqual(mockAnalytics);
});
});
describe('config hash', () => {
it('devrait invalider si la config change', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const newConfig = {
...mockConfig,
apiToken: 'newtoken',
};
const result = jiraAnalyticsCache.get(newConfig);
expect(result).toBeNull();
});
it('devrait garder le cache si la config est identique', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const sameConfig = { ...mockConfig };
const result = jiraAnalyticsCache.get(sameConfig);
expect(result).toEqual(mockAnalytics);
});
});
describe('has', () => {
it('devrait retourner true si le cache existe', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const exists = jiraAnalyticsCache.has(mockConfig);
expect(exists).toBe(true);
});
it("devrait retourner false si le cache n'existe pas", () => {
const exists = jiraAnalyticsCache.has(mockConfig);
expect(exists).toBe(false);
});
});
describe('getStats', () => {
it('devrait retourner les statistiques du cache', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const stats = jiraAnalyticsCache.getStats();
expect(stats.totalEntries).toBe(1);
expect(stats.projects).toHaveLength(1);
expect(stats.projects[0].projectKey).toBe('TEST');
});
it('devrait retourner des stats vides si pas de cache', () => {
const stats = jiraAnalyticsCache.getStats();
expect(stats.totalEntries).toBe(0);
expect(stats.projects).toHaveLength(0);
});
});
describe('forceCleanup', () => {
it('devrait nettoyer les entrées expirées', () => {
const shortTTL = 1000; // 1 seconde
jiraAnalyticsCache.set(mockConfig, mockAnalytics, shortTTL);
// Avancer le temps pour expirer
vi.advanceTimersByTime(2000);
const cleanedCount = jiraAnalyticsCache.forceCleanup();
expect(cleanedCount).toBe(1);
expect(jiraAnalyticsCache.get(mockConfig)).toBeNull();
});
it('devrait retourner 0 si aucune entrée expirée', () => {
jiraAnalyticsCache.set(mockConfig, mockAnalytics);
const cleanedCount = jiraAnalyticsCache.forceCleanup();
expect(cleanedCount).toBe(0);
expect(jiraAnalyticsCache.get(mockConfig)).not.toBeNull();
});
});
describe('multiple projects', () => {
it('devrait gérer plusieurs projets indépendamment', () => {
const config1 = { ...mockConfig, projectKey: 'PROJ1' };
const config2 = { ...mockConfig, projectKey: 'PROJ2' };
jiraAnalyticsCache.set(config1, mockAnalytics);
jiraAnalyticsCache.set(config2, mockAnalytics);
expect(jiraAnalyticsCache.get(config1)).toEqual(mockAnalytics);
expect(jiraAnalyticsCache.get(config2)).toEqual(mockAnalytics);
jiraAnalyticsCache.invalidate(config1);
expect(jiraAnalyticsCache.get(config1)).toBeNull();
expect(jiraAnalyticsCache.get(config2)).toEqual(mockAnalytics);
});
});
});

View File

@@ -0,0 +1,131 @@
/**
* Tests unitaires pour JiraAnalyticsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JiraAnalyticsService } from '../analytics';
import { JiraService } from '../jira';
import { jiraAnalyticsCache } from '../analytics-cache';
// Mock de JiraService
vi.mock('../jira', () => ({
JiraService: vi.fn(),
}));
// Mock de analytics-cache
vi.mock('../analytics-cache', () => ({
jiraAnalyticsCache: {
get: vi.fn(),
set: vi.fn(),
},
}));
describe('JiraAnalyticsService', () => {
let service: JiraAnalyticsService;
let mockJiraService: any;
const mockConfig = {
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
projectKey: 'TEST',
};
beforeEach(() => {
vi.clearAllMocks();
mockJiraService = {
searchIssues: vi.fn(),
validateProject: vi.fn(),
};
vi.mocked(JiraService).mockImplementation(() => mockJiraService as any);
vi.mocked(jiraAnalyticsCache.get).mockReturnValue(null);
service = new JiraAnalyticsService(mockConfig);
});
describe('getAllProjectIssues', () => {
it('devrait récupérer toutes les issues du projet', async () => {
const mockIssues = [
{ key: 'TEST-1', title: 'Issue 1' },
{ key: 'TEST-2', title: 'Issue 2' },
];
vi.mocked(mockJiraService.searchIssues).mockResolvedValue(mockIssues);
const issues = await service.getAllProjectIssues();
expect(issues).toEqual(mockIssues);
expect(mockJiraService.searchIssues).toHaveBeenCalledWith(
'project = "TEST" ORDER BY created DESC'
);
});
it('devrait gérer les erreurs', async () => {
vi.mocked(mockJiraService.searchIssues).mockRejectedValue(
new Error('API Error')
);
await expect(service.getAllProjectIssues()).rejects.toThrow();
});
});
describe('getProjectAnalytics', () => {
it('devrait retourner les analytics depuis le cache si disponible', async () => {
const cachedAnalytics = {
project: { key: 'TEST', name: 'Test', totalIssues: 0 },
teamMetrics: {
totalAssignees: 0,
activeAssignees: 0,
issuesDistribution: [],
},
velocityMetrics: {
currentSprintPoints: 0,
sprintHistory: [],
averageVelocity: 0,
},
cycleTimeMetrics: { averageCycleTime: 0, cycleTimeByType: [] },
workInProgress: { byStatus: [], byAssignee: [] },
};
vi.mocked(jiraAnalyticsCache.get).mockReturnValue(cachedAnalytics as any);
const analytics = await service.getProjectAnalytics();
expect(analytics).toEqual(cachedAnalytics);
expect(mockJiraService.searchIssues).not.toHaveBeenCalled();
});
it('devrait forcer le refresh si demandé', async () => {
const cachedAnalytics = {
project: { key: 'TEST', name: 'Test', totalIssues: 0 },
teamMetrics: {
totalAssignees: 0,
activeAssignees: 0,
issuesDistribution: [],
},
velocityMetrics: {
currentSprintPoints: 0,
sprintHistory: [],
averageVelocity: 0,
},
cycleTimeMetrics: { averageCycleTime: 0, cycleTimeByType: [] },
workInProgress: { byStatus: [], byAssignee: [] },
};
vi.mocked(jiraAnalyticsCache.get).mockReturnValue(cachedAnalytics as any);
vi.mocked(mockJiraService.validateProject).mockResolvedValue({
exists: true,
name: 'Test Project',
});
vi.mocked(mockJiraService.searchIssues).mockResolvedValue([]);
await service.getProjectAnalytics(true);
expect(mockJiraService.searchIssues).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,220 @@
/**
* Tests unitaires pour JiraAnomalyDetectionService
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JiraAnomalyDetectionService } from '../anomaly-detection';
import { JiraAnalytics } from '@/lib/types';
import { getToday } from '@/lib/date-utils';
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getToday: vi.fn(),
}));
describe('JiraAnomalyDetectionService', () => {
let service: JiraAnomalyDetectionService;
const mockAnalytics: JiraAnalytics = {
project: {
key: 'TEST',
name: 'Test Project',
totalIssues: 10,
},
teamMetrics: {
totalAssignees: 2,
activeAssignees: 2,
issuesDistribution: [],
},
velocityMetrics: {
currentSprintPoints: 0,
sprintHistory: [
{
sprintName: 'Sprint 1',
startDate: '2024-01-01',
endDate: '2024-01-14',
completedPoints: 20,
plannedPoints: 20,
completionRate: 100,
velocity: 20,
},
{
sprintName: 'Sprint 2',
startDate: '2024-01-15',
endDate: '2024-01-28',
completedPoints: 5,
plannedPoints: 20,
completionRate: 25,
velocity: 5,
}, // Variance importante
{
sprintName: 'Sprint 3',
startDate: '2024-01-29',
endDate: '2024-02-11',
completedPoints: 25,
plannedPoints: 20,
completionRate: 125,
velocity: 25,
},
],
averageVelocity: 16.67,
},
cycleTimeMetrics: {
averageCycleTime: 12.5,
cycleTimeByType: [
{ issueType: 'Bug', averageDays: 5, medianDays: 4, samples: 10 },
{ issueType: 'Story', averageDays: 20, medianDays: 18, samples: 5 }, // Cycle time élevé
],
},
workInProgress: {
byStatus: [],
byAssignee: [
{
assignee: 'john@example.com',
displayName: 'John',
todoCount: 3,
inProgressCount: 5,
reviewCount: 0,
totalActive: 8,
},
{
assignee: 'jane@example.com',
displayName: 'Jane',
todoCount: 1,
inProgressCount: 1,
reviewCount: 0,
totalActive: 2,
},
],
},
};
beforeEach(() => {
vi.mocked(getToday).mockReturnValue(new Date('2024-01-15'));
service = new JiraAnomalyDetectionService();
});
describe('detectAnomalies', () => {
it('devrait détecter des anomalies de vélocité', async () => {
const anomalies = await service.detectAnomalies(mockAnalytics);
const velocityAnomalies = anomalies.filter((a) => a.type === 'velocity');
expect(velocityAnomalies.length).toBeGreaterThan(0);
});
it('devrait détecter des anomalies de cycle time', async () => {
const anomalies = await service.detectAnomalies(mockAnalytics);
const cycleTimeAnomalies = anomalies.filter(
(a) => a.type === 'cycle_time'
);
expect(cycleTimeAnomalies.length).toBeGreaterThanOrEqual(0);
});
it('devrait détecter des déséquilibres de charge', async () => {
const anomalies = await service.detectAnomalies(mockAnalytics);
const workloadAnomalies = anomalies.filter((a) => a.type === 'workload');
expect(workloadAnomalies.length).toBeGreaterThanOrEqual(0);
});
it('devrait trier les anomalies par sévérité', async () => {
const anomalies = await service.detectAnomalies(mockAnalytics);
const severityWeights = {
critical: 4,
high: 3,
medium: 2,
low: 1,
};
for (let i = 0; i < anomalies.length - 1; i++) {
const current = severityWeights[anomalies[i].severity];
const next = severityWeights[anomalies[i + 1].severity];
expect(current).toBeGreaterThanOrEqual(next);
}
});
it('devrait retourner une liste vide si aucune anomalie', async () => {
const cleanAnalytics: JiraAnalytics = {
...mockAnalytics,
velocityMetrics: {
currentSprintPoints: 0,
sprintHistory: [
{
sprintName: 'Sprint 1',
startDate: '2024-01-01',
endDate: '2024-01-14',
completedPoints: 20,
plannedPoints: 20,
completionRate: 100,
velocity: 20,
},
{
sprintName: 'Sprint 2',
startDate: '2024-01-15',
endDate: '2024-01-28',
completedPoints: 21,
plannedPoints: 20,
completionRate: 105,
velocity: 21,
},
{
sprintName: 'Sprint 3',
startDate: '2024-01-29',
endDate: '2024-02-11',
completedPoints: 19,
plannedPoints: 20,
completionRate: 95,
velocity: 19,
},
],
averageVelocity: 20,
},
cycleTimeMetrics: {
averageCycleTime: 5,
cycleTimeByType: [
{ issueType: 'Story', averageDays: 5, medianDays: 4, samples: 10 },
],
},
workInProgress: {
byStatus: [],
byAssignee: [
{
assignee: 'john@example.com',
displayName: 'John',
todoCount: 2,
inProgressCount: 1,
reviewCount: 0,
totalActive: 3,
},
{
assignee: 'jane@example.com',
displayName: 'Jane',
todoCount: 1,
inProgressCount: 1,
reviewCount: 0,
totalActive: 2,
},
],
},
};
const anomalies = await service.detectAnomalies(cleanAnalytics);
// Peut avoir quelques anomalies même avec des données propres selon les seuils
expect(Array.isArray(anomalies)).toBe(true);
});
});
describe('config personnalisée', () => {
it('devrait utiliser une config personnalisée', () => {
const customConfig = {
velocityVarianceThreshold: 50,
cycleTimeThreshold: 3.0,
};
const customService = new JiraAnomalyDetectionService(customConfig);
expect(customService).toBeDefined();
});
});
});

View File

@@ -0,0 +1,144 @@
/**
* Tests unitaires pour JiraService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JiraService } from '../jira';
// Mock de fetch global
global.fetch = vi.fn();
describe('JiraService', () => {
let service: JiraService;
const mockConfig = {
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
projectKey: 'TEST',
ignoredProjects: [],
};
beforeEach(() => {
vi.clearAllMocks();
service = new JiraService(mockConfig);
});
describe('testConnection', () => {
it('devrait retourner true si la connexion réussit', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as any);
const result = await service.testConnection();
expect(result).toBe(true);
expect(fetch).toHaveBeenCalled();
});
it('devrait retourner false si la connexion échoue', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 401,
text: vi.fn().mockResolvedValue('Unauthorized'),
} as any);
const result = await service.testConnection();
expect(result).toBe(false);
});
it('devrait gérer les erreurs de réseau', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
const result = await service.testConnection();
expect(result).toBe(false);
});
});
describe('validateConfig', () => {
it('devrait valider une config complète', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue(''),
} as any);
const result = await service.validateConfig();
expect(result.valid).toBe(true);
});
it('devrait rejeter une config sans baseUrl', async () => {
const invalidConfig = {
...mockConfig,
baseUrl: undefined,
};
const invalidService = new JiraService(invalidConfig as any);
const result = await invalidService.validateConfig();
expect(result.valid).toBe(false);
expect(result.error).toContain('URL de base');
});
it('devrait rejeter une config sans email', async () => {
const invalidConfig = {
...mockConfig,
email: undefined,
};
const invalidService = new JiraService(invalidConfig as any);
const result = await invalidService.validateConfig();
expect(result.valid).toBe(false);
expect(result.error).toContain('Email');
});
it('devrait rejeter une config sans apiToken', async () => {
const invalidConfig = {
...mockConfig,
apiToken: undefined,
};
const invalidService = new JiraService(invalidConfig as any);
const result = await invalidService.validateConfig();
expect(result.valid).toBe(false);
expect(result.error).toContain('Token API');
});
});
describe('validateProject', () => {
it('devrait valider un projet existant', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ name: 'Test Project' }),
} as any);
const result = await service.validateProject('TEST');
expect(result.exists).toBe(true);
expect(result.name).toBe('Test Project');
});
it('devrait détecter un projet inexistant', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
text: vi.fn().mockResolvedValue('Not found'),
} as any);
const result = await service.validateProject('INVALID');
expect(result.exists).toBe(false);
expect(result.error).toBeDefined();
});
});
});

View File

@@ -0,0 +1,333 @@
/**
* Tests unitaires pour JiraScheduler
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { JiraScheduler } from '../scheduler';
import { userPreferencesService } from '@/services/core/user-preferences';
import { JiraService } from '../jira';
import { getToday, addMinutes } from '@/lib/date-utils';
// Mock de userPreferencesService
vi.mock('@/services/core/user-preferences', () => ({
userPreferencesService: {
getJiraConfig: vi.fn(),
getJiraSchedulerConfig: vi.fn(),
},
}));
// Mock de JiraService
vi.mock('../jira', () => ({
JiraService: vi.fn(),
}));
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getToday: vi.fn(),
addMinutes: vi.fn(),
}));
describe('JiraScheduler', () => {
let scheduler: JiraScheduler;
let mockJiraService: any;
beforeEach(() => {
vi.clearAllMocks();
scheduler = new JiraScheduler();
vi.useFakeTimers();
// Mock JiraService
mockJiraService = {
testConnection: vi.fn(),
syncTasks: vi.fn(),
};
vi.mocked(JiraService).mockImplementation(() => mockJiraService as any);
});
afterEach(() => {
scheduler.stop();
vi.useRealTimers();
});
describe('start', () => {
it('devrait démarrer le scheduler si activé et configuré', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
});
it('ne devrait pas démarrer si désactivé', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: false,
jiraSyncInterval: 'hourly',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(false);
});
it('ne devrait pas démarrer si Jira non configuré', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: false,
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(false);
});
it('ne devrait pas démarrer deux fois', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
const firstCall = scheduler.isActive();
await scheduler.start('user1');
const secondCall = scheduler.isActive();
expect(firstCall).toBe(true);
expect(secondCall).toBe(true);
});
});
describe('stop', () => {
it('devrait arrêter le scheduler', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
});
describe('restart', () => {
it('devrait redémarrer le scheduler', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
await scheduler.restart('user1');
expect(scheduler.isActive()).toBe(true);
});
});
describe('isActive', () => {
it('devrait retourner false si pas démarré', () => {
expect(scheduler.isActive()).toBe(false);
});
it('devrait retourner true si démarré', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
});
});
describe('performScheduledSync', () => {
it('devrait exécuter une synchronisation automatique', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
vi.mocked(mockJiraService.testConnection).mockResolvedValue(true);
vi.mocked(mockJiraService.syncTasks).mockResolvedValue({
success: true,
stats: {
created: 5,
updated: 3,
skipped: 1,
},
} as any);
await scheduler.start('user1');
// Avancer le timer pour déclencher la synchronisation
vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure
// Attendre que la synchronisation soit exécutée
await vi.waitFor(() => {
expect(mockJiraService.syncTasks).toHaveBeenCalled();
});
scheduler.stop();
});
it('devrait gérer les erreurs de connexion', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
vi.mocked(mockJiraService.testConnection).mockResolvedValue(false);
await scheduler.start('user1');
vi.advanceTimersByTime(60 * 60 * 1000);
await vi.waitFor(() => {
expect(mockJiraService.testConnection).toHaveBeenCalled();
});
scheduler.stop();
});
});
describe('getNextSyncTime', () => {
it('devrait retourner null si pas démarré', async () => {
const nextTime = await scheduler.getNextSyncTime();
expect(nextTime).toBeNull();
});
it('devrait calculer le prochain moment de synchronisation', async () => {
const mockDate = new Date('2024-01-15T12:00:00Z');
vi.mocked(getToday).mockReturnValue(mockDate);
vi.mocked(addMinutes).mockReturnValue(new Date('2024-01-15T13:00:00Z'));
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
const nextTime = await scheduler.getNextSyncTime();
expect(nextTime).not.toBeNull();
expect(addMinutes).toHaveBeenCalled();
});
});
describe('getStatus', () => {
it('devrait retourner le statut du scheduler', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: true,
jiraSyncInterval: 'daily',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: true,
baseUrl: 'https://test.atlassian.net',
email: 'test@example.com',
apiToken: 'token123',
} as any);
await scheduler.start('user1');
const status = await scheduler.getStatus('user1');
expect(status.isRunning).toBe(true);
expect(status.isEnabled).toBe(true);
expect(status.interval).toBe('daily');
expect(status.jiraConfigured).toBe(true);
});
it('devrait retourner le statut si pas démarré', async () => {
vi.mocked(
userPreferencesService.getJiraSchedulerConfig
).mockResolvedValue({
jiraAutoSync: false,
jiraSyncInterval: 'hourly',
} as any);
vi.mocked(userPreferencesService.getJiraConfig).mockResolvedValue({
enabled: false,
} as any);
const status = await scheduler.getStatus('user1');
expect(status.isRunning).toBe(false);
expect(status.isEnabled).toBe(false);
});
});
});

View File

@@ -426,6 +426,7 @@ describe('Synchronisation Jira - Logique de préservation des champs', () => {
expect(realChanges.length).toBeGreaterThan(0);
expect(realChanges.some((c) => c.includes('Projet'))).toBe(true);
expect(preservedFields).toHaveLength(0); // Pas de préservation pour un changement de projet
});
});
});

View File

@@ -0,0 +1,335 @@
/**
* Tests unitaires pour TfsScheduler
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TfsScheduler } from '../scheduler';
import { userPreferencesService } from '@/services/core/user-preferences';
import { TfsService } from '../tfs';
import { prisma } from '@/services/core/database';
import { getToday, addMinutes } from '@/lib/date-utils';
// Mock de userPreferencesService
vi.mock('@/services/core/user-preferences', () => ({
userPreferencesService: {
getTfsConfig: vi.fn(),
getTfsSchedulerConfig: vi.fn(),
},
}));
// Mock de TfsService
vi.mock('../tfs', () => ({
TfsService: vi.fn(),
}));
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
user: {
findFirst: vi.fn(),
},
},
}));
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getToday: vi.fn(),
addMinutes: vi.fn(),
}));
describe('TfsScheduler', () => {
let scheduler: TfsScheduler;
let mockTfsService: any;
beforeEach(() => {
vi.clearAllMocks();
scheduler = new TfsScheduler();
vi.useFakeTimers();
// Mock TfsService
mockTfsService = {
testConnection: vi.fn(),
syncTasks: vi.fn(),
};
vi.mocked(TfsService).mockImplementation(() => mockTfsService as any);
});
afterEach(() => {
scheduler.stop();
vi.useRealTimers();
});
describe('start', () => {
it('devrait démarrer le scheduler si activé et configuré', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
});
it('ne devrait pas démarrer si désactivé', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: false,
tfsSyncInterval: 'hourly',
} as any
);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(false);
});
it('ne devrait pas démarrer si TFS non configuré', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: false,
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(false);
});
it('ne devrait pas démarrer deux fois', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
const firstCall = scheduler.isActive();
await scheduler.start('user1');
const secondCall = scheduler.isActive();
expect(firstCall).toBe(true);
expect(secondCall).toBe(true);
});
});
describe('stop', () => {
it('devrait arrêter le scheduler', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
scheduler.stop();
expect(scheduler.isActive()).toBe(false);
});
});
describe('restart', () => {
it('devrait redémarrer le scheduler', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
await scheduler.restart('user1');
expect(scheduler.isActive()).toBe(true);
});
});
describe('isActive', () => {
it('devrait retourner false si pas démarré', () => {
expect(scheduler.isActive()).toBe(false);
});
it('devrait retourner true si démarré', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
expect(scheduler.isActive()).toBe(true);
});
});
describe('performScheduledSync', () => {
it('devrait exécuter une synchronisation automatique', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: 'user1',
} as any);
vi.mocked(mockTfsService.testConnection).mockResolvedValue(true);
vi.mocked(mockTfsService.syncTasks).mockResolvedValue({
success: true,
pullRequestsCreated: 5,
pullRequestsUpdated: 3,
pullRequestsSkipped: 1,
} as any);
await scheduler.start('user1');
// Avancer le timer pour déclencher la synchronisation
vi.advanceTimersByTime(60 * 60 * 1000); // 1 heure
// Attendre que la synchronisation soit exécutée
await vi.waitFor(() => {
expect(mockTfsService.syncTasks).toHaveBeenCalled();
});
scheduler.stop();
});
it('devrait gérer les erreurs de connexion', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
vi.mocked(mockTfsService.testConnection).mockResolvedValue(false);
await scheduler.start('user1');
vi.advanceTimersByTime(60 * 60 * 1000);
await vi.waitFor(() => {
expect(mockTfsService.testConnection).toHaveBeenCalled();
});
scheduler.stop();
});
});
describe('getNextSyncTime', () => {
it('devrait retourner null si pas démarré', async () => {
const nextTime = await scheduler.getNextSyncTime();
expect(nextTime).toBeNull();
});
it('devrait calculer le prochain moment de synchronisation', async () => {
const mockDate = new Date('2024-01-15T12:00:00Z');
vi.mocked(getToday).mockReturnValue(mockDate);
vi.mocked(addMinutes).mockReturnValue(new Date('2024-01-15T13:00:00Z'));
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
const nextTime = await scheduler.getNextSyncTime();
expect(nextTime).not.toBeNull();
expect(addMinutes).toHaveBeenCalled();
});
});
describe('getStatus', () => {
it('devrait retourner le statut du scheduler', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: true,
tfsSyncInterval: 'daily',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
} as any);
await scheduler.start('user1');
const status = await scheduler.getStatus('user1');
expect(status.isRunning).toBe(true);
expect(status.isEnabled).toBe(true);
expect(status.interval).toBe('daily');
expect(status.tfsConfigured).toBe(true);
});
it('devrait retourner le statut si pas démarré', async () => {
vi.mocked(userPreferencesService.getTfsSchedulerConfig).mockResolvedValue(
{
tfsAutoSync: false,
tfsSyncInterval: 'hourly',
} as any
);
vi.mocked(userPreferencesService.getTfsConfig).mockResolvedValue({
enabled: false,
} as any);
const status = await scheduler.getStatus('user1');
expect(status.isRunning).toBe(false);
expect(status.isEnabled).toBe(false);
});
});
});

View File

@@ -0,0 +1,71 @@
/**
* Tests unitaires pour TfsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TfsService } from '../tfs';
// Mock de fetch global
global.fetch = vi.fn();
describe('TfsService', () => {
let service: TfsService;
const mockConfig = {
enabled: true,
organizationUrl: 'https://dev.azure.com/test',
personalAccessToken: 'token123',
repositories: [],
ignoredRepositories: [],
};
beforeEach(() => {
vi.clearAllMocks();
service = new TfsService(mockConfig);
});
describe('testConnection', () => {
it('devrait retourner true si la connexion réussit', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
status: 200,
} as any);
const result = await service.testConnection();
expect(result).toBe(true);
expect(fetch).toHaveBeenCalled();
});
it('devrait retourner false si authentification échoue', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 401,
} as any);
const result = await service.testConnection();
expect(result).toBe(false);
});
it('devrait retourner false si accès refusé', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 403,
} as any);
const result = await service.testConnection();
expect(result).toBe(false);
});
it('devrait gérer les erreurs de réseau', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
const result = await service.testConnection();
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,309 @@
/**
* Tests unitaires pour DailyService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DailyService } from '../daily';
import { prisma } from '@/services/core/database';
import {
getPreviousWorkday,
normalizeDate,
getToday,
getYesterday,
} from '@/lib/date-utils';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
dailyCheckbox: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
aggregate: vi.fn(),
},
},
}));
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getPreviousWorkday: vi.fn(),
normalizeDate: vi.fn(),
formatDateForAPI: vi.fn(),
getToday: vi.fn(),
getYesterday: vi.fn(),
}));
describe('DailyService', () => {
let service: DailyService;
const mockUserId = 'user-123';
const mockDate = new Date('2024-01-15');
beforeEach(() => {
vi.clearAllMocks();
service = new DailyService();
vi.mocked(normalizeDate).mockImplementation((date) => date);
vi.mocked(getPreviousWorkday).mockReturnValue(new Date('2024-01-14'));
vi.mocked(getToday).mockReturnValue(mockDate);
vi.mocked(getYesterday).mockReturnValue(new Date('2024-01-14'));
});
describe('getDailyView', () => {
it('devrait récupérer la vue daily pour une date', async () => {
const yesterdayCheckboxes: any[] = [];
const todayCheckboxes: any[] = [];
vi.mocked(prisma.dailyCheckbox.findMany)
.mockResolvedValueOnce(yesterdayCheckboxes)
.mockResolvedValueOnce(todayCheckboxes);
const view = await service.getDailyView(mockDate, mockUserId);
expect(view.date).toEqual(mockDate);
expect(view.yesterday).toEqual(yesterdayCheckboxes);
expect(view.today).toEqual(todayCheckboxes);
});
});
describe('getCheckboxesByDate', () => {
it('devrait récupérer les checkboxes pour une date', async () => {
const mockCheckboxes = [
{
id: 'checkbox-1',
date: mockDate,
text: 'Checkbox 1',
isChecked: false,
type: 'task',
order: 0,
taskId: null,
userId: mockUserId,
task: null,
user: { id: mockUserId },
createdAt: new Date(),
updatedAt: new Date(),
},
];
vi.mocked(prisma.dailyCheckbox.findMany).mockResolvedValue(
mockCheckboxes as any
);
const checkboxes = await service.getCheckboxesByDate(
mockDate,
mockUserId
);
expect(checkboxes).toBeDefined();
expect(prisma.dailyCheckbox.findMany).toHaveBeenCalledWith({
where: {
date: mockDate,
userId: mockUserId,
},
include: {
task: {
include: {
taskTags: {
include: {
tag: true,
},
},
primaryTag: true,
},
},
user: true,
},
orderBy: { order: 'asc' },
});
});
});
describe('addCheckbox', () => {
it('devrait ajouter une checkbox', async () => {
const mockCheckbox = {
id: 'checkbox-1',
date: mockDate,
text: 'New checkbox',
isChecked: false,
type: 'task',
order: 0,
taskId: null,
userId: mockUserId,
task: null,
user: { id: mockUserId },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.dailyCheckbox.aggregate).mockResolvedValue({
_max: { order: null },
} as any);
vi.mocked(prisma.dailyCheckbox.create).mockResolvedValue(
mockCheckbox as any
);
const checkbox = await service.addCheckbox({
date: mockDate,
text: 'New checkbox',
userId: mockUserId,
});
expect(checkbox).toBeDefined();
expect(prisma.dailyCheckbox.create).toHaveBeenCalled();
});
it("devrait utiliser l'ordre spécifié", async () => {
const mockCheckbox = {
id: 'checkbox-1',
date: mockDate,
text: 'New checkbox',
isChecked: false,
type: 'task',
order: 5,
taskId: null,
userId: mockUserId,
task: null,
user: { id: mockUserId },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.dailyCheckbox.create).mockResolvedValue(
mockCheckbox as any
);
await service.addCheckbox({
date: mockDate,
text: 'New checkbox',
userId: mockUserId,
order: 5,
});
expect(prisma.dailyCheckbox.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
order: 5,
}),
})
);
});
});
describe('updateCheckbox', () => {
it('devrait mettre à jour une checkbox', async () => {
const mockCheckbox = {
id: 'checkbox-1',
date: mockDate,
text: 'Updated checkbox',
isChecked: true,
type: 'task',
order: 0,
taskId: null,
userId: mockUserId,
task: null,
user: { id: mockUserId },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.dailyCheckbox.update).mockResolvedValue(
mockCheckbox as any
);
const checkbox = await service.updateCheckbox('checkbox-1', {
text: 'Updated checkbox',
isChecked: true,
});
expect(checkbox).toBeDefined();
expect(prisma.dailyCheckbox.update).toHaveBeenCalled();
});
});
describe('toggleCheckbox', () => {
it("devrait toggle l'état d'une checkbox", async () => {
const existingCheckbox = {
id: 'checkbox-1',
isChecked: false,
};
const updatedCheckbox = {
id: 'checkbox-1',
date: mockDate,
text: 'Checkbox',
isChecked: true,
type: 'task',
order: 0,
taskId: null,
userId: mockUserId,
task: null,
user: { id: mockUserId },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.dailyCheckbox.findUnique).mockResolvedValue(
existingCheckbox as any
);
vi.mocked(prisma.dailyCheckbox.update).mockResolvedValue(
updatedCheckbox as any
);
const checkbox = await service.toggleCheckbox('checkbox-1');
expect(checkbox.isChecked).toBe(true);
expect(prisma.dailyCheckbox.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isChecked: true,
}),
})
);
});
it("devrait lancer une erreur si la checkbox n'existe pas", async () => {
vi.mocked(prisma.dailyCheckbox.findUnique).mockResolvedValue(null);
await expect(service.toggleCheckbox('non-existent')).rejects.toThrow(
'non trouvée'
);
});
});
describe('deleteCheckbox', () => {
it('devrait supprimer une checkbox', async () => {
const existingCheckbox = {
id: 'checkbox-1',
date: mockDate,
text: 'Checkbox',
isChecked: false,
type: 'task',
order: 0,
taskId: null,
userId: mockUserId,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.dailyCheckbox.findUnique).mockResolvedValue(
existingCheckbox as any
);
vi.mocked(prisma.dailyCheckbox.delete).mockResolvedValue({} as any);
await service.deleteCheckbox('checkbox-1');
expect(prisma.dailyCheckbox.delete).toHaveBeenCalledWith({
where: { id: 'checkbox-1' },
});
});
it("devrait lancer une erreur si la checkbox n'existe pas", async () => {
vi.mocked(prisma.dailyCheckbox.findUnique).mockResolvedValue(null);
await expect(service.deleteCheckbox('non-existent')).rejects.toThrow(
'non trouvée'
);
});
});
});

View File

@@ -0,0 +1,286 @@
/**
* Tests unitaires pour tagsService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { tagsService } from '../tags';
import { prisma } from '@/services/core/database';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
tag: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
taskTag: {
deleteMany: vi.fn(),
},
},
}));
describe('tagsService', () => {
const mockUserId = 'user-123';
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTags', () => {
it('devrait récupérer tous les tags avec leur usage', async () => {
const mockTags = [
{
id: 'tag-1',
name: 'Tag 1',
color: '#ff0000',
isPinned: false,
_count: { taskTags: 5 },
},
{
id: 'tag-2',
name: 'Tag 2',
color: '#00ff00',
isPinned: true,
_count: { taskTags: 2 },
},
];
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags as any);
const tags = await tagsService.getTags(mockUserId);
expect(tags).toHaveLength(2);
expect(tags[0]).toEqual({
id: 'tag-1',
name: 'Tag 1',
color: '#ff0000',
isPinned: false,
usage: 5,
});
expect(prisma.tag.findMany).toHaveBeenCalledWith({
where: { ownerId: mockUserId },
include: {
_count: {
select: {
taskTags: true,
},
},
},
orderBy: { name: 'asc' },
});
});
});
describe('getTagById', () => {
it('devrait récupérer un tag par son ID', async () => {
const mockTag = {
id: 'tag-1',
name: 'Tag 1',
color: '#ff0000',
isPinned: false,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue(mockTag as any);
const tag = await tagsService.getTagById('tag-1', mockUserId);
expect(tag).toEqual(mockTag);
expect(prisma.tag.findFirst).toHaveBeenCalledWith({
where: {
id: 'tag-1',
ownerId: mockUserId,
},
});
});
it("devrait retourner null si le tag n'existe pas", async () => {
vi.mocked(prisma.tag.findFirst).mockResolvedValue(null);
const tag = await tagsService.getTagById('non-existent', mockUserId);
expect(tag).toBeNull();
});
});
describe('getTagByName', () => {
it('devrait récupérer un tag par son nom', async () => {
const mockTag = {
id: 'tag-1',
name: 'Tag 1',
color: '#ff0000',
isPinned: false,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue(mockTag as any);
const tag = await tagsService.getTagByName('Tag 1', mockUserId);
expect(tag).toEqual(mockTag);
expect(prisma.tag.findFirst).toHaveBeenCalledWith({
where: {
name: {
equals: 'Tag 1',
},
ownerId: mockUserId,
},
});
});
});
describe('createTag', () => {
it('devrait créer un nouveau tag', async () => {
const mockTag = {
id: 'tag-1',
name: 'New Tag',
color: '#ff0000',
isPinned: false,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue(null); // Pas de tag existant
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag as any);
const tag = await tagsService.createTag({
name: 'New Tag',
color: '#ff0000',
userId: mockUserId,
});
expect(tag).toEqual(mockTag);
expect(prisma.tag.create).toHaveBeenCalledWith({
data: {
name: 'New Tag',
color: '#ff0000',
isPinned: false,
ownerId: mockUserId,
},
});
});
it('devrait lancer une erreur si le tag existe déjà', async () => {
const existingTag = {
id: 'tag-1',
name: 'Existing Tag',
color: '#ff0000',
isPinned: false,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue(existingTag as any);
await expect(
tagsService.createTag({
name: 'Existing Tag',
color: '#ff0000',
userId: mockUserId,
})
).rejects.toThrow('existe déjà');
});
it('devrait trimmer le nom du tag', async () => {
const mockTag = {
id: 'tag-1',
name: 'Trimmed Tag',
color: '#ff0000',
isPinned: false,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue(null);
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag as any);
await tagsService.createTag({
name: ' Trimmed Tag ',
color: '#ff0000',
userId: mockUserId,
});
expect(prisma.tag.create).toHaveBeenCalledWith({
data: {
name: 'Trimmed Tag',
color: '#ff0000',
isPinned: false,
ownerId: mockUserId,
},
});
});
});
describe('updateTag', () => {
it('devrait mettre à jour un tag', async () => {
const mockTag = {
id: 'tag-1',
name: 'Updated Tag',
color: '#00ff00',
isPinned: true,
};
vi.mocked(prisma.tag.findFirst).mockResolvedValue({
id: 'tag-1',
name: 'Old Tag',
color: '#ff0000',
isPinned: false,
} as any);
vi.mocked(prisma.tag.update).mockResolvedValue(mockTag as any);
const tag = await tagsService.updateTag('tag-1', mockUserId, {
name: 'Updated Tag',
color: '#00ff00',
isPinned: true,
});
expect(tag).toEqual(mockTag);
expect(prisma.tag.update).toHaveBeenCalledWith({
where: { id: 'tag-1' },
data: {
name: 'Updated Tag',
color: '#00ff00',
isPinned: true,
},
});
});
it("devrait lancer une erreur si le tag n'existe pas", async () => {
vi.mocked(prisma.tag.findFirst).mockResolvedValue(null);
await expect(
tagsService.updateTag('non-existent', mockUserId, {
name: 'Updated Tag',
})
).rejects.toThrow('non trouvé');
});
});
describe('deleteTag', () => {
it('devrait supprimer un tag', async () => {
vi.mocked(prisma.tag.findFirst).mockResolvedValue({
id: 'tag-1',
name: 'Tag to delete',
color: '#ff0000',
isPinned: false,
} as any);
vi.mocked(prisma.taskTag.deleteMany).mockResolvedValue({
count: 0,
} as any);
vi.mocked(prisma.tag.delete).mockResolvedValue({} as any);
await tagsService.deleteTag('tag-1', mockUserId);
expect(prisma.taskTag.deleteMany).toHaveBeenCalledWith({
where: { tagId: 'tag-1' },
});
expect(prisma.tag.delete).toHaveBeenCalledWith({
where: { id: 'tag-1' },
});
});
it("devrait lancer une erreur si le tag n'existe pas", async () => {
vi.mocked(prisma.tag.findFirst).mockResolvedValue(null);
await expect(
tagsService.deleteTag('non-existent', mockUserId)
).rejects.toThrow('non trouvé');
});
});
});

View File

@@ -0,0 +1,436 @@
/**
* Tests unitaires pour TasksService
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TasksService } from '../tasks';
import { prisma } from '@/services/core/database';
import { tagsService } from '../tags';
import { getReadonlyFieldsForTask } from '../readonly-fields';
import { getToday } from '@/lib/date-utils';
// Mock de prisma
vi.mock('@/services/core/database', () => ({
prisma: {
task: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
aggregate: vi.fn(),
},
taskTag: {
createMany: vi.fn(),
deleteMany: vi.fn(),
findMany: vi.fn(),
},
dailyCheckbox: {
findMany: vi.fn(),
},
},
}));
// Mock de tagsService
vi.mock('../tags', () => ({
tagsService: {
ensureTagsExist: vi.fn(),
},
}));
// Mock de readonly-fields
vi.mock('../readonly-fields', () => ({
getReadonlyFieldsForTask: vi.fn(),
}));
// Mock de date-utils
vi.mock('@/lib/date-utils', () => ({
getToday: vi.fn(),
}));
describe('TasksService', () => {
let service: TasksService;
const mockUserId = 'user-123';
beforeEach(() => {
vi.clearAllMocks();
service = new TasksService();
vi.mocked(getToday).mockReturnValue(new Date('2024-01-15'));
});
describe('getTasks', () => {
it('devrait récupérer toutes les tâches pour un utilisateur', async () => {
const mockTasks = [
{
id: 'task-1',
title: 'Task 1',
description: null,
status: 'todo',
priority: 'medium',
source: 'manual',
sourceId: 'manual-123',
ownerId: mockUserId,
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: null,
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
_count: { dailyCheckboxes: 0 },
},
];
vi.mocked(prisma.task.findMany).mockResolvedValue(mockTasks as any);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
const tasks = await service.getTasks(mockUserId);
expect(tasks).toHaveLength(1);
expect(prisma.task.findMany).toHaveBeenCalledWith({
where: { ownerId: mockUserId },
include: {
taskTags: {
include: {
tag: true,
},
},
primaryTag: true,
_count: {
select: {
dailyCheckboxes: true,
},
},
},
take: undefined,
skip: 0,
orderBy: [
{ completedAt: 'desc' },
{ dueDate: 'asc' },
{ createdAt: 'desc' },
],
});
});
it('devrait filtrer par statut', async () => {
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
await service.getTasks(mockUserId, { status: ['todo', 'in_progress'] });
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { in: ['todo', 'in_progress'] },
}),
})
);
});
it('devrait rechercher dans le titre et la description', async () => {
vi.mocked(prisma.task.findMany).mockResolvedValue([]);
await service.getTasks(mockUserId, { search: 'test' });
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ title: { contains: 'test' } },
{ description: { contains: 'test' } },
],
}),
})
);
});
});
describe('createTask', () => {
it('devrait créer une nouvelle tâche', async () => {
const mockTask = {
id: 'task-1',
title: 'New Task',
description: 'Description',
status: 'todo',
priority: 'medium',
ownerId: mockUserId,
source: 'manual',
sourceId: 'manual-123',
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: null,
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
};
vi.mocked(prisma.task.create).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.findUnique).mockResolvedValue(mockTask as any);
vi.mocked(tagsService.ensureTagsExist).mockResolvedValue([]);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
const task = await service.createTask({
title: 'New Task',
description: 'Description',
ownerId: mockUserId,
});
expect(task).toBeDefined();
expect(prisma.task.create).toHaveBeenCalled();
});
it('devrait définir completedAt si le statut est done', async () => {
const mockTask = {
id: 'task-1',
title: 'Done Task',
description: null,
status: 'done',
priority: 'medium',
ownerId: mockUserId,
source: 'manual',
sourceId: 'manual-123',
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: new Date('2024-01-15'),
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
};
vi.mocked(prisma.task.create).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.findUnique).mockResolvedValue(mockTask as any);
vi.mocked(tagsService.ensureTagsExist).mockResolvedValue([]);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
await service.createTask({
title: 'Done Task',
status: 'done',
ownerId: mockUserId,
});
expect(prisma.task.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
completedAt: expect.any(Date),
}),
})
);
});
});
describe('updateTask', () => {
it('devrait mettre à jour une tâche', async () => {
const mockTask = {
id: 'task-1',
title: 'Updated Task',
description: null,
status: 'in_progress',
priority: 'medium',
ownerId: mockUserId,
source: 'manual',
sourceId: 'manual-123',
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: null,
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
};
vi.mocked(prisma.task.findFirst).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.update).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.findUnique).mockResolvedValue(mockTask as any);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
const task = await service.updateTask(mockUserId, 'task-1', {
title: 'Updated Task',
status: 'in_progress',
});
expect(task).toBeDefined();
expect(prisma.task.update).toHaveBeenCalled();
});
it('devrait définir completedAt quand le statut passe à done', async () => {
const mockTask = {
id: 'task-1',
title: 'Task',
description: null,
status: 'todo',
priority: 'medium',
ownerId: mockUserId,
source: 'manual',
sourceId: 'manual-123',
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: null,
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
};
vi.mocked(prisma.task.findFirst).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.update).mockResolvedValue({
...mockTask,
status: 'done',
completedAt: new Date('2024-01-15'),
} as any);
vi.mocked(prisma.task.findUnique).mockResolvedValue({
...mockTask,
status: 'done',
completedAt: new Date('2024-01-15'),
} as any);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
await service.updateTask(mockUserId, 'task-1', {
status: 'done',
});
expect(prisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
completedAt: expect.any(Date),
}),
})
);
});
it("devrait lancer une erreur si la tâche n'existe pas", async () => {
vi.mocked(prisma.task.findFirst).mockResolvedValue(null);
await expect(
service.updateTask(mockUserId, 'non-existent', {
title: 'Updated',
})
).rejects.toThrow('introuvable');
});
});
describe('deleteTask', () => {
it('devrait supprimer une tâche', async () => {
const mockTask = {
id: 'task-1',
title: 'Task to delete',
ownerId: mockUserId,
};
vi.mocked(prisma.task.findFirst).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.delete).mockResolvedValue({} as any);
await service.deleteTask(mockUserId, 'task-1');
expect(prisma.task.delete).toHaveBeenCalledWith({
where: { id: 'task-1' },
});
});
it("devrait lancer une erreur si la tâche n'existe pas", async () => {
vi.mocked(prisma.task.findFirst).mockResolvedValue(null);
await expect(
service.deleteTask(mockUserId, 'non-existent')
).rejects.toThrow('introuvable');
});
});
describe('updateTaskStatus', () => {
it("devrait mettre à jour le statut d'une tâche", async () => {
const mockTask = {
id: 'task-1',
title: 'Task',
description: null,
status: 'todo',
priority: 'medium',
ownerId: mockUserId,
source: 'manual',
sourceId: 'manual-123',
createdAt: new Date(),
updatedAt: new Date(),
dueDate: null,
completedAt: null,
primaryTagId: null,
jiraProject: null,
jiraKey: null,
jiraType: null,
tfsProject: null,
tfsPullRequestId: null,
tfsRepository: null,
tfsSourceBranch: null,
tfsTargetBranch: null,
assignee: null,
taskTags: [],
primaryTag: null,
};
vi.mocked(prisma.task.findFirst).mockResolvedValue(mockTask as any);
vi.mocked(prisma.task.update).mockResolvedValue({
...mockTask,
status: 'in_progress',
} as any);
vi.mocked(prisma.task.findUnique).mockResolvedValue({
...mockTask,
status: 'in_progress',
} as any);
vi.mocked(getReadonlyFieldsForTask).mockReturnValue([]);
const task = await service.updateTaskStatus(
mockUserId,
'task-1',
'in_progress'
);
expect(task).toBeDefined();
expect(prisma.task.update).toHaveBeenCalled();
});
});
});