mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-18 12:49:29 +02:00
Merge pull request #167 from R0m1k3/antigravity
feat: Add recurring tasks management (frontend, backend, automation)
This commit is contained in:
822 files changed
+133208
-787
No files matched your search
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Calendar, RefreshCw, Trash2, Edit, Play } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import RecurringTodosService, { RecurringTodo } from '@/services/RecurringTodosService';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const RecurringTodosAdmin = () => {
|
||||
const [recurringTodos, setRecurringTodos] = useState<RecurringTodo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const [editingTodo, setEditingTodo] = useState<RecurringTodo | null>(null);
|
||||
|
||||
// Form state
|
||||
const [text, setText] = useState('');
|
||||
const [recurrenceType, setRecurrenceType] = useState<RecurringTodo['recurrence_type']>('weekly');
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||
const [dayOfWeek, setDayOfWeek] = useState(1); // 1 = Monday
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [priority, setPriority] = useState(false);
|
||||
const [nextOccurrence, setNextOccurrence] = useState(new Date().toISOString().split('T')[0]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecurringTodos();
|
||||
}, []);
|
||||
|
||||
const loadRecurringTodos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await RecurringTodosService.getAll();
|
||||
setRecurringTodos(data);
|
||||
} catch (error) {
|
||||
toast.error("Erreur lors du chargement des tâches récurrentes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (todo: RecurringTodo | null = null) => {
|
||||
if (todo) {
|
||||
setEditingTodo(todo);
|
||||
setText(todo.text);
|
||||
setRecurrenceType(todo.recurrence_type);
|
||||
setRecurrenceInterval(todo.recurrence_interval);
|
||||
setDayOfWeek(todo.day_of_week || 1);
|
||||
setDayOfMonth(todo.day_of_month || 1);
|
||||
setPriority(todo.priority);
|
||||
setNextOccurrence(new Date(todo.next_occurrence).toISOString().split('T')[0]);
|
||||
} else {
|
||||
setEditingTodo(null);
|
||||
setText('');
|
||||
setRecurrenceType('weekly');
|
||||
setRecurrenceInterval(1);
|
||||
setDayOfWeek(1); // Monday
|
||||
setDayOfMonth(1);
|
||||
setPriority(false);
|
||||
setNextOccurrence(new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
setOpenModal(true);
|
||||
};
|
||||
|
||||
const calculateNextDate = () => {
|
||||
// Basic logic to guess next date based on type if user changes type
|
||||
// Not strictly implementing full logic here, just default to today or let user pick
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const payload: any = {
|
||||
text,
|
||||
recurrence_type: recurrenceType,
|
||||
recurrence_interval: recurrenceInterval,
|
||||
priority,
|
||||
next_occurrence: nextOccurrence
|
||||
};
|
||||
|
||||
if (recurrenceType === 'weekly' || recurrenceType === 'biweekly') {
|
||||
payload.day_of_week = dayOfWeek;
|
||||
}
|
||||
if (recurrenceType === 'monthly' || recurrenceType === 'yearly') {
|
||||
payload.day_of_month = dayOfMonth;
|
||||
}
|
||||
|
||||
if (editingTodo) {
|
||||
await RecurringTodosService.update(editingTodo.id!, payload);
|
||||
toast.success("Tâche récurrente modifiée");
|
||||
} else {
|
||||
await RecurringTodosService.create(payload);
|
||||
toast.success("Tâche récurrente créée");
|
||||
}
|
||||
setOpenModal(false);
|
||||
loadRecurringTodos();
|
||||
} catch (error) {
|
||||
toast.error("Erreur lors de l'enregistrement");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer cette tâche récurrente ?")) return;
|
||||
try {
|
||||
await RecurringTodosService.delete(id);
|
||||
toast.success("Tâche supprimée");
|
||||
loadRecurringTodos();
|
||||
} catch (error) {
|
||||
toast.error("Erreur lors de la suppression");
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
const result = await RecurringTodosService.generateNow();
|
||||
toast.success(`${result.generated} tâche(s) générée(s)`);
|
||||
if (result.generated > 0) {
|
||||
// Also refresh recurring list to see updated next_occurrences
|
||||
loadRecurringTodos();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Erreur lors de la génération");
|
||||
}
|
||||
};
|
||||
|
||||
const formatRecurrence = (todo: RecurringTodo) => {
|
||||
const intervalStr = todo.recurrence_interval > 1 ? `Tous les ${todo.recurrence_interval} ` : 'Chaque ';
|
||||
|
||||
switch (todo.recurrence_type) {
|
||||
case 'daily': return todo.recurrence_interval > 1 ? `Tous les ${todo.recurrence_interval} jours` : 'Quotidien';
|
||||
case 'weekly': return `${todo.recurrence_interval > 1 ? intervalStr + 'semaines' : 'Hebdomadaire'} (J${todo.day_of_week})`;
|
||||
case 'biweekly': return 'Bi-mensuel (1 semaine sur 2)';
|
||||
case 'monthly': return `${todo.recurrence_interval > 1 ? intervalStr + 'mois' : 'Mensuel'} (le ${todo.day_of_month})`;
|
||||
case 'yearly': return `${todo.recurrence_interval > 1 ? intervalStr + 'ans' : 'Annuel'} (le ${todo.day_of_month})`;
|
||||
default: return todo.recurrence_type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button className="flex items-center gap-2" onClick={() => handleOpenModal()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle tâche récurrente
|
||||
</Button>
|
||||
<Button variant="outline" className="flex items-center gap-2" onClick={handleGenerate}>
|
||||
<Play className="h-4 w-4" />
|
||||
Générer maintenant (Test)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{recurringTodos.map(todo => (
|
||||
<Card key={todo.id}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{todo.text}</span>
|
||||
{todo.priority && <Badge variant="destructive" className="text-xs px-1 py-0 h-5">Prioritaire</Badge>}
|
||||
{!todo.enabled && <Badge variant="secondary" className="text-xs px-1 py-0 h-5">Désactivé</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{formatRecurrence(todo)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Prochaine: {new Date(todo.next_occurrence).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleOpenModal(todo)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Modifier
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleDelete(todo.id!)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{recurringTodos.length === 0 && !loading && (
|
||||
<p className="text-center text-muted-foreground py-8">Aucune tâche récurrente configurée</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTodo ? 'Modifier la tâche récurrente' : 'Nouvelle tâche récurrente'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="text" className="text-right">Tâche</Label>
|
||||
<Input id="text" value={text} onChange={(e) => setText(e.target.value)} className="col-span-3" placeholder="Ex: Payer loyer" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">Priorité</Label>
|
||||
<div className="flex items-center space-x-2 col-span-3">
|
||||
<Checkbox id="priority" checked={priority} onCheckedChange={(c) => setPriority(!!c)} />
|
||||
<label htmlFor="priority" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Haute priorité (sera marqué !)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="type" className="text-right">Fréquence</Label>
|
||||
<Select value={recurrenceType} onValueChange={(v: any) => setRecurrenceType(v)}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Choisir..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Quotidien</SelectItem>
|
||||
<SelectItem value="weekly">Hebdomadaire</SelectItem>
|
||||
<SelectItem value="biweekly">Bi-mensuel (2 sem)</SelectItem>
|
||||
<SelectItem value="monthly">Mensuel</SelectItem>
|
||||
<SelectItem value="yearly">Annuel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="interval" className="text-right">Intervalle</Label>
|
||||
<Input id="interval" type="number" min="1" value={recurrenceInterval} onChange={(e) => setRecurrenceInterval(parseInt(e.target.value))} className="col-span-3" />
|
||||
</div>
|
||||
|
||||
{(recurrenceType === 'weekly' || recurrenceType === 'biweekly') && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="dayOfWeek" className="text-right">Jour Sem.</Label>
|
||||
<Select value={dayOfWeek.toString()} onValueChange={(v) => setDayOfWeek(parseInt(v))}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Lundi</SelectItem>
|
||||
<SelectItem value="2">Mardi</SelectItem>
|
||||
<SelectItem value="3">Mercredi</SelectItem>
|
||||
<SelectItem value="4">Jeudi</SelectItem>
|
||||
<SelectItem value="5">Vendredi</SelectItem>
|
||||
<SelectItem value="6">Samedi</SelectItem>
|
||||
<SelectItem value="0">Dimanche</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(recurrenceType === 'monthly' || recurrenceType === 'yearly') && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="dayOfMonth" className="text-right">Jour Mois</Label>
|
||||
<Input id="dayOfMonth" type="number" min="1" max="31" value={dayOfMonth} onChange={(e) => setDayOfMonth(parseInt(e.target.value))} className="col-span-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="nextDate" className="text-right">Prochaine</Label>
|
||||
<Input id="nextDate" type="date" value={nextOccurrence} onChange={(e) => setNextOccurrence(e.target.value)} className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleSubmit}>Enregistrer</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
const recurringTodosController = require('../../controllers/recurringTodosController');
|
||||
const logger = require('../../config/logger');
|
||||
|
||||
function startScheduler() {
|
||||
logger.info('Démarrage du planificateur de tâches...');
|
||||
|
||||
// Vérification toutes les heures
|
||||
setInterval(async () => {
|
||||
try {
|
||||
logger.debug('Vérification périodique des tâches récurrentes...');
|
||||
const result = await recurringTodosController.generateTodos(); // All users
|
||||
if (result.generated > 0) {
|
||||
logger.info(`Tâches récurrentes auto-générées: ${result.generated}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur dans le planificateur:', error);
|
||||
}
|
||||
}, 60 * 60 * 1000); // 1 heure
|
||||
|
||||
// Allow start check after a slight delay to ensure DB is ready
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await recurringTodosController.generateTodos();
|
||||
if (result.generated > 0) {
|
||||
logger.info(`Tâches récurrentes auto-générées (démarrage): ${result.generated}`);
|
||||
} else {
|
||||
logger.info('Aucune tâche récurrente à générer au démarrage.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur dans le planificateur (démarrage):', error);
|
||||
}
|
||||
}, 5000); // 5 sec delay
|
||||
}
|
||||
|
||||
module.exports = { startScheduler };
|
||||
+7
-1
@@ -45,6 +45,7 @@ import { StatsDashboard } from "@/components/StatsDashboard";
|
||||
import { StatsModal } from "@/components/StatsModal";
|
||||
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp";
|
||||
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts";
|
||||
import { RecurringTodosAdmin } from "@/components/admin/RecurringTodosAdmin";
|
||||
|
||||
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
||||
|
||||
@@ -2232,11 +2233,12 @@ const Index = () => {
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={adminTab} onValueChange={setAdminTab}>
|
||||
<TabsList className="grid grid-cols-5 w-full">
|
||||
<TabsList className="grid grid-cols-6 w-full">
|
||||
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
|
||||
<TabsTrigger value="rss">Flux RSS</TabsTrigger>
|
||||
<TabsTrigger value="calendar">Google Calendar</TabsTrigger>
|
||||
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
|
||||
<TabsTrigger value="recurring">Récurrentes</TabsTrigger>
|
||||
<TabsTrigger value="settings">Paramètres</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -2506,6 +2508,10 @@ const Index = () => {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recurring" className="mt-4">
|
||||
<RecurringTodosAdmin />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface RecurringTodo {
|
||||
id?: number;
|
||||
text: string;
|
||||
recurrence_type: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly';
|
||||
recurrence_interval: number;
|
||||
day_of_week?: number;
|
||||
day_of_month?: number;
|
||||
priority: boolean;
|
||||
next_occurrence: string;
|
||||
last_generated?: string;
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
class RecurringTodosService {
|
||||
private baseURL = '/api/recurring-todos';
|
||||
|
||||
private getAuthHeader() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getAll(): Promise<RecurringTodo[]> {
|
||||
try {
|
||||
const response = await axios.get(this.baseURL, this.getAuthHeader());
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching recurring todos:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(data: Partial<RecurringTodo>): Promise<RecurringTodo> {
|
||||
try {
|
||||
const response = await axios.post(this.baseURL, data, this.getAuthHeader());
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating recurring todo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<RecurringTodo>): Promise<RecurringTodo> {
|
||||
try {
|
||||
const response = await axios.put(`${this.baseURL}/${id}`, data, this.getAuthHeader());
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating recurring todo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
try {
|
||||
await axios.delete(`${this.baseURL}/${id}`, this.getAuthHeader());
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting recurring todo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateNow(): Promise<{ generated: number }> {
|
||||
try {
|
||||
const response = await axios.post(`${this.baseURL}/generate`, {}, this.getAuthHeader());
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error generating recurring todos:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new RecurringTodosService();
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
|
||||
|
||||
// Mock toast utils
|
||||
vi.mock('@/utils/toast', () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
// We need to import AuthService after mocking localStorage
|
||||
import AuthService from '../AuthService'
|
||||
|
||||
describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('should return false when no token is stored', () => {
|
||||
expect(AuthService.isAuthenticated()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUser', () => {
|
||||
it('should return null when no user is stored', () => {
|
||||
expect(AuthService.getUser()).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return null/undefined when no token is stored', () => {
|
||||
expect(AuthService.getToken()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHeaders', () => {
|
||||
it('should return headers with Authorization and Content-Type', () => {
|
||||
const headers = AuthService.getHeaders()
|
||||
expect(headers).toHaveProperty('Authorization')
|
||||
expect(headers).toHaveProperty('Content-Type', 'application/json')
|
||||
})
|
||||
|
||||
it('should include Bearer token in Authorization header', () => {
|
||||
const headers = AuthService.getHeaders()
|
||||
expect(headers.Authorization).toMatch(/^Bearer /)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('should return false on failed login', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Invalid credentials' }),
|
||||
})
|
||||
|
||||
const result = await AuthService.login('wrong', 'password')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true and store token on successful login', async () => {
|
||||
const mockUser = { id: 1, username: 'admin', is_admin: true }
|
||||
const mockToken = 'test-jwt-token'
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: mockToken, user: mockUser }),
|
||||
})
|
||||
|
||||
const result = await AuthService.login('admin', 'password')
|
||||
expect(result).toBe(true)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('token', mockToken)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('user', JSON.stringify(mockUser))
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('should remove token and user from localStorage', () => {
|
||||
AuthService.logout()
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('token')
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkTokenValidity', () => {
|
||||
it('should return false when response is not ok', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false })
|
||||
|
||||
const result = await AuthService.checkTokenValidity()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock AuthService
|
||||
vi.mock('../AuthService', () => ({
|
||||
default: {
|
||||
getHeaders: vi.fn().mockReturnValue({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock toast utils
|
||||
vi.mock('@/utils/toast', () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
import NotesService from '../NotesService'
|
||||
|
||||
describe('NotesService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getNotes', () => {
|
||||
it('should fetch notes and return them with default arrays', async () => {
|
||||
const mockNotes = [
|
||||
{ id: 1, title: 'Note 1', content: 'Content 1' },
|
||||
{ id: 2, title: 'Note 2', content: 'Content 2', todos: [{ id: 1, text: 'Task', completed: false }] },
|
||||
]
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockNotes),
|
||||
})
|
||||
|
||||
const result = await NotesService.getNotes()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/notes?archived=false',
|
||||
expect.objectContaining({
|
||||
headers: expect.any(Object),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].todos).toEqual([])
|
||||
expect(result[0].images).toEqual([])
|
||||
expect(result[1].todos).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
})
|
||||
|
||||
const result = await NotesService.getNotes()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCounts', () => {
|
||||
it('should return counts from API', async () => {
|
||||
const mockCounts = { active: 5, archived: 2 }
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockCounts),
|
||||
})
|
||||
|
||||
const result = await NotesService.getCounts()
|
||||
expect(result).toEqual(mockCounts)
|
||||
})
|
||||
|
||||
it('should return zero counts on error', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
})
|
||||
|
||||
const result = await NotesService.getCounts()
|
||||
expect(result).toEqual({ active: 0, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note and return it', async () => {
|
||||
const mockNote = { id: 1, title: 'New Note', content: '', todos: [], images: [] }
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockNote),
|
||||
})
|
||||
|
||||
const result = await NotesService.createNote('New Note')
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'New Note', content: '', todos: [] }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(mockNote)
|
||||
})
|
||||
|
||||
it('should return null on error', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false })
|
||||
|
||||
const result = await NotesService.createNote('Test')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete a note and return true', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ message: 'Deleted' }),
|
||||
})
|
||||
|
||||
const result = await NotesService.deleteNote(1)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/notes/1',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false on error', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false })
|
||||
|
||||
const result = await NotesService.deleteNote(1)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateNote', () => {
|
||||
it('should return null if note has no id', async () => {
|
||||
const result = await NotesService.updateNote({ title: 'Test', content: '', todos: [], images: [] })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should update note and return it', async () => {
|
||||
const note = { id: 1, title: 'Updated', content: 'New content', todos: [], images: [] }
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ note }),
|
||||
})
|
||||
|
||||
const result = await NotesService.updateNote(note)
|
||||
expect(result).toEqual(note)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock window.matchMedia for components that use media queries
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => { },
|
||||
removeListener: () => { },
|
||||
addEventListener: () => { },
|
||||
removeEventListener: () => { },
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class IntersectionObserverMock {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: IntersectionObserverMock,
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'ResizeObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: ResizeObserverMock,
|
||||
})
|
||||
Reference in new issue
Block a user