{}} label="Category color" />);
+ const trigger = screen.getByRole('button', { name: /category color/i });
+
+ await userEvent.click(trigger);
+ await userEvent.click(trigger);
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('closes on a click outside', async () => {
+ render(
+
+ {}} label="Category color" />
+
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }));
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('stays open on a click inside the popover', async () => {
+ render( {}} label="Category color" />);
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ await userEvent.click(screen.getByLabelText(/category color hex value/i));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('ignores keys other than Escape', async () => {
+ render( {}} label="Category color" />);
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ await userEvent.keyboard('{Enter}');
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('ignores a half-typed hex value', async () => {
+ const onChange = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ const hex = screen.getByLabelText(/category color hex value/i);
+ await userEvent.clear(hex);
+ await userEvent.type(hex, '00ff');
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(hex).toHaveValue('00ff'); // still shows what was typed
+ });
+
+ it('accepts a hex without the leading hash and normalises it', async () => {
+ const onChange = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ const hex = screen.getByLabelText(/category color hex value/i);
+ await userEvent.clear(hex);
+ await userEvent.type(hex, 'AABBCC');
+
+ expect(onChange).toHaveBeenLastCalledWith('#aabbcc');
+ });
+
+ it('emits a new color when brightness is dragged', async () => {
+ const onChange = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ fireEvent.change(screen.getByRole('slider'), { target: { value: '50' } });
+
+ expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/));
+ });
+
+ it('picks a hue and saturation from a press on the wheel', async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ const wheel = container.querySelector('.color-picker__wheel');
+ measureWheel(wheel);
+
+ // Straight right of centre at full radius: hue 90, saturation 1.
+ fireEvent(wheel, pointer('pointerdown', { clientX: 156, clientY: 78 }));
+
+ expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/));
+ });
+
+ it('tracks a drag across the wheel and stops on release', async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ const wheel = container.querySelector('.color-picker__wheel');
+ measureWheel(wheel);
+
+ fireEvent(wheel, pointer('pointerdown', { clientX: 100, clientY: 78 }));
+ fireEvent(wheel, pointer('pointermove', { clientX: 120, clientY: 78 }));
+ const whileDragging = onChange.mock.calls.length;
+
+ fireEvent(wheel, pointer('pointerup'));
+ fireEvent(wheel, pointer('pointermove', { clientX: 140, clientY: 78 }));
+
+ expect(whileDragging).toBe(2);
+ expect(onChange).toHaveBeenCalledTimes(2); // the move after release is ignored
+ expect(onChange).toHaveBeenLastCalledWith(expect.stringMatching(/^#[0-9a-f]{6}$/));
+ });
+
+ it('ignores a move that was never preceded by a press', async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ fireEvent(container.querySelector('.color-picker__wheel'), pointer('pointermove', { clientX: 10, clientY: 10 }));
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('adopts a color chosen elsewhere', async () => {
+ const { rerender } = render(
+ {}} label="Category color" />
+ );
+
+ rerender( {}} label="Category color" />);
+
+ expect(screen.getByRole('button', { name: /category color: #00ff00/i })).toBeInTheDocument();
+ });
+
+ it('leaves the hex field alone while it is being typed in', async () => {
+ const { rerender } = render(
+ {}} label="Category color" />
+ );
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+ const hex = screen.getByLabelText(/category color hex value/i);
+ await userEvent.clear(hex);
+ await userEvent.type(hex, '00ff00');
+
+ // The parent echoes the committed value back while the field still has focus.
+ rerender( {}} label="Category color" />);
+
+ expect(hex).toHaveValue('00ff00'); // not rewritten to '#00ff00' mid-edit
+ });
+
+ it('falls back to a sensible hue when the incoming value is not a color', () => {
+ render( {}} label="Category color" />);
+
+ expect(screen.getByRole('button', { name: /category color: not-a-color/i })).toBeInTheDocument();
+ });
+
+ it('defaults its label', async () => {
+ render( {}} />);
+
+ expect(screen.getByRole('button', { name: /^color: #4f46e5$/i })).toBeInTheDocument();
+ });
+
+ it('accepts a hex typed with the leading hash', async () => {
+ const onChange = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button', { name: /category color/i }));
+
+ const hex = screen.getByLabelText(/category color hex value/i);
+ await userEvent.clear(hex);
+ await userEvent.type(hex, '#00FF00');
+
+ expect(onChange).toHaveBeenLastCalledWith('#00ff00');
+ });
});
diff --git a/src/components/DateField.test.jsx b/src/components/DateField.test.jsx
index 6a91abb..d12c394 100644
--- a/src/components/DateField.test.jsx
+++ b/src/components/DateField.test.jsx
@@ -30,4 +30,120 @@ describe('', () => {
expect(input.value).toBe('');
expect(onChange).toHaveBeenLastCalledWith('');
});
+
+ it('shows an existing ISO value as a masked date', () => {
+ render();
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('07/19/2026');
+ });
+
+ it('shows nothing for an empty value', () => {
+ render();
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('');
+ });
+
+ it('shows nothing for a value that is not a full date', () => {
+ render();
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('');
+ });
+
+ it('adopts a value set from outside', () => {
+ const { rerender } = render();
+
+ rerender();
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('01/02/2026');
+ });
+
+ it('emits nothing until the date is complete', () => {
+ const onChange = vi.fn();
+ render();
+ const input = screen.getByLabelText('Due date');
+
+ fireEvent.change(input, { target: { value: '0719' } });
+
+ expect(input).toHaveValue('07/19');
+ expect(onChange).toHaveBeenLastCalledWith('');
+ });
+
+ it('ignores extra digits past the eighth', () => {
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '071920261234' } });
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('07/19/2026');
+ expect(onChange).toHaveBeenLastCalledWith('2026-07-19');
+ });
+
+ it('rejects a month outside 1-12', () => {
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '13/01/2026' } });
+
+ expect(onChange).toHaveBeenLastCalledWith('');
+ });
+
+ it('accepts a leap day in a leap year', () => {
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByLabelText('Due date'), { target: { value: '02/29/2024' } });
+
+ expect(onChange).toHaveBeenLastCalledWith('2024-02-29');
+ });
+
+ it('opens the native picker from the calendar button', async () => {
+ const { container } = render();
+ const native = container.querySelector('.date-native');
+ native.showPicker = vi.fn();
+
+ fireEvent.click(screen.getByLabelText('Open calendar'));
+
+ expect(native.showPicker).toHaveBeenCalled();
+ });
+
+ it('focuses the native input when the browser has no picker API', async () => {
+ const { container } = render();
+ const native = container.querySelector('.date-native');
+ native.showPicker = vi.fn(() => { throw new Error('not supported'); });
+ const focus = vi.spyOn(native, 'focus');
+
+ fireEvent.click(screen.getByLabelText('Open calendar'));
+
+ expect(focus).toHaveBeenCalled();
+ });
+
+ it('fills the bar from a date picked in the native control', () => {
+ const onChange = vi.fn();
+ const { container } = render();
+
+ fireEvent.change(container.querySelector('.date-native'), { target: { value: '2026-03-04' } });
+
+ expect(screen.getByLabelText('Due date')).toHaveValue('03/04/2026');
+ expect(onChange).toHaveBeenLastCalledWith('2026-03-04');
+ });
+
+ it('defaults its label', () => {
+ render();
+
+ expect(screen.getByLabelText('Date')).toBeInTheDocument();
+ });
+
+ it('still formats when the browser refuses to move the caret', () => {
+ const onChange = vi.fn();
+ render();
+ const input = screen.getByLabelText('Due date');
+ vi.spyOn(input, 'setSelectionRange').mockImplementation(() => {
+ throw new Error('not supported on this input type');
+ });
+
+ fireEvent.change(input, { target: { value: '07192026' } });
+
+ expect(input).toHaveValue('07/19/2026');
+ expect(onChange).toHaveBeenLastCalledWith('2026-07-19');
+ });
});
diff --git a/src/components/GoogleButton.test.jsx b/src/components/GoogleButton.test.jsx
new file mode 100644
index 0000000..d3e9153
--- /dev/null
+++ b/src/components/GoogleButton.test.jsx
@@ -0,0 +1,268 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, act, waitFor } from '@testing-library/react';
+
+const SCRIPT_SRC = 'https://accounts.google.com/gsi/client';
+
+/**
+ * The client id is read once at module load, so each test re-imports the component with the
+ * environment it needs.
+ */
+async function loadButton(clientId) {
+ vi.resetModules();
+ if (clientId === undefined) {
+ vi.stubEnv('VITE_GOOGLE_CLIENT_ID', '');
+ } else {
+ vi.stubEnv('VITE_GOOGLE_CLIENT_ID', clientId);
+ }
+ return (await import('./GoogleButton.jsx')).default;
+}
+
+/** Google Identity Services, reduced to the two calls this component makes. */
+function stubGoogleIdentity() {
+ const identity = {
+ initialize: vi.fn(),
+ renderButton: vi.fn(),
+ };
+ window.google = { accounts: { id: identity } };
+ return identity;
+}
+
+/** A matchMedia whose change listeners the test can fire. */
+function stubMatchMedia({ matches = false } = {}) {
+ const listeners = new Set();
+ const media = {
+ matches,
+ addEventListener: vi.fn((_event, fn) => listeners.add(fn)),
+ removeEventListener: vi.fn((_event, fn) => listeners.delete(fn)),
+ };
+ window.matchMedia = vi.fn(() => media);
+ return { media, fireChange: () => listeners.forEach((fn) => fn({ matches: !matches })) };
+}
+
+const originalMatchMedia = window.matchMedia;
+
+beforeEach(() => {
+ document.documentElement.removeAttribute('data-theme');
+ document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`).forEach((s) => s.remove());
+ delete window.google;
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ window.matchMedia = originalMatchMedia;
+ delete window.google;
+});
+
+describe('GoogleButton without a client id', () => {
+ it('explains what to configure instead of rendering a dead button', async () => {
+ const GoogleButton = await loadButton('');
+
+ render();
+
+ expect(screen.getByText(/VITE_GOOGLE_CLIENT_ID/)).toBeInTheDocument();
+ });
+
+ it('loads nothing from Google', async () => {
+ const GoogleButton = await loadButton('');
+
+ render();
+
+ expect(document.querySelector(`script[src="${SCRIPT_SRC}"]`)).toBeNull();
+ });
+});
+
+describe('GoogleButton with a client id', () => {
+ it('injects the Google script once and initializes when it loads', async () => {
+ const GoogleButton = await loadButton('client-123.apps.googleusercontent.com');
+ render();
+
+ const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`);
+ expect(script).not.toBeNull();
+ expect(script.async).toBe(true);
+ expect(script.defer).toBe(true);
+
+ const identity = stubGoogleIdentity();
+ await act(async () => { script.onload(); });
+
+ expect(identity.initialize).toHaveBeenCalledWith(expect.objectContaining({
+ client_id: 'client-123.apps.googleusercontent.com',
+ }));
+ expect(identity.renderButton).toHaveBeenCalled();
+ });
+
+ it('initializes straight away when the script is already on the page', async () => {
+ const script = document.createElement('script');
+ script.src = SCRIPT_SRC;
+ document.body.appendChild(script);
+ const identity = stubGoogleIdentity();
+
+ const GoogleButton = await loadButton('client-123');
+ await act(async () => { render(); });
+
+ expect(identity.initialize).toHaveBeenCalled();
+ // No second copy of the script.
+ expect(document.querySelectorAll(`script[src="${SCRIPT_SRC}"]`)).toHaveLength(1);
+ });
+
+ it('hands the returned credential to the caller', async () => {
+ const onCredential = vi.fn();
+ const identity = stubGoogleIdentity();
+ const script = document.createElement('script');
+ script.src = SCRIPT_SRC;
+ document.body.appendChild(script);
+
+ const GoogleButton = await loadButton('client-123');
+ await act(async () => { render(); });
+
+ const { callback } = identity.initialize.mock.calls[0][0];
+ callback({ credential: 'id-token-abc' });
+
+ expect(onCredential).toHaveBeenCalledWith('id-token-abc');
+ });
+
+ it('survives a credential arriving with no handler attached', async () => {
+ const identity = stubGoogleIdentity();
+ const script = document.createElement('script');
+ script.src = SCRIPT_SRC;
+ document.body.appendChild(script);
+
+ const GoogleButton = await loadButton('client-123');
+ await act(async () => { render(); });
+
+ const { callback } = identity.initialize.mock.calls[0][0];
+ expect(() => callback({ credential: 'id-token-abc' })).not.toThrow();
+ });
+
+ it('does nothing when the script loads but Google never appears', async () => {
+ const GoogleButton = await loadButton('client-123');
+ render();
+
+ const script = document.querySelector(`script[src="${SCRIPT_SRC}"]`);
+
+ // No window.google — the load handler must not throw.
+ await act(async () => { expect(() => script.onload()).not.toThrow(); });
+ });
+});
+
+describe('GoogleButton theming', () => {
+ async function renderConfigured({ onCredential = vi.fn() } = {}) {
+ const script = document.createElement('script');
+ script.src = SCRIPT_SRC;
+ document.body.appendChild(script);
+ const identity = stubGoogleIdentity();
+
+ const GoogleButton = await loadButton('client-123');
+ const view = render();
+ await act(async () => {});
+ return { identity, view };
+ }
+
+ const themeOf = (identity) =>
+ identity.renderButton.mock.calls.at(-1)[1].theme;
+
+ it('uses the dark treatment when the page is explicitly dark', async () => {
+ document.documentElement.setAttribute('data-theme', 'dark');
+
+ const { identity } = await renderConfigured();
+
+ expect(themeOf(identity)).toBe('filled_black');
+ });
+
+ it('uses the light treatment when the page is explicitly light', async () => {
+ document.documentElement.setAttribute('data-theme', 'light');
+ stubMatchMedia({ matches: true }); // an explicit choice must win over the OS
+
+ const { identity } = await renderConfigured();
+
+ expect(themeOf(identity)).toBe('outline');
+ });
+
+ it('follows the OS preference when no explicit choice has been made', async () => {
+ stubMatchMedia({ matches: true });
+
+ const { identity } = await renderConfigured();
+
+ expect(themeOf(identity)).toBe('filled_black');
+ });
+
+ it('treats an unavailable matchMedia as light rather than failing', async () => {
+ window.matchMedia = vi.fn(() => { throw new Error('unsupported'); });
+
+ const { identity } = await renderConfigured();
+
+ expect(themeOf(identity)).toBe('outline');
+ });
+
+ it('redraws when the theme attribute flips', async () => {
+ const { identity } = await renderConfigured();
+ const before = identity.renderButton.mock.calls.length;
+
+ await act(async () => {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ // MutationObserver callbacks are delivered as microtasks.
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(identity.renderButton.mock.calls.length).toBeGreaterThan(before));
+ expect(themeOf(identity)).toBe('filled_black');
+ });
+
+ it('redraws when the OS preference changes', async () => {
+ const { fireChange } = stubMatchMedia({ matches: false });
+ const { identity } = await renderConfigured();
+ const before = identity.renderButton.mock.calls.length;
+
+ await act(async () => { fireChange(); });
+
+ expect(identity.renderButton.mock.calls.length).toBeGreaterThan(before);
+ });
+
+ it('stops listening once it unmounts', async () => {
+ const { media } = stubMatchMedia();
+ const { view } = await renderConfigured();
+
+ view.unmount();
+
+ expect(media.removeEventListener).toHaveBeenCalled();
+ });
+
+ it('unmounts cleanly when matchMedia was unavailable', async () => {
+ window.matchMedia = vi.fn(() => { throw new Error('unsupported'); });
+ const { view } = await renderConfigured();
+
+ expect(() => view.unmount()).not.toThrow();
+ });
+
+ it('clamps the button width to the range Google accepts', async () => {
+ // jsdom reports a zero-width box, so the component falls back to its default.
+ const { identity } = await renderConfigured();
+
+ const { width } = identity.renderButton.mock.calls.at(-1)[1];
+ expect(width).toBeGreaterThanOrEqual(240);
+ expect(width).toBeLessThanOrEqual(400);
+ });
+
+ it('measures the surrounding box when the layout reports one', async () => {
+ const spy = vi
+ .spyOn(Element.prototype, 'getBoundingClientRect')
+ .mockReturnValue({ width: 1000, height: 40, top: 0, left: 0, right: 0, bottom: 0 });
+
+ const { identity } = await renderConfigured();
+
+ expect(identity.renderButton.mock.calls.at(-1)[1].width).toBe(400); // clamped down
+ spy.mockRestore();
+ });
+
+ it('skips the redraw when Google is no longer available', async () => {
+ const { identity } = await renderConfigured();
+ const before = identity.renderButton.mock.calls.length;
+ delete window.google;
+
+ await act(async () => {
+ document.documentElement.setAttribute('data-theme', 'dark');
+ await Promise.resolve();
+ });
+
+ expect(identity.renderButton.mock.calls.length).toBe(before);
+ });
+});
diff --git a/src/components/KanbanBoard.test.jsx b/src/components/KanbanBoard.test.jsx
new file mode 100644
index 0000000..8397e5b
--- /dev/null
+++ b/src/components/KanbanBoard.test.jsx
@@ -0,0 +1,298 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('../lib/apiClient.js', () => ({
+ TodoApi: {
+ list: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ changeStatus: vi.fn(),
+ remove: vi.fn(),
+ },
+ CategoryApi: {
+ list: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ },
+}));
+
+import { TodoApi, CategoryApi } from '../lib/apiClient.js';
+import KanbanBoard from './KanbanBoard.jsx';
+
+const categories = [
+ { id: 1, name: 'Work', color: '#7fb2e6' },
+ { id: 2, name: 'Personal', color: '#ef9db4' },
+];
+
+const todo = (id, overrides = {}) => ({
+ id,
+ title: `Task ${id}`,
+ description: '',
+ status: 0,
+ priority: 1,
+ priorityName: 'Medium',
+ categoryId: 1,
+ dueDate: null,
+ isCompleted: false,
+ concurrencyToken: `token-${id}`,
+ ...overrides,
+});
+
+/** The lane section with the given heading, so a card can be located by column. */
+function lane(label) {
+ return screen.getByRole('heading', { name: label }).closest('.lane');
+}
+
+// The board toolbar and the category panel both carry a "Category" control and an "Add"/"Close"
+// button, so every query below is scoped to the one it means.
+const toolbar = () => within(document.querySelector('.board-filter'));
+const panel = () => within(document.querySelector('.cat-manager'));
+const categoryFilter = () => toolbar().getByLabelText('Category');
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ TodoApi.list.mockResolvedValue([]);
+ CategoryApi.list.mockResolvedValue(categories);
+});
+
+async function renderBoard() {
+ render();
+ await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
+}
+
+describe('KanbanBoard', () => {
+ it('shows a loading note until the todos arrive', async () => {
+ let release;
+ TodoApi.list.mockReturnValue(new Promise((resolve) => { release = resolve; }));
+ render();
+
+ expect(screen.getByText('Loading…')).toBeInTheDocument();
+
+ release([]);
+ await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
+ });
+
+ it('renders the three lanes', async () => {
+ await renderBoard();
+
+ expect(screen.getByRole('heading', { name: 'To Do' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'In Progress' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Done' })).toBeInTheDocument();
+ });
+
+ it('buckets each task into its own lane', async () => {
+ TodoApi.list.mockResolvedValue([
+ todo(1, { status: 0 }),
+ todo(2, { status: 1 }),
+ todo(3, { status: 2, isCompleted: true }),
+ ]);
+ await renderBoard();
+
+ expect(within(lane('To Do')).getByText('Task 1')).toBeInTheDocument();
+ expect(within(lane('In Progress')).getByText('Task 2')).toBeInTheDocument();
+ expect(within(lane('Done')).getByText('Task 3')).toBeInTheDocument();
+ });
+
+ it('keeps a task with an unexpected status off the known lanes', async () => {
+ TodoApi.list.mockResolvedValue([todo(1, { status: 9 })]);
+ await renderBoard();
+
+ // It must not silently land in "To Do" — that would misreport the board.
+ expect(within(lane('To Do')).queryByText('Task 1')).not.toBeInTheDocument();
+ expect(screen.getByText('1 tasks · 0 done')).toBeInTheDocument();
+ });
+
+ it('counts the tasks and the completed ones', async () => {
+ TodoApi.list.mockResolvedValue([
+ todo(1),
+ todo(2, { status: 2, isCompleted: true }),
+ todo(3, { status: 2, isCompleted: true }),
+ ]);
+ await renderBoard();
+
+ expect(screen.getByText('3 tasks · 2 done')).toBeInTheDocument();
+ });
+
+ it('shows the load error', async () => {
+ TodoApi.list.mockRejectedValue(new Error('Network is down'));
+ await renderBoard();
+
+ expect(screen.getByText('Network is down')).toBeInTheDocument();
+ });
+});
+
+describe('KanbanBoard category filter', () => {
+ it('offers every category plus "All"', async () => {
+ await renderBoard();
+
+ const filter = categoryFilter();
+ expect(within(filter).getByRole('option', { name: 'All categories' })).toBeInTheDocument();
+ expect(within(filter).getByRole('option', { name: 'Work' })).toBeInTheDocument();
+ expect(within(filter).getByRole('option', { name: 'Personal' })).toBeInTheDocument();
+ });
+
+ it('hides tasks outside the chosen category', async () => {
+ const user = userEvent.setup();
+ TodoApi.list.mockResolvedValue([
+ todo(1, { categoryId: 1 }),
+ todo(2, { categoryId: 2 }),
+ ]);
+ await renderBoard();
+
+ await user.selectOptions(categoryFilter(), '2');
+
+ expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
+ expect(screen.getByText('Task 2')).toBeInTheDocument();
+ // The footer still counts the whole board, not the filtered view.
+ expect(screen.getByText('2 tasks · 0 done')).toBeInTheDocument();
+ });
+
+ it('falls back to "All" when the selected category disappears', async () => {
+ const user = userEvent.setup();
+ TodoApi.list.mockResolvedValue([todo(1, { categoryId: 1 }), todo(2, { categoryId: 2 })]);
+ await renderBoard();
+
+ await user.selectOptions(categoryFilter(), '2');
+ expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
+
+ // Someone deletes that category in the manager panel; the board reloads the list.
+ CategoryApi.list.mockResolvedValue([categories[0]]);
+ CategoryApi.remove.mockResolvedValue(null);
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+
+ await user.click(toolbar().getByRole('button', { name: 'Manage categories' }));
+ const row = panel().getByText('Personal').closest('li');
+ await user.click(within(row).getByRole('button', { name: 'Delete' }));
+
+ // Without the fallback the board would filter on a category that no longer exists
+ // and show nothing at all.
+ await waitFor(() => expect(categoryFilter()).toHaveValue('all'));
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ });
+});
+
+describe('KanbanBoard category manager panel', () => {
+ it('opens and closes from the toolbar', async () => {
+ const user = userEvent.setup();
+ await renderBoard();
+
+ await user.click(toolbar().getByRole('button', { name: 'Manage categories' }));
+ expect(screen.getByRole('heading', { name: 'Categories' })).toBeInTheDocument();
+
+ await user.click(toolbar().getByRole('button', { name: 'Close' }));
+ expect(screen.queryByRole('heading', { name: 'Categories' })).not.toBeInTheDocument();
+ });
+
+ it('closes from the panel itself', async () => {
+ const user = userEvent.setup();
+ await renderBoard();
+
+ await user.click(toolbar().getByRole('button', { name: 'Manage categories' }));
+ await user.click(panel().getByLabelText('Close'));
+
+ expect(screen.queryByRole('heading', { name: 'Categories' })).not.toBeInTheDocument();
+ });
+
+ it('picks up a newly created category', async () => {
+ const user = userEvent.setup();
+ await renderBoard();
+
+ await user.click(toolbar().getByRole('button', { name: 'Manage categories' }));
+ CategoryApi.create.mockResolvedValue({ id: 3, name: 'Errands', color: '#86c97b' });
+ CategoryApi.list.mockResolvedValue([...categories, { id: 3, name: 'Errands', color: '#86c97b' }]);
+
+ await user.type(panel().getByLabelText('New category name'), 'Errands');
+ await user.click(panel().getByRole('button', { name: 'Add' }));
+
+ await waitFor(() => expect(
+ within(categoryFilter()).getByRole('option', { name: 'Errands' })
+ ).toBeInTheDocument());
+ });
+});
+
+describe('KanbanBoard drag state', () => {
+ it('marks the board while a card is being dragged, and clears it afterwards', async () => {
+ TodoApi.list.mockResolvedValue([todo(1)]);
+ const { container } = render();
+ await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
+
+ const note = screen.getByText('Task 1').closest('.note');
+ const dataTransfer = { setData: vi.fn(), effectAllowed: '' };
+
+ fireEvent.dragStart(note, { dataTransfer });
+ expect(container.querySelector('.board').className).toContain('is-dragging');
+ expect(dataTransfer.setData).toHaveBeenCalledWith('text/plain', '1');
+
+ fireEvent.dragEnd(note);
+ expect(container.querySelector('.board').className).not.toContain('is-dragging');
+ });
+
+ it('moves a card when it is dropped on another lane', async () => {
+ TodoApi.list.mockResolvedValue([todo(1, { status: 0 })]);
+ TodoApi.changeStatus.mockResolvedValue(todo(1, { status: 2, isCompleted: true }));
+ await renderBoard();
+
+ fireEvent.drop(lane('Done'), {
+ dataTransfer: { getData: () => '1', dropEffect: '' },
+ });
+
+ await waitFor(() => expect(TodoApi.changeStatus).toHaveBeenCalledWith(1, 2));
+ await waitFor(() => expect(within(lane('Done')).getByText('Task 1')).toBeInTheDocument());
+ });
+});
+
+describe('KanbanBoard task lifecycle', () => {
+ it('adds a created task to the board', async () => {
+ const user = userEvent.setup();
+ TodoApi.create.mockResolvedValue(todo(9, { title: 'Brand new' }));
+ await renderBoard();
+
+ await user.type(screen.getByPlaceholderText(/add a task/i), 'Brand new');
+ await user.click(screen.getByRole('button', { name: /add/i }));
+
+ await waitFor(() => expect(screen.getByText('Brand new')).toBeInTheDocument());
+ });
+
+ it('removes a deleted task from the board', async () => {
+ const user = userEvent.setup();
+ TodoApi.list.mockResolvedValue([todo(1)]);
+ TodoApi.remove.mockResolvedValue(null);
+ await renderBoard();
+
+ await user.click(screen.getByLabelText('Delete'));
+
+ await waitFor(() => expect(screen.queryByText('Task 1')).not.toBeInTheDocument());
+ expect(TodoApi.remove).toHaveBeenCalledWith(1);
+ });
+
+ it('applies an edit to the card', async () => {
+ const user = userEvent.setup();
+ TodoApi.list.mockResolvedValue([todo(1)]);
+ TodoApi.update.mockResolvedValue(todo(1, { title: 'Renamed' }));
+ await renderBoard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ const title = screen.getByLabelText('Edit title');
+ await user.clear(title);
+ await user.type(title, 'Renamed');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => expect(screen.getByText('Renamed')).toBeInTheDocument());
+ });
+
+ it('explains a concurrency conflict in plain language', async () => {
+ const user = userEvent.setup();
+ TodoApi.list.mockResolvedValue([todo(1)]);
+ const conflict = new Error('The resource was modified by someone else.');
+ conflict.status = 409;
+ TodoApi.update.mockRejectedValue(conflict);
+ await renderBoard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(await screen.findByText(/changed elsewhere/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Lane.test.jsx b/src/components/Lane.test.jsx
new file mode 100644
index 0000000..62211d4
--- /dev/null
+++ b/src/components/Lane.test.jsx
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import Lane from './Lane.jsx';
+
+const categories = [{ id: 1, name: 'Work', color: '#7fb2e6' }];
+
+const todo = (id, overrides = {}) => ({
+ id,
+ title: `Task ${id}`,
+ description: '',
+ status: 0,
+ priority: 1,
+ priorityName: 'Medium',
+ categoryId: 1,
+ dueDate: null,
+ isCompleted: false,
+ concurrencyToken: `token-${id}`,
+ ...overrides,
+});
+
+function renderLane({ todos = [], status = 0 } = {}) {
+ const handlers = {
+ onDropCard: vi.fn(),
+ onDragStart: vi.fn(),
+ onDragEnd: vi.fn(),
+ onUpdate: vi.fn(),
+ onDelete: vi.fn(),
+ };
+
+ const { container } = render(
+
+ );
+
+ return { ...handlers, lane: container.querySelector('.lane') };
+}
+
+/** jsdom implements no drag-and-drop, so the transfer object is supplied explicitly. */
+function dataTransfer(payload = '') {
+ return {
+ dropEffect: '',
+ getData: vi.fn(() => payload),
+ setData: vi.fn(),
+ };
+}
+
+describe('Lane', () => {
+ it('shows the label and the card count', () => {
+ renderLane({ todos: [todo(1), todo(2)] });
+
+ expect(screen.getByRole('heading', { name: 'To Do' })).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('prompts when empty', () => {
+ renderLane({ todos: [] });
+
+ expect(screen.getByText('Drop tasks here')).toBeInTheDocument();
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('renders a card per todo', () => {
+ renderLane({ todos: [todo(1), todo(2)] });
+
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
+ expect(screen.getByText('Task 2')).toBeInTheDocument();
+ expect(screen.queryByText('Drop tasks here')).not.toBeInTheDocument();
+ });
+
+ it('highlights while a card is dragged over it', () => {
+ const { lane } = renderLane();
+
+ fireEvent.dragOver(lane, { dataTransfer: dataTransfer() });
+
+ expect(lane.className).toContain('is-over');
+ });
+
+ it('stays highlighted across repeated dragover events', () => {
+ const { lane } = renderLane();
+ const transfer = dataTransfer();
+
+ fireEvent.dragOver(lane, { dataTransfer: transfer });
+ fireEvent.dragOver(lane, { dataTransfer: transfer });
+
+ expect(lane.className).toContain('is-over');
+ });
+
+ it('drops the highlight when the card leaves', () => {
+ const { lane } = renderLane();
+
+ fireEvent.dragOver(lane, { dataTransfer: dataTransfer() });
+ fireEvent.dragLeave(lane);
+
+ expect(lane.className).not.toContain('is-over');
+ });
+
+ it('moves the dropped card into this lane', () => {
+ const { lane, onDropCard } = renderLane({ status: 2 });
+
+ fireEvent.dragOver(lane, { dataTransfer: dataTransfer() });
+ fireEvent.drop(lane, { dataTransfer: dataTransfer('7') });
+
+ expect(onDropCard).toHaveBeenCalledWith(7, 2);
+ expect(lane.className).not.toContain('is-over');
+ });
+
+ it('ignores a drop that carries no card id', () => {
+ const { lane, onDropCard } = renderLane();
+
+ fireEvent.drop(lane, { dataTransfer: dataTransfer('') });
+
+ expect(onDropCard).not.toHaveBeenCalled();
+ });
+
+ it('ignores a drop carrying something that is not a card id', () => {
+ const { lane, onDropCard } = renderLane();
+
+ fireEvent.drop(lane, { dataTransfer: dataTransfer('not-a-number') });
+
+ expect(onDropCard).not.toHaveBeenCalled();
+ });
+
+ it('passes the move handler down so a tap-move works too', async () => {
+ const userEvent = (await import('@testing-library/user-event')).default;
+ const user = userEvent.setup();
+ const { onDropCard } = renderLane({ todos: [todo(3)], status: 0 });
+
+ await user.click(screen.getByLabelText('Move to another lane'));
+ await user.click(screen.getByRole('button', { name: '→ Done' }));
+
+ expect(onDropCard).toHaveBeenCalledWith(3, 2);
+ });
+});
diff --git a/src/components/TaskCard.test.jsx b/src/components/TaskCard.test.jsx
new file mode 100644
index 0000000..bf1b10b
--- /dev/null
+++ b/src/components/TaskCard.test.jsx
@@ -0,0 +1,312 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, within, act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import TaskCard from './TaskCard.jsx';
+
+const categories = [
+ { id: 1, name: 'Work', color: '#7fb2e6' },
+ { id: 2, name: 'Personal', color: null },
+];
+
+const baseTodo = {
+ id: 10,
+ title: 'Write the thing',
+ description: '',
+ status: 0,
+ priority: 1,
+ priorityName: 'Medium',
+ categoryId: 1,
+ dueDate: null,
+ isCompleted: false,
+ concurrencyToken: 'token-1',
+};
+
+function renderCard(todo = {}, props = {}) {
+ const handlers = {
+ onUpdate: vi.fn().mockResolvedValue(undefined),
+ onDelete: vi.fn(),
+ onMove: vi.fn(),
+ onDragStart: vi.fn(),
+ onDragEnd: vi.fn(),
+ ...props,
+ };
+
+ render();
+ return handlers;
+}
+
+describe('TaskCard display', () => {
+ it('shows the title and its category', () => {
+ renderCard();
+
+ expect(screen.getByText('Write the thing')).toBeInTheDocument();
+ expect(screen.getByText('Work')).toBeInTheDocument();
+ });
+
+ it('falls back to "Uncategorized" when the category is missing', () => {
+ renderCard({ categoryId: null });
+
+ expect(screen.getByText('Uncategorized')).toBeInTheDocument();
+ });
+
+ it('falls back to "Uncategorized" when the category was deleted', () => {
+ renderCard({ categoryId: 999 });
+
+ expect(screen.getByText('Uncategorized')).toBeInTheDocument();
+ });
+
+ it('hides the notes line when there are none', () => {
+ renderCard({ description: '' });
+
+ expect(screen.queryByText(/notes go here/i)).not.toBeInTheDocument();
+ });
+
+ it('shows the notes when there are some', () => {
+ renderCard({ description: 'notes go here' });
+
+ expect(screen.getByText('notes go here')).toBeInTheDocument();
+ });
+
+ it('marks a completed task with a check', () => {
+ renderCard({ isCompleted: true, status: 2 });
+
+ expect(screen.getByTitle('Done')).toBeInTheDocument();
+ });
+
+ it('leaves an open task unchecked', () => {
+ renderCard();
+
+ expect(screen.queryByTitle('Done')).not.toBeInTheDocument();
+ });
+
+ it('shows no due date when the task has none', () => {
+ renderCard({ dueDate: null });
+
+ expect(screen.queryByText(/overdue/)).not.toBeInTheDocument();
+ });
+});
+
+describe('TaskCard due dates', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('flags a past due date as overdue', () => {
+ renderCard({ dueDate: '2026-06-01T00:00:00Z' });
+
+ expect(screen.getByText(/overdue/)).toBeInTheDocument();
+ });
+
+ it('does not flag a future due date', () => {
+ renderCard({ dueDate: '2026-07-01T00:00:00Z' });
+
+ expect(screen.queryByText(/overdue/)).not.toBeInTheDocument();
+ });
+
+ it('does not call a completed task overdue', () => {
+ renderCard({ dueDate: '2026-06-01T00:00:00Z', isCompleted: true });
+
+ expect(screen.queryByText(/overdue/)).not.toBeInTheDocument();
+ });
+});
+
+describe('TaskCard actions', () => {
+ it('deletes on the delete control', async () => {
+ const user = userEvent.setup();
+ const { onDelete } = renderCard();
+
+ await user.click(screen.getByLabelText('Delete'));
+
+ expect(onDelete).toHaveBeenCalledWith(10);
+ });
+
+ it('reports the drag so the board can highlight the lanes', () => {
+ const { onDragStart } = renderCard();
+ const note = screen.getByText('Write the thing').closest('.note');
+
+ // jsdom has no drag support, so the handler is invoked the way React would.
+ const dataTransfer = { setData: vi.fn(), effectAllowed: '' };
+ fireEvent.dragStart(note, { dataTransfer });
+
+ expect(onDragStart).toHaveBeenCalled();
+ });
+
+ it('reports the end of a drag', () => {
+ const { onDragEnd } = renderCard();
+ const note = screen.getByText('Write the thing').closest('.note');
+
+ fireEvent.dragEnd(note);
+
+ expect(onDragEnd).toHaveBeenCalled();
+ });
+});
+
+describe('TaskCard tap-to-move', () => {
+ // Native HTML5 drag events are mouse-only, so touch devices need this control.
+ it('offers the other lanes and not the current one', async () => {
+ const user = userEvent.setup();
+ renderCard({ status: 0 });
+
+ await user.click(screen.getByLabelText('Move to another lane'));
+
+ const group = screen.getByRole('group', { name: 'Move this task to' });
+ expect(within(group).getByRole('button', { name: '→ In Progress' })).toBeInTheDocument();
+ expect(within(group).getByRole('button', { name: '→ Done' })).toBeInTheDocument();
+ expect(within(group).queryByRole('button', { name: '→ To Do' })).not.toBeInTheDocument();
+ });
+
+ it('moves the task and closes the control', async () => {
+ const user = userEvent.setup();
+ const { onMove } = renderCard({ status: 0 });
+
+ await user.click(screen.getByLabelText('Move to another lane'));
+ await user.click(screen.getByRole('button', { name: '→ Done' }));
+
+ expect(onMove).toHaveBeenCalledWith(10, 2);
+ expect(screen.queryByRole('group', { name: 'Move this task to' })).not.toBeInTheDocument();
+ });
+
+ it('toggles closed again', async () => {
+ const user = userEvent.setup();
+ renderCard();
+
+ await user.click(screen.getByLabelText('Move to another lane'));
+ await user.click(screen.getByLabelText('Move to another lane'));
+
+ expect(screen.queryByRole('group', { name: 'Move this task to' })).not.toBeInTheDocument();
+ });
+});
+
+describe('TaskCard editing', () => {
+ it('saves the edited fields with the concurrency token the card was rendered with', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard({ description: 'old notes' });
+
+ await user.click(screen.getByLabelText('Edit'));
+ const title = screen.getByLabelText('Edit title');
+ await user.clear(title);
+ await user.type(title, 'New title');
+ await user.selectOptions(screen.getByLabelText('Edit category'), '2');
+ await user.selectOptions(screen.getByLabelText('Edit priority'), '2');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({
+ title: 'New title',
+ description: 'old notes',
+ priority: 2,
+ categoryId: 2,
+ dueDate: null,
+ concurrencyToken: 'token-1',
+ }));
+ // Back to the read view: the card re-renders from props, which the parent owns.
+ expect(screen.queryByLabelText('Edit title')).not.toBeInTheDocument();
+ });
+
+ it('sends null for cleared notes and no category', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard({ description: 'old notes' });
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.clear(screen.getByLabelText('Edit notes'));
+ await user.selectOptions(screen.getByLabelText('Edit category'), '');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({
+ description: null,
+ categoryId: null,
+ }));
+ });
+
+ it('trims whitespace off the title', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ const title = screen.getByLabelText('Edit title');
+ await user.clear(title);
+ await user.type(title, ' Trimmed ');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ title: 'Trimmed' }));
+ });
+
+ it('refuses to save a blank title', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.clear(screen.getByLabelText('Edit title'));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).not.toHaveBeenCalled();
+ expect(screen.getByLabelText('Edit title')).toBeInTheDocument(); // still editing
+ });
+
+ it('discards the draft on cancel', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.type(screen.getByLabelText('Edit title'), ' extra');
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onUpdate).not.toHaveBeenCalled();
+ expect(screen.getByText('Write the thing')).toBeInTheDocument();
+ });
+
+ it('disables both buttons while the save is in flight, then closes the editor', async () => {
+ const user = userEvent.setup();
+ let finishSave;
+ const onUpdate = vi.fn(() => new Promise((resolve) => { finishSave = resolve; }));
+ renderCard({}, { onUpdate });
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ // A second click would send a second update with the same concurrency token.
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+
+ await act(async () => { finishSave(); });
+
+ expect(screen.queryByLabelText('Edit title')).not.toBeInTheDocument();
+ });
+
+ it('seeds the date field from an existing due date', async () => {
+ const user = userEvent.setup();
+ renderCard({ dueDate: '2026-07-19T00:00:00Z' });
+
+ await user.click(screen.getByLabelText('Edit'));
+
+ expect(screen.getByLabelText('Edit due date')).toHaveValue('07/19/2026');
+ });
+
+ it('sends a due date typed into the date bar', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard();
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.type(screen.getByLabelText('Edit due date'), '07192026');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({
+ dueDate: new Date('2026-07-19').toISOString(),
+ }));
+ });
+
+ it('clears a due date that is emptied out', async () => {
+ const user = userEvent.setup();
+ const { onUpdate } = renderCard({ dueDate: '2026-07-19T00:00:00Z' });
+
+ await user.click(screen.getByLabelText('Edit'));
+ await user.clear(screen.getByLabelText('Edit due date'));
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onUpdate).toHaveBeenCalledWith(10, expect.objectContaining({ dueDate: null }));
+ });
+});
diff --git a/src/components/ThemeToggle.test.jsx b/src/components/ThemeToggle.test.jsx
index 3764f0a..2a90b34 100644
--- a/src/components/ThemeToggle.test.jsx
+++ b/src/components/ThemeToggle.test.jsx
@@ -22,4 +22,47 @@ describe('', () => {
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem('todo.theme')).toBe('light');
});
+
+ it('starts dark when that was the stored choice', () => {
+ localStorage.setItem('todo.theme', 'dark');
+
+ render();
+
+ expect(screen.getByRole('button', { name: /switch to light mode/i })).toBeInTheDocument();
+ });
+
+ it('starts light when that was the stored choice, whatever the OS says', () => {
+ localStorage.setItem('todo.theme', 'light');
+ window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} });
+
+ render();
+
+ expect(screen.getByRole('button', { name: /switch to dark mode/i })).toBeInTheDocument();
+ });
+
+ it('follows the OS preference until a choice is made', () => {
+ window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} });
+
+ render();
+
+ expect(screen.getByRole('button', { name: /switch to light mode/i })).toBeInTheDocument();
+ });
+
+ it('treats an unavailable matchMedia as light rather than failing', () => {
+ window.matchMedia = () => { throw new Error('unsupported'); };
+
+ render();
+
+ expect(screen.getByRole('button', { name: /switch to dark mode/i })).toBeInTheDocument();
+ });
+
+ it('toggles back to light', async () => {
+ localStorage.setItem('todo.theme', 'dark');
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: /switch to light mode/i }));
+
+ expect(localStorage.getItem('todo.theme')).toBe('light');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light');
+ });
});
diff --git a/src/components/TodoForm.test.jsx b/src/components/TodoForm.test.jsx
index 449fc4b..9dfb067 100644
--- a/src/components/TodoForm.test.jsx
+++ b/src/components/TodoForm.test.jsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoForm from './TodoForm.jsx';
@@ -20,4 +20,65 @@ describe('', () => {
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0][0]).toMatchObject({ title: 'Buy milk' });
});
+
+ it('sends every field and then clears the form', async () => {
+ const onCreate = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ await userEvent.type(screen.getByLabelText('Title'), 'Buy milk');
+ await userEvent.type(screen.getByLabelText('Description'), ' two litres ');
+ await userEvent.selectOptions(screen.getByLabelText('Category'), '1');
+ await userEvent.selectOptions(screen.getByLabelText('Priority'), '2');
+ await userEvent.type(screen.getByLabelText('Due date'), '07192026');
+ await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
+
+ expect(onCreate).toHaveBeenCalledWith({
+ title: 'Buy milk',
+ description: 'two litres',
+ priority: 2,
+ categoryId: 1,
+ dueDate: new Date('2026-07-19').toISOString(),
+ });
+ expect(screen.getByLabelText('Title')).toHaveValue('');
+ });
+
+ it('sends nulls for the optional fields left blank', async () => {
+ const onCreate = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ await userEvent.type(screen.getByLabelText('Title'), 'Bare task');
+ await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
+
+ expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
+ description: null,
+ categoryId: null,
+ dueDate: null,
+ }));
+ });
+
+ it('shows the failure and keeps what was typed', async () => {
+ const onCreate = vi.fn().mockRejectedValue(new Error('Title must be under 200 characters.'));
+ render();
+
+ await userEvent.type(screen.getByLabelText('Title'), 'Too long');
+ await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
+
+ expect(await screen.findByText('Title must be under 200 characters.')).toBeInTheDocument();
+ expect(screen.getByLabelText('Title')).toHaveValue('Too long');
+ });
+
+ it('disables the button while the create is in flight', async () => {
+ let finish;
+ const onCreate = vi.fn(() => new Promise((resolve) => { finish = resolve; }));
+ render();
+
+ await userEvent.type(screen.getByLabelText('Title'), 'Slow one');
+ await userEvent.click(screen.getByRole('button', { name: /^add$/i }));
+
+ expect(screen.getByRole('button', { name: /adding/i })).toBeDisabled();
+
+ await act(async () => { finish(); });
+
+ expect(screen.getByRole('button', { name: /^add$/i })).toBeEnabled();
+ });
});
diff --git a/src/hooks/useCategories.test.jsx b/src/hooks/useCategories.test.jsx
new file mode 100644
index 0000000..bd8afb0
--- /dev/null
+++ b/src/hooks/useCategories.test.jsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+
+vi.mock('../lib/apiClient.js', () => ({
+ CategoryApi: { list: vi.fn() },
+}));
+
+import { CategoryApi } from '../lib/apiClient.js';
+import { useCategories } from './useCategories.js';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('useCategories()', () => {
+ it('starts empty and loads on mount', async () => {
+ CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }]);
+
+ const { result } = renderHook(() => useCategories());
+
+ expect(result.current.categories).toEqual([]);
+ await waitFor(() => expect(result.current.categories).toHaveLength(1));
+ expect(CategoryApi.list).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches on reload', async () => {
+ CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }]);
+ const { result } = renderHook(() => useCategories());
+ await waitFor(() => expect(result.current.categories).toHaveLength(1));
+
+ CategoryApi.list.mockResolvedValue([{ id: 1, name: 'Work' }, { id: 2, name: 'Personal' }]);
+ await act(async () => { await result.current.reload(); });
+
+ expect(result.current.categories).toHaveLength(2);
+ expect(CategoryApi.list).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps a stable reload identity so effects do not re-run', async () => {
+ CategoryApi.list.mockResolvedValue([]);
+ const { result, rerender } = renderHook(() => useCategories());
+ await waitFor(() => expect(CategoryApi.list).toHaveBeenCalled());
+
+ const first = result.current.reload;
+ rerender();
+
+ expect(result.current.reload).toBe(first);
+ expect(CategoryApi.list).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/hooks/useTodos.test.jsx b/src/hooks/useTodos.test.jsx
index 7758998..3e17803 100644
--- a/src/hooks/useTodos.test.jsx
+++ b/src/hooks/useTodos.test.jsx
@@ -60,3 +60,141 @@ describe('useTodos()', () => {
expect(result.current.error).toBe('boom');
});
});
+
+describe('useTodos() mutations', () => {
+ it('surfaces a failed initial load', async () => {
+ TodoApi.list.mockRejectedValue(new Error('Network is down'));
+ const { result } = renderHook(() => useTodos());
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.error).toBe('Network is down');
+ expect(result.current.todos).toEqual([]);
+ });
+
+ it('ignores a move to the lane the card is already in', async () => {
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.moveCard(1, 0); });
+
+ expect(TodoApi.changeStatus).not.toHaveBeenCalled();
+ });
+
+ it('ignores a move for a card that is not on the board', async () => {
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.moveCard(999, 2); });
+
+ expect(TodoApi.changeStatus).not.toHaveBeenCalled();
+ });
+
+ it('keeps the optimistic move when the server returns nothing to reconcile', async () => {
+ TodoApi.changeStatus.mockResolvedValue(null);
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.moveCard(1, 2); });
+
+ expect(result.current.todos.find((t) => t.id === 1).status).toBe(2);
+ expect(TodoApi.list).toHaveBeenCalledTimes(1); // no reload
+ });
+
+ it('appends a created todo without refetching the board', async () => {
+ TodoApi.create.mockResolvedValue({ id: 3, title: 'C', status: 0 });
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.createTodo({ title: 'C' }); });
+
+ expect(result.current.todos).toHaveLength(3);
+ expect(TodoApi.list).toHaveBeenCalledTimes(1);
+ });
+
+ it('reloads when a create returns nothing to append', async () => {
+ TodoApi.create.mockResolvedValue(null);
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.createTodo({ title: 'C' }); });
+
+ expect(TodoApi.list).toHaveBeenCalledTimes(2);
+ });
+
+ it('lets a failed create reach the caller so the form can show it', async () => {
+ TodoApi.create.mockRejectedValue(new Error('Title is required.'));
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await expect(result.current.createTodo({ title: '' })).rejects.toThrow('Title is required.');
+ });
+
+ it('merges an updated todo into place', async () => {
+ TodoApi.update.mockResolvedValue({ id: 1, title: 'Renamed' });
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); });
+
+ expect(result.current.todos.find((t) => t.id === 1).title).toBe('Renamed');
+ expect(TodoApi.list).toHaveBeenCalledTimes(1);
+ });
+
+ it('reloads when an update returns nothing to merge', async () => {
+ TodoApi.update.mockResolvedValue(null);
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); });
+
+ expect(TodoApi.list).toHaveBeenCalledTimes(2);
+ });
+
+ it('reloads and explains a 409 rather than showing the raw message', async () => {
+ const conflict = new Error('The resource was modified by someone else.');
+ conflict.status = 409;
+ TodoApi.update.mockRejectedValue(conflict);
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.updateTodo(1, { title: 'Renamed' }); });
+
+ expect(TodoApi.list).toHaveBeenCalledTimes(2);
+ expect(result.current.error).toMatch(/changed elsewhere/i);
+ });
+
+ it('shows any other update failure as-is without reloading', async () => {
+ TodoApi.update.mockRejectedValue(new Error('Title is required.'));
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.updateTodo(1, { title: '' }); });
+
+ expect(result.current.error).toBe('Title is required.');
+ expect(TodoApi.list).toHaveBeenCalledTimes(1);
+ });
+
+ it('removes a deleted todo immediately', async () => {
+ TodoApi.remove.mockResolvedValue(null);
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.deleteTodo(1); });
+
+ expect(result.current.todos.map((t) => t.id)).toEqual([2]);
+ expect(TodoApi.list).toHaveBeenCalledTimes(1);
+ });
+
+ it('puts a failed delete back by reloading', async () => {
+ TodoApi.remove.mockRejectedValue(new Error('Gone already'));
+ const { result } = renderHook(() => useTodos());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => { await result.current.deleteTodo(1); });
+
+ expect(TodoApi.list).toHaveBeenCalledTimes(2);
+ expect(result.current.todos).toHaveLength(2);
+ expect(result.current.error).toBe('Gone already');
+ });
+});
diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js
new file mode 100644
index 0000000..fe3d3cf
--- /dev/null
+++ b/src/lib/apiClient.test.js
@@ -0,0 +1,450 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// Every test re-imports the module so the in-memory access token, the shared in-flight
+// refresh, and the registered callbacks all start clean.
+async function loadClient() {
+ vi.resetModules();
+ return import('./apiClient.js');
+}
+
+/** A fetch response stub. `body` is serialised unless it is already a string. */
+function respond(status, body = null, { text } = {}) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ text: async () => (text !== undefined ? text : body === null ? '' : JSON.stringify(body)),
+ };
+}
+
+let fetchMock;
+
+beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+describe('request plumbing', () => {
+ it('sends JSON and credentials, and returns the parsed body', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, [{ id: 1 }]));
+
+ const todos = await TodoApi.list();
+
+ expect(todos).toEqual([{ id: 1 }]);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/todos');
+ expect(init.credentials).toBe('include');
+ expect(init.headers['Content-Type']).toBe('application/json');
+ });
+
+ it('omits the Authorization header until a session is set', async () => {
+ const { TodoApi, setSession } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, []));
+
+ await TodoApi.list();
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
+
+ setSession({ accessToken: 'token-abc' });
+ await TodoApi.list();
+ expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer token-abc');
+ });
+
+ it('drops the Authorization header again once the session is cleared', async () => {
+ const { TodoApi, setSession, clearSession } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, []));
+
+ setSession({ accessToken: 'token-abc' });
+ clearSession();
+ await TodoApi.list();
+
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
+ });
+
+ it('treats 204 as no content', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(204));
+
+ await expect(TodoApi.remove(1)).resolves.toBeNull();
+ });
+
+ it('treats an empty body as no content', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, null, { text: '' }));
+
+ await expect(TodoApi.list()).resolves.toBeNull();
+ });
+
+ it('hasSession is always true — the refresh cookie is httpOnly and cannot be inspected', async () => {
+ const { hasSession } = await loadClient();
+
+ expect(hasSession()).toBe(true);
+ });
+});
+
+describe('error mapping', () => {
+ it('prefers the problem title', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(400, { title: 'Bad input', detail: 'ignored' }));
+
+ await expect(TodoApi.list()).rejects.toThrow('Bad input');
+ });
+
+ it('falls back to the problem detail', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(409, { detail: 'Already exists' }));
+
+ await expect(TodoApi.list()).rejects.toThrow('Already exists');
+ });
+
+ it('falls back to the status code when the body carries neither', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(500, {}));
+
+ await expect(TodoApi.list()).rejects.toThrow('Request failed (500)');
+ });
+
+ it('falls back to the status code when there is no body at all', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(500, null, { text: '' }));
+
+ await expect(TodoApi.list()).rejects.toThrow('Request failed (500)');
+ });
+
+ it('carries the status and the problem document on the error', async () => {
+ const { TodoApi } = await loadClient();
+ const problem = { title: 'Conflict', current: { id: 1 } };
+ fetchMock.mockResolvedValue(respond(409, problem));
+
+ await expect(TodoApi.list()).rejects.toMatchObject({ status: 409, problem });
+ });
+});
+
+describe('401 refresh-and-retry', () => {
+ it('refreshes once and replays the original request', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock
+ .mockResolvedValueOnce(respond(401, { title: 'Expired' }))
+ .mockResolvedValueOnce(respond(200, { accessToken: 'fresh' })) // the refresh
+ .mockResolvedValueOnce(respond(200, [{ id: 7 }])); // the replay
+
+ await expect(TodoApi.list()).resolves.toEqual([{ id: 7 }]);
+
+ const [, refreshInit] = fetchMock.mock.calls[1];
+ expect(refreshInit.method).toBe('POST');
+ // The header's presence is the CSRF proof; its value is irrelevant.
+ expect(refreshInit.headers['X-Refresh-CSRF']).toBeDefined();
+ // The replay carries the token the refresh returned.
+ expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('Bearer fresh');
+ });
+
+ it('gives up and notifies when the refresh itself fails', async () => {
+ const { TodoApi, setOnUnauthorized, setSession } = await loadClient();
+ const onUnauthorized = vi.fn();
+ setOnUnauthorized(onUnauthorized);
+ setSession({ accessToken: 'stale' });
+
+ fetchMock
+ .mockResolvedValueOnce(respond(401, { title: 'Expired' }))
+ .mockResolvedValueOnce(respond(401, { title: 'No cookie' })); // the refresh
+
+ await expect(TodoApi.list()).rejects.toThrow('Expired');
+
+ expect(onUnauthorized).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledTimes(2); // no replay after a failed refresh
+ });
+
+ it('does not require an unauthorized handler to be registered', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock
+ .mockResolvedValueOnce(respond(401, { title: 'Expired' }))
+ .mockResolvedValueOnce(respond(401, { title: 'No cookie' }));
+
+ await expect(TodoApi.list()).rejects.toThrow('Expired');
+ });
+
+ it('shares one refresh between requests that 401 at the same instant', async () => {
+ const { TodoApi } = await loadClient();
+ let releaseRefresh;
+ const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
+
+ fetchMock.mockImplementation(async (url) => {
+ if (url.endsWith('/api/auth/refresh')) {
+ await refreshGate;
+ return respond(200, { accessToken: 'fresh' });
+ }
+ // First call from each of the two requests 401s; the replays succeed.
+ return fetchMock.mock.calls.filter((c) => !c[0].endsWith('/api/auth/refresh')).length <= 2
+ ? respond(401, { title: 'Expired' })
+ : respond(200, []);
+ });
+
+ const both = Promise.all([TodoApi.list(), TodoApi.list()]);
+ await Promise.resolve();
+ releaseRefresh();
+ await both;
+
+ // Two POSTs of the same rotating refresh token would look like reuse to the backend
+ // and revoke every session.
+ const refreshCalls = fetchMock.mock.calls.filter((c) => c[0].endsWith('/api/auth/refresh'));
+ expect(refreshCalls).toHaveLength(1);
+ });
+
+ it('allows a fresh refresh after the in-flight one settles', async () => {
+ const { AuthApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, { accessToken: 'fresh' }));
+
+ await expect(AuthApi.refresh()).resolves.toBe(true);
+ await expect(AuthApi.refresh()).resolves.toBe(true);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('reports a failed refresh as not signed in', async () => {
+ const { AuthApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(401, { title: 'No cookie' }));
+
+ await expect(AuthApi.refresh()).resolves.toBe(false);
+ });
+});
+
+describe('cold-start resilience', () => {
+ // Azure's Free tier unloads the app after idle, so the first request after a quiet spell
+ // can take a minute. These paths are the difference between that and "Failed to fetch".
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ /** Runs `promise` to completion, flushing the backoff sleeps as they are scheduled. */
+ async function withBackoffFlushed(promise) {
+ const settled = promise.then(
+ (value) => ({ value }),
+ (error) => ({ error }),
+ );
+
+ let done = false;
+ settled.then(() => { done = true; });
+
+ // Each iteration lets pending microtasks run, then fires whatever sleep they queued.
+ for (let i = 0; i < 40 && !done; i++) {
+ await vi.advanceTimersByTimeAsync(20000);
+ }
+
+ const outcome = await settled;
+ if (outcome.error) throw outcome.error;
+ return outcome.value;
+ }
+
+ it.each([502, 503, 504])('retries a %i while the instance is starting up', async (status) => {
+ const { TodoApi, setOnServerWaking } = await loadClient();
+ const onWaking = vi.fn();
+ setOnServerWaking(onWaking);
+
+ fetchMock
+ .mockResolvedValueOnce(respond(status))
+ .mockResolvedValueOnce(respond(200, []));
+
+ await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ // The UI is told once that it is waiting, and once that the wait is over.
+ expect(onWaking.mock.calls).toEqual([[true], [false]]);
+ });
+
+ it('retries a network error until the server answers', async () => {
+ const { TodoApi, setOnServerWaking } = await loadClient();
+ const onWaking = vi.fn();
+ setOnServerWaking(onWaking);
+
+ fetchMock
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
+ .mockResolvedValueOnce(respond(200, [{ id: 1 }]));
+
+ await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([{ id: 1 }]);
+
+ expect(onWaking.mock.calls).toEqual([[true], [false]]);
+ });
+
+ it('gives up on a network error once the retry budget is spent', async () => {
+ const { TodoApi, setOnServerWaking } = await loadClient();
+ const onWaking = vi.fn();
+ setOnServerWaking(onWaking);
+
+ fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
+
+ await expect(withBackoffFlushed(TodoApi.list())).rejects.toThrow('Failed to fetch');
+
+ // The first attempt plus WAKE_MAX_RETRIES more.
+ expect(fetchMock).toHaveBeenCalledTimes(7);
+ expect(onWaking).toHaveBeenLastCalledWith(false);
+ });
+
+ it('surfaces a persistent 503 as an error rather than retrying forever', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(503, { title: 'Service Unavailable' }));
+
+ await expect(withBackoffFlushed(TodoApi.list())).rejects.toThrow('Service Unavailable');
+
+ expect(fetchMock).toHaveBeenCalledTimes(7);
+ });
+
+ it('does not signal the UI when the very first attempt succeeds', async () => {
+ const { TodoApi, setOnServerWaking } = await loadClient();
+ const onWaking = vi.fn();
+ setOnServerWaking(onWaking);
+
+ fetchMock.mockResolvedValue(respond(200, []));
+
+ await withBackoffFlushed(TodoApi.list());
+
+ expect(onWaking).not.toHaveBeenCalled();
+ });
+
+ it('works with no waking handler registered', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock
+ .mockResolvedValueOnce(respond(503))
+ .mockResolvedValueOnce(respond(200, []));
+
+ await expect(withBackoffFlushed(TodoApi.list())).resolves.toEqual([]);
+ });
+});
+
+describe('AuthApi', () => {
+ it.each([
+ ['register', ['a@b.com', 'pw'], '/api/auth/register', { email: 'a@b.com', password: 'pw' }],
+ ['login', ['a@b.com', 'pw'], '/api/auth/login', { email: 'a@b.com', password: 'pw' }],
+ ['google', ['id-token'], '/api/auth/google', { idToken: 'id-token' }],
+ ])('%s posts to %s and adopts the returned session', async (method, args, path, body) => {
+ const { AuthApi, TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, { accessToken: 'new-token', user: { id: 1 } }));
+
+ const auth = await AuthApi[method](...args);
+
+ expect(auth.user).toEqual({ id: 1 });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(path);
+ expect(JSON.parse(init.body)).toEqual(body);
+ // credentials: 'include' so the browser stores the refresh cookie the server sets.
+ expect(init.credentials).toBe('include');
+ // No Authorization header on an anonymous endpoint.
+ expect(init.headers.Authorization).toBeUndefined();
+
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue(respond(200, []));
+ await TodoApi.list();
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer new-token');
+ });
+
+ it('surfaces a rejected sign-in', async () => {
+ const { AuthApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(401, { title: 'Invalid email or password.' }));
+
+ await expect(AuthApi.login('a@b.com', 'wrong')).rejects.toThrow('Invalid email or password.');
+ });
+
+ it('me() reads the current profile', async () => {
+ const { AuthApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, { id: 1, email: 'a@b.com' }));
+
+ await expect(AuthApi.me()).resolves.toEqual({ id: 1, email: 'a@b.com' });
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/auth/me');
+ });
+
+ it.each(['logout', 'revokeAll'])('%s clears the local session even when the call fails', async (method) => {
+ const { AuthApi, TodoApi, setSession } = await loadClient();
+ setSession({ accessToken: 'token-abc' });
+ fetchMock.mockResolvedValue(respond(500, { title: 'Boom' }));
+
+ await expect(AuthApi[method]()).rejects.toThrow('Boom');
+
+ // The user asked to be signed out; a server error must not leave the token in memory.
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue(respond(200, []));
+ await TodoApi.list();
+ expect(fetchMock.mock.calls[0][1].headers.Authorization).toBeUndefined();
+ });
+
+ it.each([
+ ['logout', '/api/auth/logout'],
+ ['revokeAll', '/api/auth/revoke-all'],
+ ])('%s posts to %s', async (method, path) => {
+ const { AuthApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(204));
+
+ await AuthApi[method]();
+
+ expect(fetchMock.mock.calls[0][0]).toBe(path);
+ expect(fetchMock.mock.calls[0][1].method).toBe('POST');
+ });
+});
+
+describe('CategoryApi', () => {
+ it.each([
+ ['list', [], 'GET', '/api/categories', undefined],
+ ['create', [{ name: 'Work' }], 'POST', '/api/categories', { name: 'Work' }],
+ ['update', [3, { name: 'Study' }], 'PUT', '/api/categories/3', { name: 'Study' }],
+ ['remove', [3], 'DELETE', '/api/categories/3', undefined],
+ ])('%s issues %s %s', async (method, args, verb, path, body) => {
+ const { CategoryApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(204));
+
+ await CategoryApi[method](...args);
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(path);
+ expect(init.method ?? 'GET').toBe(verb);
+ expect(init.body ? JSON.parse(init.body) : undefined).toEqual(body);
+ });
+});
+
+describe('TodoApi', () => {
+ it.each([
+ ['no filter or search', [], '/api/todos'],
+ ['the default All filter dropped', ['All'], '/api/todos'],
+ ['a filter', ['Active'], '/api/todos?filter=Active'],
+ ['a search term', ['All', 'milk'], '/api/todos?search=milk'],
+ ['both', ['Completed', 'milk'], '/api/todos?filter=Completed&search=milk'],
+ ['an empty filter', ['', 'milk'], '/api/todos?search=milk'],
+ ])('list() with %s requests %s', async (_label, args, expected) => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, []));
+
+ await TodoApi.list(...args);
+
+ expect(fetchMock.mock.calls[0][0]).toBe(expected);
+ });
+
+ it.each([
+ ['create', [{ title: 'A' }], 'POST', '/api/todos', { title: 'A' }],
+ ['update', [5, { title: 'B' }], 'PUT', '/api/todos/5', { title: 'B' }],
+ ['remove', [5], 'DELETE', '/api/todos/5', undefined],
+ ])('%s issues %s %s', async (method, args, verb, path, body) => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(204));
+
+ await TodoApi[method](...args);
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe(path);
+ expect(init.method).toBe(verb);
+ expect(init.body ? JSON.parse(init.body) : undefined).toEqual(body);
+ });
+
+ it('changeStatus sends the concurrency token so a stale move is rejected', async () => {
+ const { TodoApi } = await loadClient();
+ fetchMock.mockResolvedValue(respond(200, { id: 5 }));
+
+ await TodoApi.changeStatus(5, 2, 'token-xyz');
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/todos/5/status');
+ expect(init.method).toBe('PATCH');
+ expect(JSON.parse(init.body)).toEqual({ status: 2, concurrencyToken: 'token-xyz' });
+ });
+});
diff --git a/src/lib/colors.test.js b/src/lib/colors.test.js
index bb56e26..317fcb0 100644
--- a/src/lib/colors.test.js
+++ b/src/lib/colors.test.js
@@ -56,3 +56,49 @@ describe('hexToHsv()', () => {
expect(hexToHsv('nope')).toBe(null);
});
});
+
+describe('hsvToHex() across the hue circle', () => {
+ it('covers every sixth of the circle', () => {
+ expect(hsvToHex(0, 1, 1)).toBe('#ff0000'); // 0-60
+ expect(hsvToHex(90, 1, 1)).toBe('#80ff00'); // 60-120
+ expect(hsvToHex(150, 1, 1)).toBe('#00ff80'); // 120-180
+ expect(hsvToHex(210, 1, 1)).toBe('#0080ff'); // 180-240
+ expect(hsvToHex(270, 1, 1)).toBe('#8000ff'); // 240-300
+ expect(hsvToHex(330, 1, 1)).toBe('#ff0080'); // 300-360
+ });
+
+ it('wraps a hue outside 0-360', () => {
+ expect(hsvToHex(360, 1, 1)).toBe(hsvToHex(0, 1, 1));
+ expect(hsvToHex(-30, 1, 1)).toBe(hsvToHex(330, 1, 1));
+ });
+
+ it('clamps saturation and value into range', () => {
+ expect(hsvToHex(0, 2, 2)).toBe(hsvToHex(0, 1, 1));
+ expect(hsvToHex(0, -1, -1)).toBe('#000000');
+ });
+
+});
+
+describe('hexToHsv() around the circle', () => {
+ it('finds the hue whichever channel is brightest', () => {
+ expect(hexToHsv('#ff0000').h).toBeCloseTo(0); // red is max
+ expect(hexToHsv('#00ff00').h).toBeCloseTo(120); // green is max
+ expect(hexToHsv('#0000ff').h).toBeCloseTo(240); // blue is max
+ });
+
+ it('wraps a negative hue back into 0-360', () => {
+ // Red is max and blue exceeds green, which computes a negative hue first.
+ expect(hexToHsv('#ff00ff').h).toBeCloseTo(300);
+ });
+
+ it('reports no hue for greys', () => {
+ expect(hexToHsv('#808080')).toMatchObject({ h: 0, s: 0 });
+ expect(hexToHsv('#000000')).toMatchObject({ h: 0, s: 0, v: 0 });
+ });
+
+ it.each([null, undefined, ''])('treats %s as invalid rather than throwing', (input) => {
+ expect(hexToHsv(input)).toBe(null);
+ expect(isValidHexColor(input)).toBe(false);
+ expect(tint(input)).toBe(tint('#64748b'));
+ });
+});
diff --git a/vite.config.js b/vite.config.js
index 1b7e0d5..6c1d591 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -19,5 +19,12 @@ export default defineConfig({
globals: true,
setupFiles: './src/test/setup.js',
css: false,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json-summary', 'json'],
+ include: ['src/**/*.{js,jsx}'],
+ // main.jsx only mounts the app into the DOM, and setup.js is the harness itself.
+ exclude: ['src/main.jsx', 'src/test/**', 'src/**/*.test.{js,jsx}'],
+ },
},
});