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
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCollapsedDays } from '../../hooks/useCollapsedDays';
describe('useCollapsedDays', () => {
beforeEach(() => {
vi.clearAllMocks();
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
it('returns empty sets initially when no userId', () => {
const { result } = renderHook(() => useCollapsedDays(undefined));
expect(result.current.manuallyCollapsedDays.size).toBe(0);
expect(result.current.manuallyExpandedDays.size).toBe(0);
});
it('loads from localStorage when userId is provided', () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
if (key === 'collapsedDays_user_1') return JSON.stringify(['2024-01-01']);
if (key === 'expandedDays_user_1') return JSON.stringify(['2024-01-02']);
return null;
});
const { result } = renderHook(() => useCollapsedDays(1));
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true);
expect(result.current.manuallyExpandedDays.has('2024-01-02')).toBe(true);
});
it('toggles collapsed day when not auto-collapsed', () => {
const { result } = renderHook(() => useCollapsedDays(1));
act(() => {
result.current.toggleDayCollapse('2024-01-01', false);
});
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true);
act(() => {
result.current.toggleDayCollapse('2024-01-01', false);
});
expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(false);
});
it('toggles expanded day when auto-collapsed', () => {
const { result } = renderHook(() => useCollapsedDays(1));
act(() => {
result.current.toggleDayCollapse('2024-01-01', true);
});
expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(true);
act(() => {
result.current.toggleDayCollapse('2024-01-01', true);
});
expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(false);
});
it('saves to localStorage when toggling with userId', () => {
const { result } = renderHook(() => useCollapsedDays(1));
act(() => {
result.current.toggleDayCollapse('2024-01-01', false);
});
expect(window.localStorage.setItem).toHaveBeenCalledWith(
'collapsedDays_user_1',
expect.any(String)
);
});
it('does not save to localStorage without userId', () => {
const { result } = renderHook(() => useCollapsedDays(undefined));
act(() => {
result.current.toggleDayCollapse('2024-01-01', false);
});
expect(window.localStorage.setItem).not.toHaveBeenCalled();
});
});
+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);
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { defaultForm, defaultBlister } from '../../hooks/useMedicationForm';
describe('defaultBlister', () => {
it('creates a blister with default values', () => {
const blister = defaultBlister();
expect(blister.usage).toBe('1');
expect(blister.every).toBe('1');
expect(blister.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(blister.startTime).toMatch(/^\d{2}:\d{2}$/);
});
it('uses current date', () => {
const before = new Date();
const blister = defaultBlister();
const after = new Date();
// Date should be between before and after
const blisterDate = new Date(blister.startDate);
expect(blisterDate >= new Date(before.toISOString().slice(0, 10))).toBe(true);
expect(blisterDate <= new Date(after.toISOString().slice(0, 10) + 'T23:59:59')).toBe(true);
});
});
describe('defaultForm', () => {
it('creates a form with default values', () => {
const form = defaultForm();
expect(form.name).toBe('');
expect(form.genericName).toBe('');
expect(form.takenBy).toEqual([]);
expect(form.packCount).toBe('1');
expect(form.blistersPerPack).toBe('1');
expect(form.pillsPerBlister).toBe('1');
expect(form.looseTablets).toBe('0');
expect(form.pillWeightMg).toBe('');
expect(form.expiryDate).toBe('');
expect(form.notes).toBe('');
expect(form.intakeRemindersEnabled).toBe(false);
expect(form.blisters).toHaveLength(1);
});
it('creates a blister in the form', () => {
const form = defaultForm();
expect(form.blisters).toHaveLength(1);
expect(form.blisters[0].usage).toBe('1');
expect(form.blisters[0].every).toBe('1');
});
it('creates independent forms', () => {
const form1 = defaultForm();
const form2 = defaultForm();
form1.name = 'Test';
expect(form2.name).toBe('');
});
it('creates independent blisters arrays', () => {
const form1 = defaultForm();
const form2 = defaultForm();
form1.blisters.push(defaultBlister());
expect(form2.blisters).toHaveLength(1);
});
it('creates independent takenBy arrays', () => {
const form1 = defaultForm();
const form2 = defaultForm();
form1.takenBy.push('John');
expect(form2.takenBy).toHaveLength(0);
});
});
@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useMedications } from '../../hooks/useMedications';
describe('useMedications', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve([])
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('initializes with empty state', () => {
const { result } = renderHook(() => useMedications());
expect(result.current.meds).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.saving).toBe(false);
expect(result.current.uploadingImage).toBe(false);
});
it('loads medications from API', async () => {
const mockMeds = [
{ id: 1, name: 'TestMed', packCount: 1 }
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockMeds)
});
const { result } = renderHook(() => useMedications());
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(result.current.meds).toEqual(mockMeds);
});
expect(fetch).toHaveBeenCalledWith('/api/medications');
});
it('handles API error gracefully', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useMedications());
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.meds).toEqual([]);
});
it('handles non-array response', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ not: 'array' })
});
const { result } = renderHook(() => useMedications());
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.meds).toEqual([]);
});
it('deletes medication', async () => {
const mockMeds = [{ id: 1, name: 'TestMed' }];
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMeds) })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
const mockResetForm = vi.fn();
const { result } = renderHook(() => useMedications());
// First load meds
act(() => {
result.current.loadMeds();
});
await waitFor(() => {
expect(result.current.meds).toEqual(mockMeds);
});
// Then delete
await act(async () => {
await result.current.deleteMed(1, 1, mockResetForm);
});
expect(fetch).toHaveBeenCalledWith('/api/medications/1', { method: 'DELETE' });
expect(mockResetForm).toHaveBeenCalled();
});
it('does not call resetForm if editingId does not match', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
const mockResetForm = vi.fn();
const { result } = renderHook(() => useMedications());
await act(async () => {
await result.current.deleteMed(1, 2, mockResetForm);
});
expect(mockResetForm).not.toHaveBeenCalled();
});
it('uploads medication image', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
const { result } = renderHook(() => useMedications());
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
await act(async () => {
await result.current.uploadMedImage(1, file);
});
expect(fetch).toHaveBeenCalledWith(
'/api/medications/1/image',
expect.objectContaining({
method: 'POST',
body: expect.any(FormData)
})
);
});
it('handles image upload error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Upload failed'));
const { result } = renderHook(() => useMedications());
const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
await act(async () => {
await result.current.uploadMedImage(1, file);
});
expect(result.current.uploadingImage).toBe(false);
});
it('deletes medication image', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) });
const { result } = renderHook(() => useMedications());
await act(async () => {
await result.current.deleteMedImage(1);
});
expect(fetch).toHaveBeenCalledWith('/api/medications/1/image', { method: 'DELETE' });
});
it('allows setting meds directly', () => {
const { result } = renderHook(() => useMedications());
const newMeds = [{ id: 1, name: 'NewMed' }] as any;
act(() => {
result.current.setMeds(newMeds);
});
expect(result.current.meds).toEqual(newMeds);
});
it('allows setting saving state', () => {
const { result } = renderHook(() => useMedications());
act(() => {
result.current.setSaving(true);
});
expect(result.current.saving).toBe(true);
});
});
+313
View File
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRefill } from '../../hooks/useRefill';
import type { Medication, Coverage } from '../../types';
describe('useRefill', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({})
});
vi.spyOn(window.history, 'pushState').mockImplementation(() => {});
vi.spyOn(window.history, 'back').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('initializes with default state', () => {
const { result } = renderHook(() => useRefill());
expect(result.current.showRefillModal).toBe(false);
expect(result.current.refillPacks).toBe(1);
expect(result.current.refillLoose).toBe(0);
expect(result.current.refillSaving).toBe(false);
expect(result.current.refillHistory).toEqual([]);
expect(result.current.refillHistoryExpanded).toBe(false);
expect(result.current.showEditStockModal).toBe(false);
});
it('loads refill history', async () => {
const mockHistory = [
{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: '2024-03-15T10:00:00Z' }
];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistory)
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual(mockHistory);
});
it('handles refill history with refills wrapper', async () => {
const mockHistory = {
refills: [{ id: 1, packsAdded: 2, createdAt: '2024-03-15T10:00:00Z' }]
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistory)
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual(mockHistory.refills);
});
it('handles refill history error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual([]);
});
it('opens refill modal and pushes history', () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openRefillModal();
});
expect(result.current.showRefillModal).toBe(true);
expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'refill' }, '');
});
it('closes refill modal using history back', () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openRefillModal();
});
act(() => {
result.current.closeRefillModal();
});
expect(window.history.back).toHaveBeenCalled();
});
it('does not call history back when refill modal not open', () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.closeRefillModal();
});
expect(window.history.back).not.toHaveBeenCalled();
});
it('submits refill successfully', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ newStock: { packCount: 3, looseTablets: 5 } })
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([])
});
const mockSetForm = vi.fn();
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// Open modal first
act(() => {
result.current.openRefillModal();
});
await act(async () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
'/api/medications/1/refill',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 })
})
);
expect(mockSetForm).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
it('does not submit refill if both values are 0', async () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(0);
result.current.setRefillLoose(0);
});
const mockSetForm = vi.fn();
const mockLoadMeds = vi.fn();
await act(async () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).not.toHaveBeenCalled();
});
it('opens edit stock modal', () => {
const { result } = renderHook(() => useRefill());
const mockMed: Medication = {
id: 1,
name: 'Test Med',
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null
};
const mockCoverage = {
all: [{ name: 'Test Med', medsLeft: 20, daysLeft: 10 }] as Coverage[]
};
act(() => {
result.current.openEditStockModal(mockMed, mockCoverage);
});
expect(result.current.showEditStockModal).toBe(true);
expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'editStock' }, '');
expect(result.current.editStockFullBlisters).toBe(2); // 20 / 10 = 2
expect(result.current.editStockPartialBlisterPills).toBe(0); // 20 % 10 = 0
});
it('closes edit stock modal using history back', () => {
const { result } = renderHook(() => useRefill());
const mockMed: Medication = {
id: 1,
name: 'Test Med',
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null
};
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
});
act(() => {
result.current.closeEditStockModal();
});
expect(window.history.back).toHaveBeenCalled();
});
it('submits stock correction', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockMed: Medication = {
id: 1,
name: 'Test Med',
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
});
await act(async () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
'/api/medications/1/stock-adjustment',
expect.objectContaining({ method: 'PATCH' })
);
expect(mockLoadMeds).toHaveBeenCalled();
});
it('handles full blister conversion in stock correction', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockMed: Medication = {
id: 1,
name: 'Test Med',
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
// Set partial pills to equal a full blister
result.current.setEditStockPartialBlisterPills(10);
});
await act(async () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
it('allows setting state directly', () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(5);
result.current.setRefillLoose(3);
result.current.setRefillHistoryExpanded(true);
result.current.setShowRefillModal(true);
result.current.setShowEditStockModal(true);
result.current.setEditStockFullBlisters(10);
result.current.setEditStockPartialBlisterPills(5);
});
expect(result.current.refillPacks).toBe(5);
expect(result.current.refillLoose).toBe(3);
expect(result.current.refillHistoryExpanded).toBe(true);
expect(result.current.showRefillModal).toBe(true);
expect(result.current.showEditStockModal).toBe(true);
expect(result.current.editStockFullBlisters).toBe(10);
expect(result.current.editStockPartialBlisterPills).toBe(5);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useSettings } from '../../hooks/useSettings';
import React from 'react';
describe('useSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({})
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('initializes with default settings', () => {
const { result } = renderHook(() => useSettings());
expect(result.current.settings.emailEnabled).toBe(false);
expect(result.current.settings.lowStockDays).toBe(30);
expect(result.current.settings.reminderDaysBefore).toBe(7);
expect(result.current.settingsLoading).toBe(true);
});
it('loads settings from API on mount', async () => {
const mockSettings = {
emailEnabled: true,
notificationEmail: 'test@example.com',
lowStockDays: 14
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSettings)
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.settings.emailEnabled).toBe(true);
expect(result.current.settings.notificationEmail).toBe('test@example.com');
});
it('handles API error on load', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
});
it('saves settings to API', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
await act(async () => {
await result.current.saveSettings(mockEvent);
});
expect(fetch).toHaveBeenCalledWith(
'/api/settings',
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' }
})
);
expect(result.current.settingsSaved).toBe(true);
});
it('validates email before saving', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({})
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
// Set invalid email
act(() => {
result.current.setSettings(s => ({
...s,
emailEnabled: true,
notificationEmail: 'invalid-email'
}));
});
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
await act(async () => {
await result.current.saveSettings(mockEvent);
});
expect(result.current.testEmailResult?.success).toBe(false);
expect(result.current.testEmailResult?.message).toContain('Invalid email');
});
it('tests email notification', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'Email sent!' })
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testEmail();
});
expect(result.current.testEmailResult?.success).toBe(true);
expect(result.current.testingEmail).toBe(false);
});
it('handles test email failure', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testEmail();
});
expect(result.current.testEmailResult?.success).toBe(false);
});
it('tests shoutrrr notification', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'Notification sent!' })
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testShoutrrr();
});
expect(result.current.testShoutrrrResult?.success).toBe(true);
expect(result.current.testingShoutrrr).toBe(false);
});
it('tracks unsaved changes', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ lowStockDays: 30 })
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.hasUnsavedChanges).toBe(false);
act(() => {
result.current.setSettings(s => ({ ...s, lowStockDays: 14 }));
});
expect(result.current.hasUnsavedChanges).toBe(true);
});
it('loadSettings can be called manually', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ lowStockDays: 14 })
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
act(() => {
result.current.loadSettings();
});
await waitFor(() => {
expect(result.current.settings.lowStockDays).toBe(14);
});
});
it('auto-disables email when no recipient', async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
act(() => {
result.current.setSettings(s => ({
...s,
emailEnabled: true,
notificationEmail: ''
}));
});
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
await act(async () => {
await result.current.saveSettings(mockEvent);
});
// emailEnabled should be false in the saved state
expect(result.current.settings.emailEnabled).toBe(false);
});
});
+298
View File
@@ -0,0 +1,298 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useShare } from '../../hooks/useShare';
import type { Medication } from '../../types';
describe('useShare', () => {
let mockAlert: ReturnType<typeof vi.fn>;
let mockClipboard: { writeText: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockAlert = vi.fn();
global.alert = mockAlert;
mockClipboard = { writeText: vi.fn() };
Object.defineProperty(navigator, 'clipboard', {
value: mockClipboard,
writable: true
});
// Mock window.history
vi.spyOn(window.history, 'pushState').mockImplementation(() => {});
vi.spyOn(window.history, 'back').mockImplementation(() => {});
// Mock window.location.origin
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:5173' },
writable: true
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'test-token' })
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('initializes with default state', () => {
const { result } = renderHook(() => useShare());
expect(result.current.showShareDialog).toBe(false);
expect(result.current.sharePeople).toEqual([]);
expect(result.current.shareSelectedPerson).toBe('');
expect(result.current.shareSelectedDays).toBe(30);
expect(result.current.shareLink).toBeNull();
});
it('opens share dialog with people from medications', () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice', 'Bob'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
},
{
id: 2, name: 'Med2', takenBy: ['Bob', 'Charlie'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
expect(result.current.showShareDialog).toBe(true);
expect(result.current.sharePeople).toEqual(['Alice', 'Bob', 'Charlie']);
expect(result.current.shareSelectedPerson).toBe('Alice');
expect(window.history.pushState).toHaveBeenCalled();
});
it('resets state when opening dialog', () => {
const { result } = renderHook(() => useShare());
// Set some state first
act(() => {
result.current.setShareLink('old-link');
result.current.setShareCopied(true);
});
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
expect(result.current.shareLink).toBeNull();
expect(result.current.shareCopied).toBe(false);
});
it('generates share link', async () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await result.current.generateShareLink();
});
expect(fetch).toHaveBeenCalledWith(
'/api/share',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ takenBy: 'Alice', scheduleDays: 30 })
})
);
expect(result.current.shareLink).toBe('http://localhost:5173/share/test-token');
});
it('handles share link generation error', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: 'Failed to generate' })
});
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
expect(result.current.shareLink).toBeNull();
});
it('handles network error on share link generation', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
await act(async () => {
await result.current.generateShareLink();
});
expect(mockAlert).toHaveBeenCalled();
});
it('does nothing when generateShareLink called without selected person', async () => {
const { result } = renderHook(() => useShare());
// Don't open dialog, so shareSelectedPerson is empty
await act(async () => {
await result.current.generateShareLink();
});
expect(fetch).not.toHaveBeenCalled();
});
it('copies share link to clipboard', async () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.setShareLink('http://localhost:5173/share/test-token');
});
act(() => {
result.current.copyShareLink();
});
expect(mockClipboard.writeText).toHaveBeenCalledWith('http://localhost:5173/share/test-token');
expect(result.current.shareCopied).toBe(true);
// Should reset after 2 seconds
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.shareCopied).toBe(false);
});
it('does nothing when copyShareLink called without link', () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.copyShareLink();
});
expect(mockClipboard.writeText).not.toHaveBeenCalled();
});
it('closes share dialog with history back', () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
});
act(() => {
result.current.closeShareDialog();
});
expect(window.history.back).toHaveBeenCalled();
});
it('does not call history back when dialog not open', () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.closeShareDialog();
});
expect(window.history.back).not.toHaveBeenCalled();
});
it('resetShareDialogState clears state', () => {
const { result } = renderHook(() => useShare());
const meds: Medication[] = [
{
id: 1, name: 'Med1', takenBy: ['Alice'],
packCount: 1, blistersPerPack: 1, pillsPerBlister: 10,
looseTablets: 0, blisters: [], updatedAt: null
}
];
act(() => {
result.current.openShareDialog(meds);
result.current.setShareLink('some-link');
result.current.setShareCopied(true);
});
act(() => {
result.current.resetShareDialogState();
});
expect(result.current.showShareDialog).toBe(false);
expect(result.current.shareLink).toBeNull();
expect(result.current.shareCopied).toBe(false);
});
it('allows changing selected person and days', () => {
const { result } = renderHook(() => useShare());
act(() => {
result.current.setShareSelectedPerson('Bob');
result.current.setShareSelectedDays(90);
});
expect(result.current.shareSelectedPerson).toBe('Bob');
expect(result.current.shareSelectedDays).toBe(90);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTheme } from '../../hooks/useTheme';
describe('useTheme', () => {
beforeEach(() => {
vi.clearAllMocks();
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
// Reset mock to default behavior
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
it('returns dark as default theme', () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');
});
it('reads theme from localStorage', () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue('light');
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('light');
});
it('toggles theme from dark to light', () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('dark');
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('light');
});
it('toggles theme from light to dark', () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue('light');
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe('light');
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('dark');
});
it('saves theme to localStorage on change', () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.toggleTheme();
});
expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light');
});
it('sets data-theme attribute on document', () => {
const { result } = renderHook(() => useTheme());
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
act(() => {
result.current.toggleTheme();
});
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
});