@@ -27,7 +27,6 @@ router.get('/', async (req, res) => {
|
|||||||
router.post('/cluster', async (req, res) => {
|
router.post('/cluster', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const notes = await loadNotes();
|
const notes = await loadNotes();
|
||||||
console.log('notes returned from loadNotes', notes)
|
|
||||||
const clusters = await clusterNotes(notes);
|
const clusters = await clusterNotes(notes);
|
||||||
res.json(clusters);
|
res.json(clusters);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
1180
frontend/package-lock.json
generated
1180
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run --config vitest.config.ts",
|
||||||
|
"test:watch": "vitest --config vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
@@ -17,6 +19,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -25,8 +30,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.48.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
46
frontend/src/tests/app.test.tsx
Normal file
46
frontend/src/tests/app.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from '../App';
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
fetchMock.mockReturnValue(
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderApp = (initialRoute = '/') => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[initialRoute]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('App routing', () => {
|
||||||
|
|
||||||
|
it('should render the Home page on "/"', async () => {
|
||||||
|
renderApp('/');
|
||||||
|
expect(await screen.findByText('kohngrüti')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect unknown routes to Home', async () => {
|
||||||
|
renderApp('/some/random/path');
|
||||||
|
expect(await screen.findByText('kohngrüti')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
frontend/src/tests/button.test.tsx
Normal file
27
frontend/src/tests/button.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import Button from '../components/button';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
|
||||||
|
it('should render with the provided label', () => {
|
||||||
|
render(<Button onClick={() => {}} isLoading={false} label="Click Me" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Working..." when isLoading is true', () => {
|
||||||
|
render(<Button onClick={() => {}} isLoading={true} label="Click Me" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Working...' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClick when clicked', async () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(<Button onClick={handleClick} isLoading={false} label="Click Me" />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
1
frontend/src/tests/setup.ts
Normal file
1
frontend/src/tests/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
104
frontend/src/tests/stickies.test.tsx
Normal file
104
frontend/src/tests/stickies.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import Stickies from '../components/stickies';
|
||||||
|
import { createTestWrapper } from './test-wrapper';
|
||||||
|
|
||||||
|
const MOCK_STICKIES = [
|
||||||
|
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
||||||
|
{ id: 'note_002', text: 'Export takes too long', x: 798, y: 211, author: 'user_2', color: 'green' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_CLUSTERS = [
|
||||||
|
{ label: 'Auth Issues', noteIds: ['note_001'] },
|
||||||
|
{ label: 'Export Issues', noteIds: ['note_002'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFetchOk = (data: unknown) =>
|
||||||
|
Promise.resolve({ ok: true, json: () => Promise.resolve(data) } as Response);
|
||||||
|
|
||||||
|
const mockFetchFail = () =>
|
||||||
|
Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) } as Response);
|
||||||
|
|
||||||
|
describe('Stickies', () => {
|
||||||
|
|
||||||
|
it('should show a loading indicator while fetching notes', () => {
|
||||||
|
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all sticky notes after fetch succeeds', async () => {
|
||||||
|
fetchMock.mockReturnValue(mockFetchOk(MOCK_STICKIES));
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Export takes too long')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the cluster button', async () => {
|
||||||
|
fetchMock.mockReturnValue(mockFetchOk(MOCK_STICKIES));
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'Group Stickies By Topic' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show an error message when fetching notes fails', async () => {
|
||||||
|
fetchMock.mockReturnValue(mockFetchFail());
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Error:/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show cluster labels after clustering succeeds', async () => {
|
||||||
|
// First call = GET notes, second call = POST cluster
|
||||||
|
fetchMock
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS));
|
||||||
|
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Auth Issues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Export Issues')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still render sticky note text inside clusters', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_STICKIES))
|
||||||
|
.mockReturnValueOnce(mockFetchOk(MOCK_CLUSTERS));
|
||||||
|
|
||||||
|
const { Wrapper } = createTestWrapper();
|
||||||
|
render(<Stickies />, { wrapper: Wrapper });
|
||||||
|
|
||||||
|
const btn = await screen.findByRole('button', { name: 'Group Stickies By Topic' });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Login flow feels confusing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Export takes too long')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/src/tests/sticky.test.tsx
Normal file
33
frontend/src/tests/sticky.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Sticky from '../components/sticky';
|
||||||
|
import type { Sticky as StickyType } from '../types/types';
|
||||||
|
|
||||||
|
const baseStickyNote: StickyType = {
|
||||||
|
id: 'note_001',
|
||||||
|
text: 'Login flow feels confusing',
|
||||||
|
x: 193,
|
||||||
|
y: 191,
|
||||||
|
author: 'user_5',
|
||||||
|
color: 'yellow',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Sticky', () => {
|
||||||
|
|
||||||
|
it('should render the note text', () => {
|
||||||
|
render(<Sticky sticky={baseStickyNote} />);
|
||||||
|
expect(screen.getByText('Login flow feels confusing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply the correct background color for a known color', () => {
|
||||||
|
render(<Sticky sticky={{ ...baseStickyNote, color: 'blue' }} />);
|
||||||
|
const el = screen.getByText('Login flow feels confusing');
|
||||||
|
expect(el.style.backgroundColor).toBe('rgb(162, 210, 255)'); // #a2d2ff
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to yellow for an unknown color', () => {
|
||||||
|
render(<Sticky sticky={{ ...baseStickyNote, color: 'magenta' }} />);
|
||||||
|
const el = screen.getByText('Login flow feels confusing');
|
||||||
|
expect(el.style.backgroundColor).toBe('rgb(253, 253, 150)'); // #fdfd96
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/src/tests/test-wrapper.tsx
Normal file
20
frontend/src/tests/test-wrapper.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const createTestWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { Wrapper, queryClient };
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "@testing-library/jest-dom"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|||||||
11
frontend/vitest.config.ts
Normal file
11
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/tests/setup.ts',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user