diff --git a/app/(dashboard)/tasks/actions.ts b/app/(dashboard)/tasks/actions.ts index b5b0eb1..545f336 100644 --- a/app/(dashboard)/tasks/actions.ts +++ b/app/(dashboard)/tasks/actions.ts @@ -189,3 +189,48 @@ export async function getTeamStats() { }; } } + +// Bulk update task status +export async function bulkUpdateTaskStatus(taskIds: number[], status: "todo" | "in_progress" | "review" | "done") { + const user = await getCurrentUser(); + if (!user) return { error: "Not authenticated.", success: false }; + + if (!taskIds || taskIds.length === 0) { + return { error: "No tasks selected.", success: false }; + } + + try { + await prisma.task.updateMany({ + where: { + id: { in: taskIds }, + }, + data: { status }, + }); + revalidatePath("/tasks"); + return { error: null, success: true, message: `Updated ${taskIds.length} task(s) successfully!` }; + } catch (e) { + return { error: "Failed to update tasks.", success: false }; + } +} + +// Bulk delete tasks +export async function bulkDeleteTasks(taskIds: number[]) { + const user = await getCurrentUser(); + if (!user) return { error: "Not authenticated.", success: false }; + + if (!taskIds || taskIds.length === 0) { + return { error: "No tasks selected.", success: false }; + } + + try { + await prisma.task.deleteMany({ + where: { + id: { in: taskIds }, + }, + }); + revalidatePath("/tasks"); + return { error: null, success: true, message: `Deleted ${taskIds.length} task(s) successfully!` }; + } catch (e) { + return { error: "Failed to delete tasks.", success: false }; + } +} diff --git a/components/task-list.tsx b/components/task-list.tsx index 9f80933..90bacdb 100644 --- a/components/task-list.tsx +++ b/components/task-list.tsx @@ -6,10 +6,10 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Checkbox } from "@/components/ui/checkbox" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" -import { MoreHorizontal, Clock, Edit, Trash2 } from "lucide-react" -import { deleteTask, updateTaskStatus } from "@/app/(dashboard)/tasks/actions" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription } from "@/components/ui/dialog" +import { MoreHorizontal, Clock, Edit, Trash2, CheckSquare } from "lucide-react" +import { deleteTask, updateTaskStatus, bulkUpdateTaskStatus, bulkDeleteTasks } from "@/app/(dashboard)/tasks/actions" import { formatDateForDisplay } from "@/lib/date-utils" import { EditTaskForm } from "./edit-task-form" import { poppins } from "@/lib/fonts" @@ -24,23 +24,29 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; }) const [tasks, setTasks] = useState(initialTasks) const [optimisticTasks, setOptimisticTasks] = useOptimistic( tasks, - (state, { action, task }: { action: "delete" | "toggle"; task: TaskWithProfile | { id: number } }) => { - if (action === "delete") { + (state, { action, task }: { action: "delete" | "toggle" | "bulkDelete"; task?: TaskWithProfile | { id: number }; taskIds?: number[] }) => { + if (action === "delete" && task) { return state.filter((t) => t.id !== task.id) } - if (action === "toggle") { + if (action === "toggle" && task) { return state.map((t) => (t.id === task.id ? { ...t, status: t.status === "done" ? "todo" : "done" } : t)) } + if (action === "bulkDelete" && taskIds) { + return state.filter((t) => !taskIds.includes(t.id)) + } return state }, ) const [isPending, startTransition] = useTransition() const [openDialogs, setOpenDialogs] = useState>({}) const [openDropdowns, setOpenDropdowns] = useState>({}) + const [selectedTaskIds, setSelectedTaskIds] = useState([]) + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false) // Sync state with incoming props useEffect(() => { setTasks(initialTasks) + setSelectedTaskIds([]) // Clear selection when tasks change }, [initialTasks]) const handleDelete = async (taskId: number) => { @@ -66,6 +72,46 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; }) setOpenDialogs(prev => ({ ...prev, [taskId]: true })) } + const handleTaskSelection = (taskId: number, checked: boolean) => { + if (checked) { + setSelectedTaskIds(prev => [...prev, taskId]) + } else { + setSelectedTaskIds(prev => prev.filter(id => id !== taskId)) + } + } + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedTaskIds(optimisticTasks.map(t => t.id)) + } else { + setSelectedTaskIds([]) + } + } + + const handleBulkMarkAsDone = async () => { + startTransition(async () => { + // Optimistic update: mark selected tasks as done + const updatedTasks = optimisticTasks.map(t => + selectedTaskIds.includes(t.id) ? { ...t, status: "done" } : t + ) + await bulkUpdateTaskStatus(selectedTaskIds, "done") + setSelectedTaskIds([]) + }) + } + + const handleBulkDelete = async () => { + startTransition(async () => { + setOptimisticTasks({ action: "bulkDelete", taskIds: selectedTaskIds }) + await bulkDeleteTasks(selectedTaskIds) + setSelectedTaskIds([]) + setShowBulkDeleteDialog(false) + }) + } + + const handleDeselectAll = () => { + setSelectedTaskIds([]) + } + const getInitials = (name: string | null) => { if (!name) return "??" return name @@ -77,6 +123,74 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; }) return (
+ {/* Bulk Actions Bar */} + {selectedTaskIds.length > 0 && ( + + +
+
+ + {selectedTaskIds.length} task(s) selected + + +
+
+ + + + + + + + Mark as Done + + + setShowBulkDeleteDialog(true)} + className="cursor-pointer text-primary" + data-testid="bulk-delete" + > + + Delete + + + +
+
+
+
+ )} + + {/* Select All Checkbox */} + {optimisticTasks.length > 0 && ( +
+ 0} + onCheckedChange={handleSelectAll} + data-testid="select-all-checkbox" + /> + +
+ )} + + {/* Task List */} {optimisticTasks.map((task) => ( setOpenDialogs(prev => ({ ...prev, [task.id]: open })) @@ -85,10 +199,19 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; })
+ {/* Task Selection Checkbox */} + handleTaskSelection(task.id, checked as boolean)} + className="mt-1 cursor-pointer" + data-testid={`task-select-${task.id}`} + /> + {/* Status Toggle Checkbox */} handleToggle(task)} className="mt-1 cursor-pointer" + data-testid={`task-status-${task.id}`} />
@@ -165,6 +288,34 @@ export function TaskList({ initialTasks }: { initialTasks: TaskWithProfile[]; })
))} + + {/* Bulk Delete Confirmation Dialog */} + + + + Confirm Bulk Delete + + Are you sure you want to delete {selectedTaskIds.length} task(s)? This action cannot be undone. + + + + + + + +
) } diff --git a/package-lock.json b/package-lock.json index 080a5fc..b0bf4a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,7 +114,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2070,7 +2069,6 @@ "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.56.1" }, @@ -3311,6 +3309,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -3320,7 +3319,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -3410,7 +3410,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3659,7 +3660,6 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3670,7 +3670,6 @@ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3758,7 +3757,6 @@ "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", @@ -4284,7 +4282,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4870,7 +4867,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5705,6 +5701,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -6134,7 +6131,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -6309,7 +6305,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8060,7 +8055,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9531,6 +9525,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10319,7 +10314,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10655,6 +10649,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10670,6 +10665,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10682,7 +10678,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prisma": { "version": "6.13.0", @@ -10691,7 +10688,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.13.0", "@prisma/engines": "6.13.0" @@ -10836,7 +10832,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10848,8 +10843,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", @@ -11040,8 +11034,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12038,7 +12031,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12326,7 +12318,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/tests/e2e/tasks.spec.ts b/tests/e2e/tasks.spec.ts index db215d4..630e573 100644 --- a/tests/e2e/tasks.spec.ts +++ b/tests/e2e/tasks.spec.ts @@ -81,3 +81,119 @@ test.describe('Task CRUD flows', () => { await expect(page.locator('h3', { hasText: title })).toHaveCount(0, { timeout: 5000 }); }); }); + +test.describe('Bulk Task Actions', () => { + test('select and deselect all tasks', async ({ page }) => { + await login(page); + await page.goto('/tasks'); + + // Create two test tasks + const title1 = await createTaskViaUI(page, 'Bulk Test 1'); + const title2 = await createTaskViaUI(page, 'Bulk Test 2'); + + // Click "Select All" + const selectAllCheckbox = page.locator('[data-testid="select-all-checkbox"]'); + await expect(selectAllCheckbox).toBeVisible(); + await selectAllCheckbox.click(); + + // Verify bulk actions bar appears + await expect(page.locator('text=/\\d+ task\\(s\\) selected/')).toBeVisible({ timeout: 5000 }); + + // Click "Deselect All" + await page.click('button:has-text("Deselect All")'); + + // Verify bulk actions bar disappears + await expect(page.locator('text=/\\d+ task\\(s\\) selected/')).toHaveCount(0); + }); + + test('bulk mark tasks as done', async ({ page }) => { + await login(page); + await page.goto('/tasks'); + + // Create two test tasks + const title1 = await createTaskViaUI(page, 'Bulk Done 1'); + const title2 = await createTaskViaUI(page, 'Bulk Done 2'); + + // Select both tasks individually + const card1 = page.locator(`[data-testid^="task-card-"]`).filter({ has: page.locator('h3', { hasText: title1 }) }).first(); + const card2 = page.locator(`[data-testid^="task-card-"]`).filter({ has: page.locator('h3', { hasText: title2 }) }).first(); + + await card1.locator('[data-testid^="task-select-"]').click(); + await card2.locator('[data-testid^="task-select-"]').click(); + + // Verify bulk actions menu appears + await expect(page.locator('[data-testid="bulk-actions-menu"]')).toBeVisible(); + + // Click bulk actions menu and select "Mark as Done" + await page.click('[data-testid="bulk-actions-menu"]'); + await page.click('[data-testid="bulk-mark-done"]'); + + // Wait for the action to complete + await page.waitForTimeout(1000); + + // Verify both tasks are marked as done (have line-through styling) + const task1Title = page.locator('h3', { hasText: title1 }).first(); + const task2Title = page.locator('h3', { hasText: title2 }).first(); + await expect(task1Title).toHaveClass(/line-through/); + await expect(task2Title).toHaveClass(/line-through/); + }); + + test('bulk delete tasks with confirmation', async ({ page }) => { + await login(page); + await page.goto('/tasks'); + + // Create two test tasks + const title1 = await createTaskViaUI(page, 'Bulk Delete 1'); + const title2 = await createTaskViaUI(page, 'Bulk Delete 2'); + + // Select both tasks + const card1 = page.locator(`[data-testid^="task-card-"]`).filter({ has: page.locator('h3', { hasText: title1 }) }).first(); + const card2 = page.locator(`[data-testid^="task-card-"]`).filter({ has: page.locator('h3', { hasText: title2 }) }).first(); + + await card1.locator('[data-testid^="task-select-"]').click(); + await card2.locator('[data-testid^="task-select-"]').click(); + + // Click bulk actions menu and select "Delete" + await page.click('[data-testid="bulk-actions-menu"]'); + await page.click('[data-testid="bulk-delete"]'); + + // Verify confirmation dialog appears + await expect(page.locator('text=Confirm Bulk Delete')).toBeVisible(); + await expect(page.locator('text=/delete \\d+ task\\(s\\)/')).toBeVisible(); + + // Click confirm + await page.click('[data-testid="bulk-delete-confirm"]'); + + // Wait for the action to complete + await page.waitForTimeout(1000); + + // Verify both tasks are deleted + await expect(page.locator('h3', { hasText: title1 })).toHaveCount(0); + await expect(page.locator('h3', { hasText: title2 })).toHaveCount(0); + }); + + test('cancel bulk delete', async ({ page }) => { + await login(page); + await page.goto('/tasks'); + + // Create a test task + const title = await createTaskViaUI(page, 'Bulk Cancel Delete'); + + // Select the task + const card = page.locator(`[data-testid^="task-card-"]`).filter({ has: page.locator('h3', { hasText: title }) }).first(); + await card.locator('[data-testid^="task-select-"]').click(); + + // Click bulk actions menu and select "Delete" + await page.click('[data-testid="bulk-actions-menu"]'); + await page.click('[data-testid="bulk-delete"]'); + + // Verify confirmation dialog appears + await expect(page.locator('text=Confirm Bulk Delete')).toBeVisible(); + + // Click cancel + await page.click('[data-testid="bulk-delete-cancel"]'); + + // Verify task is still visible + await expect(page.locator('h3', { hasText: title })).toBeVisible(); + }); +}); diff --git a/tests/unit/tasks-actions.test.ts b/tests/unit/tasks-actions.test.ts index 50edbe6..477f2d8 100644 --- a/tests/unit/tasks-actions.test.ts +++ b/tests/unit/tasks-actions.test.ts @@ -1,4 +1,4 @@ -import { createTask, getTeamStats, updateTask } from '@/app/(dashboard)/tasks/actions' +import { createTask, getTeamStats, updateTask, bulkUpdateTaskStatus, bulkDeleteTasks } from '@/app/(dashboard)/tasks/actions' // Mock prisma client used in module jest.mock('@/app/generated/prisma', () => { @@ -9,6 +9,8 @@ jest.mock('@/app/generated/prisma', () => { findMany: jest.fn().mockResolvedValue([]), delete: jest.fn().mockResolvedValue(true), update: jest.fn().mockResolvedValue(true), + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + deleteMany: jest.fn().mockResolvedValue({ count: 2 }), count: jest.fn().mockResolvedValue(0), }, user: { @@ -29,6 +31,12 @@ jest.mock('next/cache', () => ({ revalidatePath: jest.fn() })) jest.mock('@/app/login/actions', () => ({ getCurrentUser: jest.fn(async () => ({ id: 1 })) })) describe('tasks actions', () => { + beforeEach(() => { + // Reset getCurrentUser mock before each test + const auth = require('@/app/login/actions') + auth.getCurrentUser = jest.fn(async () => ({ id: 1 })) + }) + test('createTask returns error when missing title', async () => { const res = await createTask(new FormData()) expect(res.success).toBe(false) @@ -52,4 +60,44 @@ describe('tasks actions', () => { expect(res.success).toBe(false) expect(res.error).toBe('Not authenticated.') }) + + test('bulkUpdateTaskStatus returns error when not authenticated', async () => { + const auth = require('@/app/login/actions') + auth.getCurrentUser = jest.fn(async () => null) + const res = await bulkUpdateTaskStatus([1, 2], 'done') + expect(res.success).toBe(false) + expect(res.error).toBe('Not authenticated.') + }) + + test('bulkUpdateTaskStatus returns error when no tasks selected', async () => { + const res = await bulkUpdateTaskStatus([], 'done') + expect(res.success).toBe(false) + expect(res.error).toBe('No tasks selected.') + }) + + test('bulkUpdateTaskStatus updates tasks successfully', async () => { + const res = await bulkUpdateTaskStatus([1, 2, 3], 'done') + expect(res.success).toBe(true) + expect(res.message).toContain('3 task(s)') + }) + + test('bulkDeleteTasks returns error when not authenticated', async () => { + const auth = require('@/app/login/actions') + auth.getCurrentUser = jest.fn(async () => null) + const res = await bulkDeleteTasks([1, 2]) + expect(res.success).toBe(false) + expect(res.error).toBe('Not authenticated.') + }) + + test('bulkDeleteTasks returns error when no tasks selected', async () => { + const res = await bulkDeleteTasks([]) + expect(res.success).toBe(false) + expect(res.error).toBe('No tasks selected.') + }) + + test('bulkDeleteTasks deletes tasks successfully', async () => { + const res = await bulkDeleteTasks([1, 2]) + expect(res.success).toBe(true) + expect(res.message).toContain('2 task(s)') + }) })