diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
new file mode 100644
index 0000000..d2f2cb6
--- /dev/null
+++ b/src/__tests__/App.test.tsx
@@ -0,0 +1,54 @@
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import App from '../App';
+import * as api from '../api';
+
+// Mock the API module
+vi.mock('../api', () => ({
+ apiFetchTasks: vi.fn(),
+ apiFetchUsers: vi.fn(),
+ apiCreateTask: vi.fn(),
+ apiUpdateTask: vi.fn(),
+ apiAddActivity: vi.fn(),
+ apiLogin: vi.fn(),
+}));
+
+describe('App Component', () => {
+ it('renders login page when no user is logged in', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /sig\s*n\s*in/i })).toBeInTheDocument();
+ });
+
+ it('renders main content after login', async () => {
+ const mockUser = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'admin', dept: 'Engineering' };
+ const mockTasks = [{ id: 't1', title: 'Task 1', status: 'todo' }];
+ const mockUsers = [mockUser];
+
+ // Mock API responses
+ (api.apiLogin as any).mockResolvedValue(mockUser);
+ (api.apiFetchTasks as any).mockResolvedValue(mockTasks);
+ (api.apiFetchUsers as any).mockResolvedValue(mockUsers);
+
+ render(
);
+
+ // Simulate login
+ const emailInput = screen.getByLabelText(/email/i);
+ const passwordInput = screen.getByLabelText(/password/i);
+ const loginButton = screen.getByRole('button', { name: /sig\s*n\s*in/i });
+
+ await userEvent.type(emailInput, 'test@example.com');
+ await userEvent.type(passwordInput, 'password');
+ await userEvent.click(loginButton);
+
+ // Wait for data loading
+ await waitFor(() => {
+ expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
+ });
+
+ // Check if main content is rendered (e.g., Sidebar, Calendar)
+ expect(screen.getByText('Calendar')).toBeInTheDocument();
+ expect(screen.getByText('Test User')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/Kanban.test.tsx b/src/__tests__/Kanban.test.tsx
new file mode 100644
index 0000000..f78fdfa
--- /dev/null
+++ b/src/__tests__/Kanban.test.tsx
@@ -0,0 +1,67 @@
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { KanbanBoard } from '../Kanban';
+import type { Task, User } from '../data';
+
+// Mock Shared components
+vi.mock('../Shared', () => ({
+ Avatar: ({ userId }: any) =>
{userId}
,
+ PriorityBadge: ({ level }: any) =>
{level}
,
+ StatusBadge: ({ status }: any) =>
{status}
,
+ ProgressBar: () =>
Progress
+}));
+
+describe('KanbanBoard Component', () => {
+ const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
+ const mockUsers = [mockUser];
+
+ const mockTasks: Task[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
+ { id: 't2', title: 'Task 2', description: 'Desc', status: 'inprogress', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
+ ];
+
+ const mockOnTaskClick = vi.fn();
+ const mockOnAddTask = vi.fn();
+ const mockOnMoveTask = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders columns and tasks', () => {
+ render(
);
+
+ expect(screen.getByText('To Do')).toBeInTheDocument();
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
+ expect(screen.getByText('Review')).toBeInTheDocument();
+ expect(screen.getByText('Done')).toBeInTheDocument();
+
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ expect(screen.getByText('Task 2')).toBeInTheDocument();
+ });
+
+ it('filters tasks by search query', () => {
+ render(
);
+
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
+ });
+
+ it('calls onAddTask when + button clicked', () => {
+ render(
);
+
+ // There are multiple + buttons (one per column)
+ const addButtons = screen.getAllByText('+');
+ fireEvent.click(addButtons[0]); // Click first column (Todo) add button
+
+ expect(mockOnAddTask).toHaveBeenCalledWith('todo');
+ });
+
+ it('calls onTaskClick when task clicked', () => {
+ render(
);
+
+ fireEvent.click(screen.getByText('Task 1'));
+ expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
+ });
+});
diff --git a/src/__tests__/ListView.test.tsx b/src/__tests__/ListView.test.tsx
new file mode 100644
index 0000000..e6addf9
--- /dev/null
+++ b/src/__tests__/ListView.test.tsx
@@ -0,0 +1,57 @@
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ListView } from '../ListView';
+import type { Task, User } from '../data';
+
+describe('ListView Component', () => {
+ const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
+ const mockUsers = [mockUser];
+
+ // Test data
+ const mockTasks: Task[] = [
+ { id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
+ { id: 't2', title: 'Task 2', description: 'Desc', status: 'done', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
+ ];
+
+ const mockOnTaskClick = vi.fn();
+ const mockOnToggleDone = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders tasks in list', () => {
+ render(
);
+
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ expect(screen.getByText('Task 2')).toBeInTheDocument();
+ // Check for headers (using getAllByText because buttons also have these labels)
+ expect(screen.getAllByText('Title').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Assignee').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
+ });
+
+ it('filters tasks by search', () => {
+ render(
);
+
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
+ });
+
+ it('handles task click', () => {
+ render(
);
+
+ fireEvent.click(screen.getByText('Task 1'));
+ expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
+ });
+
+ it('handles toggle done', () => {
+ render(
);
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ fireEvent.click(checkboxes[0]);
+
+ expect(mockOnToggleDone).toHaveBeenCalledWith('t1');
+ });
+});
diff --git a/src/__tests__/Login.test.tsx b/src/__tests__/Login.test.tsx
new file mode 100644
index 0000000..dac1611
--- /dev/null
+++ b/src/__tests__/Login.test.tsx
@@ -0,0 +1,107 @@
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { LoginPage } from '../Login';
+import * as api from '../api';
+
+// Mock API
+vi.mock('../api', () => ({
+ apiLogin: vi.fn(),
+ apiRegister: vi.fn(),
+}));
+
+describe('LoginPage Component', () => {
+ const mockOnLogin = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOnLogin.mockClear();
+ });
+
+ it('renders login form by default', () => {
+ render(
);
+ expect(screen.getByText(/scrum-manager/i)).toBeInTheDocument(); // Logo check
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
+ expect(screen.getByText(/no account\? register here/i)).toBeInTheDocument();
+ });
+
+
+ it('switches to register mode', async () => {
+ render(
);
+ await userEvent.click(screen.getByText(/no account\? register here/i));
+
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
+ expect(screen.getByText(/already have an account\? sign in/i)).toBeInTheDocument();
+ });
+
+ it('handles successful login', async () => {
+ const mockUser = { id: 'u1', name: 'Test', email: 'test@example.com', role: 'emp', dept: 'dev' };
+ (api.apiLogin as any).mockResolvedValue(mockUser);
+
+ render(
);
+
+ await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
+ await userEvent.type(screen.getByLabelText(/password/i), 'password');
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() => {
+ expect(api.apiLogin).toHaveBeenCalledWith('test@example.com', 'password');
+ expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
+ });
+ });
+
+ it('handles login failure', async () => {
+ (api.apiLogin as any).mockRejectedValue(new Error('Invalid credentials'));
+
+ render(
);
+
+ await userEvent.type(screen.getByLabelText(/email/i), 'wrong@example.com');
+ await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass');
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
+ });
+ expect(mockOnLogin).not.toHaveBeenCalled();
+ });
+
+ it('handles successful registration', async () => {
+ const mockUser = { id: 'u2', name: 'New User', email: 'new@example.com', role: 'employee', dept: 'dev' };
+ (api.apiRegister as any).mockResolvedValue(mockUser);
+
+ render(
);
+ await userEvent.click(screen.getByText(/no account\? register here/i));
+
+ await userEvent.type(screen.getByLabelText(/name/i), 'New User');
+ await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com');
+ await userEvent.type(screen.getByLabelText(/password/i), 'password123');
+ await userEvent.type(screen.getByLabelText(/department/i), 'DevOps'); // "e.g. Backend..." placeholder, checking label
+
+ await userEvent.click(screen.getByRole('button', { name: /create account/i }));
+
+ await waitFor(() => {
+ expect(api.apiRegister).toHaveBeenCalledWith({
+ name: 'New User',
+ email: 'new@example.com',
+ password: 'password123',
+ role: 'employee', // Default
+ dept: 'DevOps'
+ });
+ expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
+ });
+ });
+
+ it('validates registration inputs', async () => {
+ render(
);
+ await userEvent.click(screen.getByText(/no account\? register here/i));
+
+ await userEvent.click(screen.getByRole('button', { name: /create account/i }));
+
+ expect(screen.getByText(/all fields are required/i)).toBeInTheDocument();
+ expect(api.apiRegister).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/TaskDrawer.test.tsx b/src/__tests__/TaskDrawer.test.tsx
new file mode 100644
index 0000000..fa7d4df
--- /dev/null
+++ b/src/__tests__/TaskDrawer.test.tsx
@@ -0,0 +1,108 @@
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TaskDrawer } from '../TaskDrawer';
+import type { Task, User } from '../data';
+
+// Mock Shared components to avoid testing their specific implementation here
+vi.mock('../Shared', () => ({
+ Avatar: ({ userId }: any) =>
{userId}
,
+ Tag: ({ label }: any) =>
{label}
,
+ ProgressBar: () =>
Progress
+}));
+
+describe('TaskDrawer Component', () => {
+ const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
+ const mockUsers = [mockUser, { id: 'u2', name: 'Other User', email: 'other@example.com', role: 'emp', dept: 'dev', avatar: 'OU', color: '#fff' }];
+
+ const mockTask: Task = {
+ id: 't1',
+ title: 'Test Task',
+ description: 'Test Description',
+ status: 'todo',
+ priority: 'medium',
+ assignee: 'u1',
+ reporter: 'u1',
+ dueDate: '2023-12-31',
+ tags: ['bug'],
+ subtasks: [],
+ comments: [],
+ activity: [],
+ dependencies: []
+ };
+
+ const mockOnUpdate = vi.fn();
+ const mockOnClose = vi.fn();
+ const mockOnAddDep = vi.fn();
+ const mockOnToggleDep = vi.fn();
+ const mockOnRemoveDep = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders task details correctly', () => {
+ render(
);
+
+ // screen.debug();
+
+ expect(screen.getByText('Test Task')).toBeInTheDocument();
+ expect(screen.getByText('Test Description')).toBeInTheDocument();
+ // Check for mocked avatar with userId
+ const avatars = screen.getAllByTestId('avatar');
+ expect(avatars.length).toBeGreaterThan(0);
+ expect(avatars[0]).toHaveTextContent('u1');
+ // expect(screen.getByText(/Test User/i)).toBeInTheDocument(); // Assignee name might be tricky in Select/Div
+ expect(screen.getByText('bug')).toBeInTheDocument();
+ });
+
+ it('updates title/description calling onUpdate', async () => {
+ // Title isn't editable in the drawer based on code, only via modal or just display?
+ // Checking code:
{task.title}
. It's not an input.
+ // But status, priority, assignee are selects.
+
+ render(
);
+
+ // Update status
+ const statusSelect = screen.getByRole('combobox', { name: /status/i });
+ await userEvent.selectOptions(statusSelect, 'done');
+
+ expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
+ id: 't1',
+ status: 'done'
+ }));
+ });
+
+ it('adds a subtask', async () => {
+ render(
);
+
+ const input = screen.getByPlaceholderText(/add a subtask/i);
+ await userEvent.type(input, 'New Subtask{enter}');
+
+ expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
+ subtasks: expect.arrayContaining([expect.objectContaining({ title: 'New Subtask', done: false })])
+ }));
+ });
+
+ it('adds a comment', async () => {
+ render(
);
+
+ const input = screen.getByPlaceholderText(/add a comment/i);
+ await userEvent.type(input, 'This is a comment{enter}');
+
+ expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
+ comments: expect.arrayContaining([expect.objectContaining({ text: 'This is a comment', userId: 'u1' })])
+ }));
+ });
+
+ it('adds a dependency', async () => {
+ render(
);
+
+ const descInput = screen.getByPlaceholderText(/describe the dependency/i);
+ await userEvent.type(descInput, 'Blocked by API{enter}');
+
+ // Default select is "Anyone" (empty string).
+ expect(mockOnAddDep).toHaveBeenCalledWith('t1', { dependsOnUserId: '', description: 'Blocked by API' });
+ });
+});
diff --git a/src/api.ts b/src/api.ts
index d1f5a97..b52b564 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -42,6 +42,7 @@ export async function apiCreateTask(task: {
title: string; description?: string; status?: string; priority?: string;
assignee?: string; reporter?: string; dueDate?: string; tags?: string[];
subtasks?: { title: string; done: boolean }[];
+ dependencies?: { dependsOnUserId: string; description: string }[];
}) {
return request('/tasks', {
method: 'POST',
@@ -83,3 +84,24 @@ export async function apiAddActivity(taskId: string, text: string) {
body: JSON.stringify({ text }),
});
}
+
+// Dependencies
+export async function apiAddDependency(taskId: string, dep: { dependsOnUserId: string; description: string }) {
+ return request(`/tasks/${taskId}/dependencies`, {
+ method: 'POST',
+ body: JSON.stringify(dep),
+ });
+}
+
+export async function apiToggleDependency(taskId: string, depId: string, resolved: boolean) {
+ return request(`/tasks/${taskId}/dependencies/${depId}`, {
+ method: 'PUT',
+ body: JSON.stringify({ resolved }),
+ });
+}
+
+export async function apiRemoveDependency(taskId: string, depId: string) {
+ return request(`/tasks/${taskId}/dependencies/${depId}`, {
+ method: 'DELETE',
+ });
+}
diff --git a/src/data.ts b/src/data.ts
index ed4222c..d5f7b2d 100644
--- a/src/data.ts
+++ b/src/data.ts
@@ -9,10 +9,12 @@ export interface User {
export interface Subtask { id: string; title: string; done: boolean }
export interface Comment { id: string; userId: string; text: string; timestamp: string }
export interface Activity { id: string; text: string; timestamp: string }
+export interface Dependency { id: string; dependsOnUserId: string; description: string; resolved: boolean }
export interface Task {
id: string; title: string; description: string; status: Status; priority: Priority;
assignee: string; reporter: string; dueDate: string; tags: string[];
subtasks: Subtask[]; comments: Comment[]; activity: Activity[];
+ dependencies: Dependency[];
}
export const PRIORITY_COLORS: Record
= {
diff --git a/src/index.css b/src/index.css
index 2b2acd3..6b6c99a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1655,6 +1655,281 @@ body {
background: var(--accent-hover);
}
+/* DEPENDENCIES */
+.dep-unresolved-badge {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+ margin-left: 8px;
+}
+
+.dep-empty {
+ color: var(--text-muted);
+ font-size: 12px;
+ padding: 8px 0;
+ font-style: italic;
+}
+
+.dep-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ margin-bottom: 4px;
+ transition: background 0.15s;
+}
+
+.dep-item:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.dep-unresolved {
+ border-left: 3px solid #ef4444;
+}
+
+.dep-resolved {
+ border-left: 3px solid #22c55e;
+ opacity: 0.7;
+}
+
+.dep-check {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 2px solid var(--text-muted);
+ background: none;
+ color: #22c55e;
+ font-size: 13px;
+ font-weight: 700;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ flex-shrink: 0;
+}
+
+.dep-check.checked {
+ border-color: #22c55e;
+ background: rgba(34, 197, 94, 0.15);
+}
+
+.dep-check:hover {
+ border-color: var(--accent);
+}
+
+.dep-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.dep-user {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.dep-desc {
+ font-size: 13px;
+ color: var(--text-primary);
+}
+
+.dep-desc.done {
+ text-decoration: line-through;
+ color: var(--text-muted);
+}
+
+.dep-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ opacity: 0;
+ transition: all 0.15s;
+}
+
+.dep-item:hover .dep-remove {
+ opacity: 1;
+}
+
+.dep-remove:hover {
+ color: #ef4444;
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.dep-add-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.dep-add-select {
+ padding: 6px 8px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-family: inherit;
+ width: 140px;
+ outline: none;
+}
+
+.dep-add-select:focus {
+ border-color: var(--accent);
+}
+
+.dep-add-input {
+ flex: 1;
+ padding: 6px 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-family: inherit;
+ outline: none;
+}
+
+.dep-add-input:focus {
+ border-color: var(--accent);
+}
+
+.dep-add-btn {
+ padding: 6px 14px;
+ background: var(--accent);
+ border: none;
+ border-radius: 6px;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: inherit;
+ white-space: nowrap;
+ transition: background 0.15s;
+}
+
+.dep-add-btn:hover {
+ background: var(--accent-hover);
+}
+
+/* Modal dependency styles */
+.modal-deps-list {
+ margin-bottom: 8px;
+}
+
+.modal-dep-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 4px;
+ font-size: 13px;
+ border-left: 3px solid #f59e0b;
+}
+
+.modal-dep-icon {
+ font-size: 14px;
+}
+
+.modal-dep-user {
+ color: var(--accent);
+ font-weight: 600;
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.modal-dep-desc {
+ flex: 1;
+ color: var(--text-primary);
+}
+
+.modal-dep-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+
+.modal-dep-remove:hover {
+ color: #ef4444;
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.modal-dep-add {
+ display: flex;
+ gap: 6px;
+}
+
+.modal-dep-select {
+ padding: 8px 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-family: inherit;
+ width: 160px;
+ outline: none;
+}
+
+.modal-dep-select:focus {
+ border-color: var(--accent);
+}
+
+.modal-dep-input {
+ flex: 1;
+ padding: 8px 12px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+}
+
+.modal-dep-input:focus {
+ border-color: var(--accent);
+}
+
+.modal-dep-btn {
+ padding: 8px 14px;
+ background: var(--accent);
+ border: none;
+ border-radius: 8px;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: inherit;
+ white-space: nowrap;
+ transition: background 0.15s;
+}
+
+.modal-dep-btn:hover {
+ background: var(--accent-hover);
+}
+
/* DASHBOARD */
.dashboard {
padding: 20px;
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..dec714c
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,2 @@
+
+import '@testing-library/jest-dom';
diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts
new file mode 100644
index 0000000..f9a9ce1
--- /dev/null
+++ b/tests/integration/health.test.ts
@@ -0,0 +1,36 @@
+
+import { describe, it, expect } from 'vitest';
+// fetch is global in Node 18+
+
+describe('Integration Tests', () => {
+ const FRONTEND_URL = 'http://localhost:80';
+ const BACKEND_URL = 'http://localhost:3001';
+
+ it('Frontend is reachable', async () => {
+ try {
+ const res = await fetch(FRONTEND_URL);
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ expect(text).toContain('');
+ } catch (e) {
+ // If fetch fails (connection refused), test fails
+ throw new Error(`Frontend not reachable at ${FRONTEND_URL}: ${e.message}`);
+ }
+ });
+
+ it('Backend health check / API is reachable', async () => {
+ // We don't have a specific health endpoint, but we can try to hit an auth endpoint
+ // that requires valid input, expecting a 400 or 401 instead of connection refused.
+ try {
+ const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({})
+ });
+ // Expecting 400 because we sent empty body, meaning server is up and parsing JSON
+ expect(res.status).toBe(400);
+ } catch (e) {
+ throw new Error(`Backend not reachable at ${BACKEND_URL}: ${e.message}`);
+ }
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index d692594..cdc55bc 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,5 @@
-import { defineConfig } from 'vite'
+///
+import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
@@ -13,4 +14,9 @@ export default defineConfig({
},
},
},
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.ts',
+ },
})
diff --git a/vitest.integration.config.js b/vitest.integration.config.js
new file mode 100644
index 0000000..2575e4f
--- /dev/null
+++ b/vitest.integration.config.js
@@ -0,0 +1,11 @@
+
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['tests/integration/**/*.test.ts', 'tests/integration/**/*.test.js'],
+ testTimeout: 20000,
+ },
+});