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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user