Feat/frontend tests (#62)

* test(frontend): add vitest test infrastructure

- Add vitest, testing-library, jsdom dependencies
- Configure vitest with jsdom environment
- Add test setup with mocks for fetch, localStorage, matchMedia
- Set 75% coverage threshold

* Add frontend tests (#61)

* Initial plan

* Add frontend tests - utilities, hooks, and components (21% coverage)

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add more component tests (Auth, AboutModal, ExportModal) and useRefill hook tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add useMedicationForm utility function tests - 30% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add AppHeader tests and more schedule.ts tests - 32% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix page tests and add more tests - 326 tests passing, 34% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add tests for ProfileModal, UserFilterModal, MedDetailModal - 361 tests, 36% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add SharedSchedule tests - 366 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand page tests - 383 tests, 39% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Add MobileEditModal tests - 409 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Expand Dashboard and Schedule page tests - 427 tests, 40% coverage

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

* Fix code review issues - remove invalid remindEnabled property

Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
This commit is contained in:
Daniel Volz
2026-01-22 10:25:11 +01:00
committed by GitHub
parent 8718311876
commit fd055a3a2a
36 changed files with 7602 additions and 3 deletions
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDoses } from '../../hooks/useDoses';
describe('useDoses', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ doses: [] })
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('initializes with empty state', () => {
const { result } = renderHook(() => useDoses());
expect(result.current.takenDoses.size).toBe(0);
expect(result.current.dismissedDoses.size).toBe(0);
expect(result.current.clearingMissed).toBe(false);
expect(result.current.showClearMissedConfirm).toBe(false);
});
it('loads taken doses from API on mount', async () => {
const mockDoses = {
doses: [
{ doseId: 'dose-1', dismissed: false },
{ doseId: 'dose-2', dismissed: true }
]
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDoses)
});
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.has('dose-1')).toBe(true);
expect(result.current.dismissedDoses.has('dose-2')).toBe(true);
});
});
it('getDoseId returns correct ID format', () => {
const { result } = renderHook(() => useDoses());
expect(result.current.getDoseId('dose-1', null)).toBe('dose-1');
expect(result.current.getDoseId('dose-1', 'John')).toBe('dose-1-John');
});
it('countTakenDoses calculates correctly', async () => {
const mockDoses = {
doses: [{ doseId: 'dose-1', dismissed: false }]
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDoses)
});
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.has('dose-1')).toBe(true);
});
const doses = [
{ id: 'dose-1', takenBy: [] },
{ id: 'dose-2', takenBy: [] }
];
const count = result.current.countTakenDoses(doses);
expect(count.total).toBe(2);
expect(count.taken).toBe(1);
});
it('countTakenDoses handles multiple people', async () => {
const mockDoses = {
doses: [
{ doseId: 'dose-1-Alice', dismissed: false },
{ doseId: 'dose-1-Bob', dismissed: false }
]
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDoses)
});
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(2);
});
const doses = [{ id: 'dose-1', takenBy: ['Alice', 'Bob', 'Charlie'] }];
const count = result.current.countTakenDoses(doses);
expect(count.total).toBe(3);
expect(count.taken).toBe(2);
});
it('marks dose as taken optimistically', async () => {
// First call for initial load, subsequent calls for marking dose
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useDoses());
// Wait for initial load to complete
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
});
await act(async () => {
await result.current.markDoseTaken('new-dose');
});
expect(result.current.takenDoses.has('new-dose')).toBe(true);
expect(fetch).toHaveBeenCalledWith(
'/api/doses/taken',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ doseId: 'new-dose' })
})
);
});
it('reverts optimistic update on error', async () => {
// First call for initial load, second for marking dose fails
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
});
await act(async () => {
await result.current.markDoseTaken('new-dose');
});
// After error, the dose should be removed
await waitFor(() => {
expect(result.current.takenDoses.has('new-dose')).toBe(false);
});
});
it('undoes dose taken optimistically', async () => {
const mockDoses = {
doses: [{ doseId: 'taken-dose', dismissed: false }]
};
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.has('taken-dose')).toBe(true);
});
await act(async () => {
await result.current.undoDoseTaken('taken-dose');
});
expect(result.current.takenDoses.has('taken-dose')).toBe(false);
expect(fetch).toHaveBeenCalledWith(
'/api/doses/taken/taken-dose',
expect.objectContaining({ method: 'DELETE' })
);
});
it('dismisses missed doses', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.clearingMissed).toBe(false);
});
await act(async () => {
await result.current.dismissMissedDoses(['missed-1', 'missed-2']);
});
expect(result.current.dismissedDoses.has('missed-1')).toBe(true);
expect(result.current.dismissedDoses.has('missed-2')).toBe(true);
});
it('does nothing when dismissing empty array', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [] })
});
const { result } = renderHook(() => useDoses());
await act(async () => {
await result.current.dismissMissedDoses([]);
});
// Should not make a POST call for dismiss
expect(fetch).not.toHaveBeenCalledWith(
'/api/doses/dismiss',
expect.anything()
);
});
it('setShowClearMissedConfirm works', () => {
const { result } = renderHook(() => useDoses());
act(() => {
result.current.setShowClearMissedConfirm(true);
});
expect(result.current.showClearMissedConfirm).toBe(true);
});
it('handles API error on dismiss gracefully', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.clearingMissed).toBe(false);
});
await act(async () => {
await result.current.dismissMissedDoses(['missed-1']);
});
expect(result.current.clearingMissed).toBe(false);
});
});