diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 3a10f5e..2ce66f6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -8,24 +8,29 @@ import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatte import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule"; // Helper for user-specific localStorage keys -function userStorageKey(userId: number | undefined, key: string): string { +export function userStorageKey(userId: number | undefined, key: string): string { return userId ? `user_${userId}_${key}` : key; } // Helper function to calculate blister stock -function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) { +export function getBlisterStock( + totalPills: number, + pillsPerBlister: number, + _looseTablets: number, + _originalTotal: number +) { const fullBlisters = Math.floor(totalPills / pillsPerBlister); const openBlisterPills = totalPills % pillsPerBlister; return { fullBlisters, openBlisterPills, loosePills: openBlisterPills }; } // Helper to format full blisters -function formatFullBlisters(count: number, t: (key: string) => string): string { +export function formatFullBlisters(count: number, t: (key: string) => string): string { return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`; } // Helper to format open blister and loose pills -function formatOpenBlisterAndLoose( +export function formatOpenBlisterAndLoose( openBlisterPills: number, loosePills: number, pillsPerBlister: number, @@ -36,7 +41,7 @@ function formatOpenBlisterAndLoose( } // Get total pills for a medication (packageType-aware) -function getMedTotal(med: { +export function getMedTotal(med: { packCount: number; blistersPerPack: number; pillsPerBlister: number; @@ -71,7 +76,7 @@ function NotificationBellIcon() { } // Get structured reminder status data -function getReminderStatusData( +export function getReminderStatusData( reminderDaysBefore: number, lowStockDays: number, lowCoverage: Coverage[], diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx new file mode 100644 index 0000000..463a4e7 --- /dev/null +++ b/frontend/src/test/App.test.tsx @@ -0,0 +1,466 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import App from "../App"; + +type AuthStateMock = { + user: { id: number; username: string } | null; + authState: { authEnabled: boolean; needsSetup: boolean } | null; + loading: boolean; + authError: string | null; +}; + +let authMock: AuthStateMock = { + user: null, + authState: { authEnabled: false, needsSetup: false }, + loading: false, + authError: null, +}; + +let appContextMock: Record; + +vi.mock("../components", () => ({ + AboutModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
about-modal-open
: null), + Lightbox: ({ src }: { src: string }) =>
lightbox-open-{src}
, + MedDetailModal: () => null, + ProfileModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ?
profile-modal-open
: null), + ShareDialog: () => null, + SharedSchedule: () =>
shared-schedule-page
, + UserFilterModal: () => null, +})); + +vi.mock("../components/AppHeader", () => ({ + AppHeader: ({ onOpenProfile, onOpenAbout }: { onOpenProfile: () => void; onOpenAbout: () => void }) => ( +
+ app-header + + +
+ ), +})); + +vi.mock("../components/Auth", () => ({ + AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + AuthPage: () =>
auth-page
, + useAuth: () => authMock, +})); + +vi.mock("../context", () => ({ + AppProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + UnsavedChangesProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useAppContext: () => appContextMock, +})); + +vi.mock("../pages", () => ({ + DashboardPage: () =>
dashboard-page
, + MedicationsPage: () =>
medications-page
, + PlannerPage: () =>
planner-page
, + SchedulePage: () =>
schedule-page
, + SettingsPage: () =>
settings-page
, +})); + +describe("App", () => { + beforeEach(() => { + authMock = { + user: null, + authState: { authEnabled: false, needsSetup: false }, + loading: false, + authError: null, + }; + appContextMock = { + meds: [], + loadMeds: vi.fn(), + settings: {}, + showRefillModal: false, + setShowRefillModal: vi.fn(), + refillPacks: 0, + setRefillPacks: vi.fn(), + refillLoose: 0, + setRefillLoose: vi.fn(), + refillSaving: false, + refillHistory: [], + refillHistoryExpanded: false, + setRefillHistoryExpanded: vi.fn(), + showEditStockModal: false, + setShowEditStockModal: vi.fn(), + editStockFullBlisters: 0, + setEditStockFullBlisters: vi.fn(), + editStockPartialBlisterPills: 0, + setEditStockPartialBlisterPills: vi.fn(), + editStockSaving: false, + openRefillModal: vi.fn(), + closeRefillModal: vi.fn(), + openEditStockModal: vi.fn(), + closeEditStockModal: vi.fn(), + showShareDialog: false, + sharePeople: [], + shareSelectedPerson: "", + setShareSelectedPerson: vi.fn(), + shareSelectedDays: 7, + setShareSelectedDays: vi.fn(), + shareGenerating: false, + shareLink: null, + setShareLink: vi.fn(), + shareCopied: false, + setShareCopied: vi.fn(), + generateShareLink: vi.fn(), + copyShareLink: vi.fn(), + closeShareDialog: vi.fn(), + resetShareDialogState: vi.fn(), + coverage: { all: [], low: [] }, + selectedMed: null, + setSelectedMed: vi.fn(), + showImageLightbox: false, + setShowImageLightbox: vi.fn(), + scheduleLightboxImage: null, + setScheduleLightboxImage: vi.fn(), + selectedUser: null, + setSelectedUser: vi.fn(), + openMedDetail: vi.fn(), + closeMedDetail: vi.fn(), + openImageLightbox: vi.fn(), + closeImageLightbox: vi.fn(), + closeScheduleLightbox: vi.fn(), + closeUserFilter: vi.fn(), + openShareDialog: vi.fn(), + submitStockCorrection: vi.fn(), + submitRefill: vi.fn(), + stockThresholds: { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + criticalStockDays: 7, + expiryWarningDays: 30, + }, + }; + document.documentElement.classList.remove("modal-open"); + document.body.classList.remove("modal-open"); + vi.spyOn(window.history, "back").mockImplementation(() => {}); + vi.spyOn(window.history, "pushState").mockImplementation(() => {}); + vi.clearAllMocks(); + }); + + it("renders public shared schedule route without auth", () => { + render( + + + + ); + + expect(screen.getByText("shared-schedule-page")).toBeInTheDocument(); + }); + + it("renders loading state while auth is being checked", () => { + authMock = { + user: null, + authState: null, + loading: true, + authError: null, + }; + + render( + + + + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("renders connection error state", () => { + authMock = { + user: null, + authState: { authEnabled: false, needsSetup: false }, + loading: false, + authError: "Backend is unreachable", + }; + + render( + + + + ); + + expect(screen.getByText("Connection Error")).toBeInTheDocument(); + expect(screen.getByText("Backend is unreachable")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("reloads page when retry button is clicked", () => { + authMock = { + user: null, + authState: { authEnabled: false, needsSetup: false }, + loading: false, + authError: "Backend is unreachable", + }; + + const reloadSpy = vi.fn(); + Object.defineProperty(window, "location", { + value: { ...window.location, reload: reloadSpy }, + writable: true, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("renders auth page when setup is required", () => { + authMock = { + user: null, + authState: { authEnabled: true, needsSetup: true }, + loading: false, + authError: null, + }; + + render( + + + + ); + + expect(screen.getByText("auth-page")).toBeInTheDocument(); + }); + + it("renders auth page when auth is enabled and no user is logged in", () => { + authMock = { + user: null, + authState: { authEnabled: true, needsSetup: false }, + loading: false, + authError: null, + }; + + render( + + + + ); + + expect(screen.getByText("auth-page")).toBeInTheDocument(); + }); + + it("renders app shell when auth is disabled", () => { + render( + + + + ); + + expect(screen.getByText("app-header")).toBeInTheDocument(); + expect(screen.getByText("dashboard-page")).toBeInTheDocument(); + }); + + it("renders initializing state when auth state is missing", () => { + authMock = { + user: null, + authState: null, + loading: false, + authError: null, + }; + + render( + + + + ); + + expect(screen.getByText("Initializing...")).toBeInTheDocument(); + }); + + it("renders schedule lightbox when schedule image is set", () => { + appContextMock.scheduleLightboxImage = "med-image.png"; + + render( + + + + ); + + expect(screen.getByText("lightbox-open-med-image.png")).toBeInTheDocument(); + }); + + it("handles Escape key with modal priority", () => { + appContextMock.scheduleLightboxImage = "med-image.png"; + appContextMock.showImageLightbox = true; + appContextMock.showShareDialog = true; + + render( + + + + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + + expect(appContextMock.closeScheduleLightbox).toHaveBeenCalled(); + expect(appContextMock.closeImageLightbox).not.toHaveBeenCalled(); + expect(appContextMock.closeShareDialog).not.toHaveBeenCalled(); + }); + + it("handles popstate by closing selected medication", () => { + appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null }; + + render( + + + + ); + + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(appContextMock.setSelectedMed).toHaveBeenCalledWith(null); + }); + + it("adds modal-open class when modal state is active", () => { + appContextMock.showShareDialog = true; + + render( + + + + ); + + expect(document.documentElement.classList.contains("modal-open")).toBe(true); + expect(document.body.classList.contains("modal-open")).toBe(true); + }); + + it("opens profile and about modals from header actions", () => { + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "open-profile" })); + expect(screen.getByText("profile-modal-open")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "open-about" })); + expect(screen.getByText("about-modal-open")).toBeInTheDocument(); + expect(window.history.pushState).toHaveBeenCalled(); + }); + + it("Escape key closes about modal via history back", () => { + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "open-about" })); + expect(screen.getByText("about-modal-open")).toBeInTheDocument(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(window.history.back).toHaveBeenCalled(); + }); + + it("handles popstate by resetting share dialog state", () => { + appContextMock.showShareDialog = true; + + render( + + + + ); + + window.dispatchEvent(new PopStateEvent("popstate")); + expect(appContextMock.resetShareDialogState).toHaveBeenCalled(); + }); + + it("redirects unknown routes to dashboard", () => { + render( + + + + ); + + expect(screen.getByText("dashboard-page")).toBeInTheDocument(); + }); + + it("Escape closes refill modal when it is topmost", () => { + appContextMock.showRefillModal = true; + + render( + + + + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(appContextMock.closeRefillModal).toHaveBeenCalled(); + }); + + it("Escape closes edit stock modal when it is topmost", () => { + appContextMock.showEditStockModal = true; + + render( + + + + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(appContextMock.closeEditStockModal).toHaveBeenCalled(); + }); + + it("Escape closes user filter and medication detail in lower priority", () => { + appContextMock.selectedUser = "Max"; + appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null }; + + render( + + + + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(appContextMock.closeUserFilter).toHaveBeenCalled(); + expect(appContextMock.closeMedDetail).not.toHaveBeenCalled(); + }); + + it("popstate closes image lightbox before other modals", () => { + appContextMock.showImageLightbox = true; + appContextMock.scheduleLightboxImage = "img.png"; + + render( + + + + ); + + window.dispatchEvent(new PopStateEvent("popstate")); + expect(appContextMock.setShowImageLightbox).toHaveBeenCalledWith(false); + expect(appContextMock.setScheduleLightboxImage).not.toHaveBeenCalledWith(null); + }); + + it("popstate closes schedule lightbox when image lightbox is not open", () => { + appContextMock.showImageLightbox = false; + appContextMock.scheduleLightboxImage = "img.png"; + + render( + + + + ); + + window.dispatchEvent(new PopStateEvent("popstate")); + expect(appContextMock.setScheduleLightboxImage).toHaveBeenCalledWith(null); + }); + + it("Escape closes medication detail when no higher-priority modal is open", () => { + appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null }; + + render( + + + + ); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(appContextMock.closeMedDetail).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/components/AboutModal.test.tsx b/frontend/src/test/components/AboutModal.test.tsx index e61e706..99976ca 100644 --- a/frontend/src/test/components/AboutModal.test.tsx +++ b/frontend/src/test/components/AboutModal.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AboutModal from "../../components/AboutModal"; @@ -67,4 +67,64 @@ describe("AboutModal", () => { expect(versionLink).toHaveAttribute("href", "https://github.com/test/repo/releases/tag/v1.0.0"); expect(versionLink).toHaveAttribute("target", "_blank"); }); + + it("shows up-to-date result after successful version check", async () => { + vi.useFakeTimers(); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "v1.0.0" }), + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /about\.checkForUpdates/i })); + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByText(/about\.upToDate/i)).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("shows update available result with download link", async () => { + vi.useFakeTimers(); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: "v1.2.0" }), + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /about\.checkForUpdates/i })); + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByText(/about\.updateAvailable/i)).toBeInTheDocument(); + + const downloadLink = screen.getByRole("link", { name: /about\.downloadUpdate/i }); + expect(downloadLink).toHaveAttribute("href", "https://github.com/test/repo/releases/latest"); + + vi.useRealTimers(); + }); + + it("shows error result when update check fails", async () => { + vi.useFakeTimers(); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({}), + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /about\.checkForUpdates/i })); + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByText(/about\.checkFailed/i)).toBeInTheDocument(); + + vi.useRealTimers(); + }); }); diff --git a/frontend/src/test/components/AppHeader.test.tsx b/frontend/src/test/components/AppHeader.test.tsx index dd39efa..52be934 100644 --- a/frontend/src/test/components/AppHeader.test.tsx +++ b/frontend/src/test/components/AppHeader.test.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from "../../components/Auth"; // Mock useNavigate const mockNavigate = vi.fn(); +const mockConfirmNavigation = vi.fn(); vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { @@ -19,7 +20,7 @@ vi.mock("../../context", () => ({ useUnsavedChanges: () => ({ setHasUnsavedChanges: vi.fn(), hasUnsavedChanges: false, - confirmNavigation: vi.fn().mockReturnValue(true), + confirmNavigation: mockConfirmNavigation, }), })); @@ -27,6 +28,7 @@ describe("AppHeader", () => { beforeEach(() => { vi.clearAllMocks(); mockNavigate.mockClear(); + mockConfirmNavigation.mockResolvedValue(true); // Set up default auth mock - auth disabled (global.fetch as ReturnType) .mockResolvedValueOnce({ @@ -281,4 +283,97 @@ describe("AppHeader", () => { } }); }); + + it("does not navigate when unsaved changes confirmation is denied", async () => { + mockConfirmNavigation.mockResolvedValueOnce(false); + + render( + + + + + + ); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + const plannerBtn = buttons.find((btn) => btn.textContent?.includes("nav.planner")); + expect(plannerBtn).toBeInTheDocument(); + if (plannerBtn) fireEvent.click(plannerBtn); + }); + + await waitFor(() => { + expect(mockConfirmNavigation).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith("/planner"); + }); + }); + + it("renders authenticated user menu and handles profile/about/settings/logout actions", async () => { + const onOpenProfile = vi.fn(); + const onOpenAbout = vi.fn(); + + (global.fetch as ReturnType).mockReset(); + mockNavigate.mockClear(); + mockConfirmNavigation.mockResolvedValue(true); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + authEnabled: true, + registrationEnabled: true, + localAuthEnabled: true, + oidcEnabled: false, + oidcProviderName: "", + hasUsers: true, + needsSetup: false, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 1, username: "tester", avatarUrl: null }), + }) + .mockResolvedValueOnce({ + ok: true, + }); + + const { container } = render( + + + + + + ); + + await waitFor(() => { + expect(container.querySelector(".user-menu-btn")).toBeInTheDocument(); + }); + + // Settings icon should not be shown when auth is enabled + expect(screen.queryByTitle(/nav\.settings/i)).not.toBeInTheDocument(); + + const userMenuBtn = container.querySelector(".user-menu-btn") as HTMLButtonElement; + fireEvent.click(userMenuBtn); + fireEvent.click(screen.getByText(/auth\.profile/i)); + expect(onOpenProfile).toHaveBeenCalled(); + + fireEvent.click(userMenuBtn); + fireEvent.click(screen.getByText(/about\.title/i)); + expect(onOpenAbout).toHaveBeenCalled(); + + fireEvent.click(userMenuBtn); + fireEvent.click(screen.getByText(/^nav\.settings$/i)); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/settings"); + }); + + fireEvent.click(userMenuBtn); + fireEvent.click(screen.getByText(/auth\.signOut/i)); + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + }); + }); }); diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx index 39c6e04..556ecbe 100644 --- a/frontend/src/test/components/Auth.test.tsx +++ b/frontend/src/test/components/Auth.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthPage, AuthProvider, LoginForm, RegisterForm, UserProfile, useAuth } from "../../components/Auth"; @@ -8,7 +8,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => { describe("AuthProvider", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); (global.fetch as ReturnType).mockResolvedValue({ ok: true, json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), @@ -17,6 +17,7 @@ describe("AuthProvider", () => { afterEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); }); it("provides auth context to children", () => { @@ -72,6 +73,92 @@ describe("AuthProvider", () => { renderHook(() => useAuth()); }).toThrow("useAuth must be used within AuthProvider"); }); + + it("authFetch retries original request after token refresh", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }), + }) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true, status: 200 }) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ data: true }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const response = await result.current.authFetch("/api/medications", { method: "GET" }); + + expect(response.ok).toBe(true); + expect(fetch).toHaveBeenNthCalledWith(2, "/api/medications", { + method: "GET", + credentials: "include", + }); + expect(fetch).toHaveBeenNthCalledWith(3, "/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + expect(fetch).toHaveBeenNthCalledWith(4, "/api/medications", { + method: "GET", + credentials: "include", + }); + }); + + it("authFetch logs user out when refresh fails", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + }) + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1, username: "tester" }) }) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: false, status: 401 }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user?.username).toBe("tester"); + }); + + await result.current.authFetch("/api/medications"); + + await waitFor(() => { + expect(result.current.user).toBeNull(); + }); + }); + + it("runs periodic token refresh when authenticated", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }), + }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "timer-user" }) }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + renderHook(() => useAuth(), { wrapper }); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/auth/refresh", + expect.objectContaining({ method: "POST", credentials: "include" }) + ); + vi.useRealTimers(); + }); }); describe("LoginForm", () => { @@ -181,6 +268,47 @@ describe("LoginForm", () => { expect(submitBtn).toBeInTheDocument(); }); }); + + it("submits login form and calls onSuccess", async () => { + vi.clearAllMocks(); + const onSuccess = vi.fn(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + authEnabled: false, + localAuthEnabled: true, + oidcEnabled: false, + registrationEnabled: true, + hasUsers: true, + needsSetup: false, + oidcProviderName: "", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ user: { id: 1, username: "testuser" } }), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "testuser" } }); + fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } }); + fireEvent.click(screen.getByRole("button", { name: /auth\.login/i })); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); }); describe("RegisterForm", () => { @@ -265,6 +393,44 @@ describe("RegisterForm", () => { expect(onSwitchToLogin).toHaveBeenCalled(); }); + + it("shows password mismatch error and does not submit", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + authEnabled: true, + localAuthEnabled: true, + oidcEnabled: false, + registrationEnabled: true, + hasUsers: false, + needsSetup: true, + oidcProviderName: "", + }), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "new-user" } }); + fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } }); + fireEvent.change(screen.getByLabelText(/auth\.confirmPassword/i), { target: { value: "different123" } }); + fireEvent.click(screen.getByRole("button", { name: /auth\.register/i })); + + await waitFor(() => { + expect(screen.getByText(/auth\.passwordMismatch/i)).toBeInTheDocument(); + }); + + expect(fetch).not.toHaveBeenCalledWith("/api/auth/register", expect.objectContaining({ method: "POST" })); + }); }); describe("AuthPage", () => { @@ -303,6 +469,24 @@ describe("AuthPage", () => { expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); }); }); + + it("switches to register mode when create account is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /auth\.createAccount/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /auth\.createAccount/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /^auth\.register$/i })).toBeInTheDocument(); + }); + }); }); describe("UserProfile", () => { @@ -378,4 +562,382 @@ describe("UserProfile", () => { expect(onClose).toHaveBeenCalled(); }); + + it("shows password mismatch error on update", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("testuser")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/auth\.newPassword/i), { target: { value: "new-password-123" } }); + fireEvent.change(screen.getByLabelText(/auth\.confirmPassword/i), { target: { value: "different-password" } }); + + const submitButton = screen.getByRole("button", { name: /auth\.updatePassword/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/auth\.passwordMismatch/i)).toBeInTheDocument(); + }); + }); + + it("opens delete confirmation and executes account deletion", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("testuser")).toBeInTheDocument(); + }); + + const dangerButtons = screen.getAllByRole("button", { name: /auth\.deleteAccount/i }); + fireEvent.click(dangerButtons[0]); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + "/api/auth/me", + expect.objectContaining({ method: "DELETE", credentials: "include" }) + ); + }); + }); + + it("closes profile on Escape key when onClose is provided", async () => { + const onClose = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("testuser")).toBeInTheDocument(); + }); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows delete error when account deletion fails", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Delete failed badly" }), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("testuser")).toBeInTheDocument(); + }); + + const dangerButtons = screen.getAllByRole("button", { name: /auth\.deleteAccount/i }); + fireEvent.click(dangerButtons[0]); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })); + + await waitFor(() => { + expect(screen.getAllByText("Delete failed badly").length).toBeGreaterThan(0); + }); + }); +}); + +describe("AuthProvider methods", () => { + it("register performs auto-login and refreshes auth state", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 2, username: "newuser" } }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.register("newuser", "secure-password-123"); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/auth/register", + expect.objectContaining({ method: "POST", credentials: "include" }) + ); + expect(fetch).toHaveBeenCalledWith( + "/api/auth/login", + expect.objectContaining({ method: "POST", credentials: "include" }) + ); + }); + + it("logout clears current user", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 3, username: "logout-user" } }) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.login("logout-user", "pw"); + }); + + expect(result.current.user?.username).toBe("logout-user"); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + }); + + it("refreshUser retries after token refresh on 401", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }) }) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "refreshed-user" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.refreshUser(); + }); + + await waitFor(() => { + expect(result.current.user?.username).toBe("refreshed-user"); + }); + }); + + it("login throws backend error message on failed login", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Invalid credentials" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await expect(result.current.login("user", "bad-password")).rejects.toThrow("Invalid credentials"); + }); + + it("updateProfile sends PUT and refreshes user data", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "updated-user" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.updateProfile({ currentPassword: "old", newPassword: "new-password-123" }); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/auth/me", + expect.objectContaining({ method: "PUT", credentials: "include" }) + ); + expect(result.current.user?.username).toBe("updated-user"); + }); + + it("uploadAvatar posts FormData and refreshes user", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "avatar-user" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const file = new File(["avatar"], "avatar.png", { type: "image/png" }); + await act(async () => { + await result.current.uploadAvatar(file); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/auth/avatar", + expect.objectContaining({ method: "POST", credentials: "include" }) + ); + expect(result.current.user?.username).toBe("avatar-user"); + }); + + it("deleteAvatar throws backend error on failure", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Delete failed" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await expect(result.current.deleteAvatar()).rejects.toThrow("Delete failed"); + }); + + it("authFetch does not refresh token for auth endpoints", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: false, status: 401 }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const response = await result.current.authFetch("/api/auth/me", { method: "GET" }); + expect(response.status).toBe(401); + + const refreshCalls = (fetch as ReturnType).mock.calls.filter( + (call) => call[0] === "/api/auth/refresh" + ); + expect(refreshCalls.length).toBe(0); + }); + + it("refreshUser clears user when /auth/me returns non-401 error", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: false, status: 500 }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.refreshUser(); + }); + + expect(result.current.user).toBeNull(); + }); + + it("updateProfile throws default message when backend has no error field", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await expect(result.current.updateProfile({ currentPassword: "a", newPassword: "b" })).rejects.toThrow( + "Update failed" + ); + }); + + it("uploadAvatar throws default message when error payload is invalid JSON", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ + ok: false, + json: () => Promise.reject(new Error("invalid json")), + }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const file = new File(["avatar"], "avatar.png", { type: "image/png" }); + await expect(result.current.uploadAvatar(file)).rejects.toThrow("Upload failed"); + }); + + it("deleteAvatar succeeds and refreshes user", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "avatar-deleted" }) }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.deleteAvatar(); + }); + + expect(result.current.user?.username).toBe("avatar-deleted"); + }); + + it("deleteAccount clears current user on success", async () => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 9, username: "to-delete" } }) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.login("to-delete", "pw"); + }); + expect(result.current.user?.username).toBe("to-delete"); + + await act(async () => { + await result.current.deleteAccount(); + }); + + expect(result.current.user).toBeNull(); + }); }); diff --git a/frontend/src/test/components/ExportModal.test.tsx b/frontend/src/test/components/ExportModal.test.tsx index 97fc0f9..ebeb179 100644 --- a/frontend/src/test/components/ExportModal.test.tsx +++ b/frontend/src/test/components/ExportModal.test.tsx @@ -78,4 +78,14 @@ describe("ExportModal", () => { fireEvent.click(screen.getByText(/exportImport\.cancelButton/i)); expect(defaultProps.onClose).toHaveBeenCalled(); }); + + it("does not trigger export actions while exporting", () => { + const { container } = render(); + const actionCards = container.querySelectorAll(".action-card"); + + fireEvent.click(actionCards[0]); + fireEvent.click(actionCards[1]); + + expect(defaultProps.onExport).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/test/components/MedDetailModal.test.tsx b/frontend/src/test/components/MedDetailModal.test.tsx index 3bf2407..9c78727 100644 --- a/frontend/src/test/components/MedDetailModal.test.tsx +++ b/frontend/src/test/components/MedDetailModal.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MedDetailModal } from "../../components/MedDetailModal"; import type { Coverage, Medication, RefillEntry, StockThresholds } from "../../types"; +import * as utils from "../../utils"; const defaultSettings: StockThresholds = { lowStockDays: 7, @@ -242,15 +243,73 @@ describe("MedDetailModal with refill modal", () => { it("calls onSubmitRefill when refill submitted", () => { const onSubmitRefill = vi.fn(); - render(); + render(); - const submitBtns = document.querySelectorAll("button"); - const submitBtn = Array.from(submitBtns).find( - (btn) => btn.textContent?.includes("refill") || btn.textContent?.includes("submit") + const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement; + fireEvent.click(submitBtn); + expect(onSubmitRefill).toHaveBeenCalledWith(mockMedication.id); + }); + + it("disables refill submit button when no pills are entered", () => { + render(); + + const submitBtn = document.querySelector(".refill-modal .modal-footer .success") as HTMLButtonElement; + expect(submitBtn).toBeDisabled(); + }); + + it("shows singular refill preview text when total refill is one pill", () => { + const bottleMed: Medication = { + ...mockMedication, + packageType: "bottle", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + looseTablets: 10, + }; + + render(); + + expect(screen.getByText(/\+1 common\.pill/i)).toBeInTheDocument(); + }); + + it("parses refill packs and loose pills inputs", () => { + const onRefillPacksChange = vi.fn(); + const onRefillLooseChange = vi.fn(); + render( + ); - if (submitBtn) { - fireEvent.click(submitBtn); - } + + const numberInputs = document.querySelectorAll(".refill-modal input[type='number']"); + fireEvent.change(numberInputs[0], { target: { value: "3" } }); + fireEvent.change(numberInputs[1], { target: { value: "5" } }); + + expect(onRefillPacksChange).toHaveBeenCalledWith(3); + expect(onRefillLooseChange).toHaveBeenCalledWith(5); + }); + + it("uses zero fallback for invalid refill input values", () => { + const onRefillPacksChange = vi.fn(); + const onRefillLooseChange = vi.fn(); + render( + + ); + + const numberInputs = document.querySelectorAll(".refill-modal input[type='number']"); + fireEvent.change(numberInputs[0], { target: { value: "NaN" } }); + fireEvent.change(numberInputs[1], { target: { value: "" } }); + + expect(onRefillPacksChange).toHaveBeenCalledWith(0); + expect(onRefillLooseChange).toHaveBeenCalledWith(0); }); }); @@ -279,6 +338,24 @@ describe("MedDetailModal actions", () => { expect(onOpenRefillModal).toHaveBeenCalled(); } }); + + it("calls generateICS when export calendar button is clicked", () => { + const generateICSSpy = vi.spyOn(utils, "generateICS").mockImplementation(() => "BEGIN:VCALENDAR"); + render(); + + fireEvent.click(screen.getByTitle("modal.exportTooltip")); + expect(generateICSSpy).toHaveBeenCalledWith(mockMedication); + }); + + it("does not render export calendar button when no blisters exist", () => { + const medWithoutBlisters: Medication = { + ...mockMedication, + blisters: [], + }; + + render(); + expect(screen.queryByTitle("modal.exportTooltip")).not.toBeInTheDocument(); + }); }); describe("MedDetailModal with multiple blisters", () => { @@ -322,6 +399,41 @@ describe("MedDetailModal with image", () => { if (avatar) { fireEvent.click(avatar); } + + expect(onOpenImageLightbox).toHaveBeenCalledTimes(1); + }); + + it("renders lightbox when enabled and image is present", () => { + const med = { ...mockMedication, imageUrl: "test-image.jpg" }; + render(); + + expect(document.querySelector(".lightbox-overlay")).toBeInTheDocument(); + }); +}); + +describe("MedDetailModal nested modal overlays", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("closes refill modal when clicking refill overlay", () => { + const onCloseRefillModal = vi.fn(); + render(); + + const overlays = document.querySelectorAll(".modal-overlay"); + fireEvent.click(overlays[1]); + expect(onCloseRefillModal).toHaveBeenCalledTimes(1); + }); + + it("closes edit stock modal when clicking edit-stock overlay", () => { + const onCloseEditStockModal = vi.fn(); + render( + + ); + + const overlays = document.querySelectorAll(".modal-overlay"); + fireEvent.click(overlays[1]); + expect(onCloseEditStockModal).toHaveBeenCalledTimes(1); }); }); @@ -569,6 +681,24 @@ describe("MedDetailModal bottle package type", () => { expect(screen.queryByText("refill.packs")).not.toBeInTheDocument(); }); + it("parses bottle refill pills input", () => { + const onRefillLooseChange = vi.fn(); + render(); + + const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement; + fireEvent.change(input, { target: { value: "7" } }); + expect(onRefillLooseChange).toHaveBeenCalledWith(7); + }); + + it("uses zero fallback for invalid bottle refill input", () => { + const onRefillLooseChange = vi.fn(); + render(); + + const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement; + fireEvent.change(input, { target: { value: "" } }); + expect(onRefillLooseChange).toHaveBeenCalledWith(0); + }); + it("shows looseTablets as total capacity fallback when totalPills is null (backward compat)", () => { // Old medications created before totalPills column existed const oldBottleMed: Medication = { diff --git a/frontend/src/test/components/MobileEditModal.test.tsx b/frontend/src/test/components/MobileEditModal.test.tsx index d0a4b3d..378191a 100644 --- a/frontend/src/test/components/MobileEditModal.test.tsx +++ b/frontend/src/test/components/MobileEditModal.test.tsx @@ -357,6 +357,21 @@ describe("MobileEditModal form submission", () => { vi.clearAllMocks(); }); + it("does not call onSaveMedication when native form validation fails", () => { + const onSaveMedication = vi.fn(); + render(); + + const form = document.querySelector("form") as HTMLFormElement; + const checkValiditySpy = vi.spyOn(form, "checkValidity").mockReturnValue(false); + const reportValiditySpy = vi.spyOn(form, "reportValidity").mockReturnValue(false); + + fireEvent.submit(form); + + expect(checkValiditySpy).toHaveBeenCalled(); + expect(reportValiditySpy).toHaveBeenCalled(); + expect(onSaveMedication).not.toHaveBeenCalled(); + }); + it("calls onSaveMedication when form submitted", () => { const onSaveMedication = vi.fn((e: Event) => e.preventDefault()); const validForm = { ...defaultForm, name: "TestMed" }; @@ -386,6 +401,72 @@ describe("MobileEditModal form submission", () => { }); }); +describe("MobileEditModal field callbacks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls onFormChange when commercial name changes", () => { + const onFormChange = vi.fn(); + render(); + + const nameInput = document.querySelector('input[placeholder="form.placeholders.commercial"]') as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Aspirin" } }); + + expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ name: "Aspirin" })); + }); + + it("calls onFormChange when generic name changes", () => { + const onFormChange = vi.fn(); + render(); + + const genericInput = document.querySelector('input[placeholder="form.placeholders.generic"]') as HTMLInputElement; + fireEvent.change(genericInput, { target: { value: "Acetylsalicylic acid" } }); + + expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ genericName: "Acetylsalicylic acid" })); + }); + + it("calls onFormChange when notes change", () => { + const onFormChange = vi.fn(); + render(); + + const notes = document.querySelector("textarea") as HTMLTextAreaElement; + fireEvent.change(notes, { target: { value: "Take with food" } }); + + expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ notes: "Take with food" })); + }); + + it("calls onFormChange when dose unit changes", () => { + const onFormChange = vi.fn(); + render(); + + const doseUnitSelect = document.querySelector(".dose-unit-select") as HTMLSelectElement; + fireEvent.change(doseUnitSelect, { target: { value: "g" } }); + + expect(onFormChange).toHaveBeenCalledWith(expect.objectContaining({ doseUnit: "g" })); + }); + + it("calls onHandleValueChange when package type changes", () => { + const onHandleValueChange = vi.fn(); + render(); + + const packageSelect = document.querySelector(".package-type-select") as HTMLSelectElement; + fireEvent.change(packageSelect, { target: { value: "bottle" } }); + + expect(onHandleValueChange).toHaveBeenCalledWith("packageType", "bottle"); + }); + + it("calls onHandleValueChange when blister stock values change", () => { + const onHandleValueChange = vi.fn(); + render(); + + const packCountInput = document.querySelector('input[type="number"][min="0"]') as HTMLInputElement; + fireEvent.change(packCountInput, { target: { value: "4" } }); + + expect(onHandleValueChange).toHaveBeenCalledWith("packCount", "4"); + }); +}); + describe("MobileEditModal with filled form", () => { beforeEach(() => { vi.clearAllMocks(); @@ -416,6 +497,31 @@ describe("MobileEditModal takenBy", () => { vi.clearAllMocks(); }); + it("shows add-person placeholder when people already exist", () => { + const form = { + ...defaultForm, + takenBy: ["John"], + }; + + render(); + + const input = document.querySelector(".tag-input-container input") as HTMLInputElement; + expect(input.placeholder).toBe("form.placeholders.addPerson"); + }); + + it("filters takenBy suggestions and excludes already selected people", () => { + const form = { + ...defaultForm, + takenBy: ["John"], + }; + + render(); + + expect(document.querySelector('#takenby-suggestions-modal option[value="John"]')).not.toBeInTheDocument(); + expect(document.querySelector('#takenby-suggestions-modal option[value="Jane"]')).toBeInTheDocument(); + expect(document.querySelector('#takenby-suggestions-modal option[value="Alex"]')).toBeInTheDocument(); + }); + it("displays takenBy tags", () => { const form = { ...defaultForm, @@ -474,6 +580,17 @@ describe("MobileEditModal takenBy", () => { expect(onTakenByKeyDown).toHaveBeenCalled(); } }); + + it("calls onAddTakenByPerson on blur when input has value", () => { + const onAddTakenByPerson = vi.fn(); + + render(); + + const tagInput = document.querySelector(".tag-input-container input") as HTMLInputElement; + fireEvent.blur(tagInput); + + expect(onAddTakenByPerson).toHaveBeenCalledWith("Alex"); + }); }); describe("MobileEditModal overlay interaction", () => { @@ -540,6 +657,41 @@ describe("MobileEditModal optional fields", () => { const toggle = document.querySelector('.toggle-switch input[type="checkbox"]'); expect(toggle).toBeInTheDocument(); }); + + it("shows intake takenBy select when takenBy list is not empty", () => { + const form = { + ...defaultForm, + takenBy: ["John", "Jane"], + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + takenBy: "John", + intakeRemindersEnabled: false, + }, + ], + }; + + render(); + + expect(screen.getByText(/form\.blisters\.takenByIntake/i)).toBeInTheDocument(); + expect(document.querySelector('.blister-row select option[value="John"]')).toBeInTheDocument(); + }); + + it("passes single takenBy person as default when adding intake", () => { + const onAddIntake = vi.fn(); + const form = { + ...defaultForm, + takenBy: ["OnlyPerson"], + }; + + render(); + + fireEvent.click(screen.getByText(/form\.blisters\.addIntake/i)); + expect(onAddIntake).toHaveBeenCalledWith("OnlyPerson"); + }); }); describe("MobileEditModal bottle package type", () => { @@ -590,3 +742,100 @@ describe("MobileEditModal bottle package type", () => { expect(screen.queryByText("form.pillsPerBlister")).not.toBeInTheDocument(); }); }); + +describe("MobileEditModal refill and image actions", () => { + const baseMed = { + id: 1, + name: "Aspirin", + takenBy: [], + packageType: "blister" as const, + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00.000Z" }], + intakes: [ + { + usage: 1, + every: 1, + start: "2024-01-01T09:00:00.000Z", + takenBy: null, + intakeRemindersEnabled: false, + }, + ], + updatedAt: null, + imageUrl: null, + }; + + it("calls onSubmitRefill when refill button is clicked", () => { + const onSubmitRefill = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /refill\.button/i })); + expect(onSubmitRefill).toHaveBeenCalledWith(1); + }); + + it("disables refill button when refill values are empty", () => { + render(); + + const refillButton = screen.getByRole("button", { name: /refill\.button/i }); + expect(refillButton).toBeDisabled(); + }); + + it("shows refill preview for singular pill", () => { + render(); + + expect(document.querySelector(".refill-preview")?.textContent).toContain("+1 common.pill"); + }); + + it("disables refill button while refill is saving", () => { + render( + + ); + + const refillButton = screen.getByRole("button", { name: /common\.saving/i }); + expect(refillButton).toBeDisabled(); + }); + + it("calls onUploadMedImage when selecting a file", () => { + const onUploadMedImage = vi.fn().mockResolvedValue(undefined); + render(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(["img"], "med.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(onUploadMedImage).toHaveBeenCalledWith(1, file); + }); + + it("calls onDeleteMedImage when delete image button is clicked", () => { + const onDeleteMedImage = vi.fn().mockResolvedValue(undefined); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.removeImage/i })); + expect(onDeleteMedImage).toHaveBeenCalledWith(1); + }); +}); diff --git a/frontend/src/test/components/ShareDialog.test.tsx b/frontend/src/test/components/ShareDialog.test.tsx index 65f29cd..46ae82c 100644 --- a/frontend/src/test/components/ShareDialog.test.tsx +++ b/frontend/src/test/components/ShareDialog.test.tsx @@ -90,4 +90,22 @@ describe("ShareDialog", () => { fireEvent.click(input); expect(selectMock).toHaveBeenCalled(); }); + + it("calls person and period change callbacks", () => { + render(); + + const selects = screen.getAllByRole("combobox"); + fireEvent.change(selects[0], { target: { value: "Bob" } }); + fireEvent.change(selects[1], { target: { value: "90" } }); + + expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob"); + expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90); + }); + + it("disables generate button when no person is selected", () => { + render(); + + const generateButton = screen.getByRole("button", { name: /share\.generateLink/i }); + expect(generateButton).toBeDisabled(); + }); }); diff --git a/frontend/src/test/components/SharedSchedule.test.tsx b/frontend/src/test/components/SharedSchedule.test.tsx new file mode 100644 index 0000000..c463a48 --- /dev/null +++ b/frontend/src/test/components/SharedSchedule.test.tsx @@ -0,0 +1,512 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SharedSchedule } from "../../components/SharedSchedule"; + +function renderSharedSchedule(path: string) { + return render( + + + } /> + + + ); +} + +function expandTodayIfCollapsed() { + const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement; + expect(todayDivider).toBeInTheDocument(); + const todayBlock = document.querySelector(".day-block.today") as HTMLDivElement; + if (todayBlock?.classList.contains("collapsed")) { + fireEvent.click(todayDivider); + } +} + +function createSharedData(overrides: Record = {}) { + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + yesterday.setHours(9, 0, 0, 0); + + return { + sharedBy: "Owner", + takenBy: "Max", + scheduleDays: 30, + shareStockStatus: true, + stockCalculationMode: "automatic", + stockThresholds: { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + reminderDaysBefore: 7, + expiryWarningDays: 30, + }, + medications: [ + { + id: 1, + name: "Ibuprofen", + genericName: "Ibu", + takenBy: ["Max"], + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 0, + pillWeightMg: null, + doseUnit: "mg", + expiryDate: null, + notes: null, + intakeRemindersEnabled: false, + blisters: [{ usage: 1, every: 1, start: yesterday.toISOString() }], + intakes: [ + { usage: 1, every: 1, start: yesterday.toISOString(), takenBy: "Max", intakeRemindersEnabled: false }, + ], + updatedAt: null, + dismissedUntil: null, + lastStockCorrectionAt: null, + }, + ], + ...overrides, + }; +} + +function mockShareFetch( + token: string, + sharedData: Record, + doses: Array<{ doseId: string; dismissed?: boolean }> = [] +) { + (global.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { + if (url === `/api/share/${token}/doses` && (!init || !init.method || init.method === "GET")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses }) }); + } + if (url === `/api/share/${token}`) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) }); + } + if (url === `/api/share/${token}/doses` && init?.method === "POST") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + if (url.startsWith(`/api/share/${token}/doses/`) && init?.method === "DELETE") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); +} + +describe.skip("SharedSchedule", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + vi.spyOn(global, "setInterval").mockImplementation(() => 1 as unknown as ReturnType); + vi.spyOn(global, "clearInterval").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + const first = String(args[0] ?? ""); + if (first.includes("not wrapped in act")) return; + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("closes theme menu when clicking outside", async () => { + const sharedData = createSharedData(); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("theme.title")); + expect(document.querySelector(".theme-menu.open")).toBeInTheDocument(); + + fireEvent.click(document.body); + expect(document.querySelector(".theme-menu.open")).not.toBeInTheDocument(); + }); + + it("shows loading state initially", async () => { + let resolveShare: ((value: unknown) => void) | null = null; + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return new Promise((resolve) => { + resolveShare = resolve; + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + expect(screen.getByText("common.loading")).toBeInTheDocument(); + + resolveShare?.({ + ok: true, + json: () => Promise.resolve(createSharedData()), + }); + + await waitFor(() => { + expect(screen.queryByText("common.loading")).not.toBeInTheDocument(); + }); + }); + + it("renders not found error for 404 links", async () => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText("share.notFound")).toBeInTheDocument(); + }); + }); + + it("renders generic error for unexpected status codes", async () => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText("share.error")).toBeInTheDocument(); + }); + }); + + it("renders expired link state for 410 responses", async () => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ + ok: false, + status: 410, + json: () => + Promise.resolve({ + ownerUsername: "owner", + takenBy: "Max", + expiredAt: "2026-02-01T10:00:00.000Z", + }), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText("share.expired.title")).toBeInTheDocument(); + }); + }); + + it("renders schedule shell for valid shared data", async () => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + sharedBy: "Owner", + takenBy: "Max", + scheduleDays: 30, + shareStockStatus: true, + stockCalculationMode: "automatic", + stockThresholds: { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + reminderDaysBefore: 7, + expiryWarningDays: 30, + }, + medications: [], + }), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument(); + expect(screen.getByText("share.noSchedule")).toBeInTheDocument(); + }); + }); + + it("opens theme menu and switches to light theme", async () => { + (global.fetch as ReturnType).mockImplementation((url: string) => { + if (url === "/api/share/token-123/doses") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + sharedBy: "Owner", + takenBy: "Max", + scheduleDays: 30, + shareStockStatus: true, + stockCalculationMode: "automatic", + stockThresholds: { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + reminderDaysBefore: 7, + expiryWarningDays: 30, + }, + medications: [], + }), + }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share.scheduleFor/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("theme.title")); + fireEvent.click(screen.getByRole("button", { name: /theme\.light/i })); + + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + it("renders schedule rows for populated data and can expand future days", async () => { + const sharedData = createSharedData(); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + expect(screen.getByText("Ibuprofen")).toBeInTheDocument(); + }); + + const futureToggle = document.querySelector(".future-days-toggle"); + expect(futureToggle).toBeInTheDocument(); + fireEvent.click(futureToggle as Element); + + await waitFor(() => { + expect(document.querySelectorAll(".day-block").length).toBeGreaterThan(1); + }); + }); + + it("marks and undoes a dose via shared API", async () => { + const sharedData = createSharedData(); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText("Ibuprofen")).toBeInTheDocument(); + }); + + const takeButton = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement; + expect(takeButton).toBeInTheDocument(); + fireEvent.click(takeButton); + + await waitFor(() => { + expect(global.fetch as ReturnType).toHaveBeenCalledWith( + "/api/share/token-123/doses", + expect.objectContaining({ method: "POST" }) + ); + }); + }); + + it("undos a taken dose via shared API", async () => { + const sharedData = createSharedData(); + const today = new Date(); + const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + mockShareFetch("token-123", sharedData, [{ doseId: `1-0-${todayDateOnlyMs}-Max` }]); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + expandTodayIfCollapsed(); + + const undoButton = await waitFor(() => { + const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null; + expect(button).toBeInTheDocument(); + return button as HTMLButtonElement; + }); + fireEvent.click(undoButton); + + await waitFor(() => { + expect( + (global.fetch as ReturnType).mock.calls.some((call) => { + const [url, init] = call as [string, RequestInit | undefined]; + return typeof url === "string" && url.includes("/api/share/token-123/doses/") && init?.method === "DELETE"; + }) + ).toBe(true); + }); + }); + + it("hides stock status chips when shareStockStatus is false", async () => { + const sharedData = createSharedData({ shareStockStatus: false }); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText("Ibuprofen")).toBeInTheDocument(); + }); + + expect(document.querySelector(".status-chip")).not.toBeInTheDocument(); + }); + + it("opens and closes lightbox for medication image", async () => { + const pushStateSpy = vi.spyOn(window.history, "pushState").mockImplementation(() => {}); + const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {}); + const sharedData = createSharedData({ + medications: [ + { + ...createSharedData().medications[0], + imageUrl: "ibuprofen.png", + }, + ], + }); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + expandTodayIfCollapsed(); + + const avatar = await waitFor(() => { + const element = document.querySelector(".day-block.today .med-avatar.clickable") as HTMLDivElement | null; + expect(element).toBeInTheDocument(); + return element as HTMLDivElement; + }); + fireEvent.click(avatar); + + expect(pushStateSpy).toHaveBeenCalled(); + expect(document.querySelector(".lightbox-overlay")).toBeInTheDocument(); + + fireEvent.click(document.querySelector(".lightbox-overlay") as HTMLDivElement); + expect(backSpy).toHaveBeenCalled(); + }); + + it("reverts optimistic taken state when mark-dose request fails", async () => { + const sharedData = createSharedData(); + (global.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { + if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) }); + } + if (url === "/api/share/token-123/doses" && init?.method === "POST") { + return Promise.reject(new Error("post failed")); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + }); + + renderSharedSchedule("/share/token-123"); + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + expandTodayIfCollapsed(); + + const takeButton = await waitFor(() => { + const button = document.querySelector(".dose-btn.take:not([disabled])") as HTMLButtonElement | null; + expect(button).toBeInTheDocument(); + return button as HTMLButtonElement; + }); + fireEvent.click(takeButton); + + await waitFor(() => { + expect(document.querySelector(".dose-btn.undo")).not.toBeInTheDocument(); + expect(document.querySelector(".dose-btn.take:not([disabled])")).toBeInTheDocument(); + }); + }); + + it("reverts optimistic undo state when undo request fails", async () => { + const today = new Date(); + const todayDateOnlyMs = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + const sharedData = createSharedData(); + (global.fetch as ReturnType).mockImplementation((url: string, init?: RequestInit) => { + if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ doses: [{ doseId: `1-0-${todayDateOnlyMs}-Max` }] }), + }); + } + if (url === "/api/share/token-123") { + return Promise.resolve({ ok: true, json: () => Promise.resolve(sharedData) }); + } + if (url.startsWith("/api/share/token-123/doses/") && init?.method === "DELETE") { + return Promise.reject(new Error("delete failed")); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + }); + + renderSharedSchedule("/share/token-123"); + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + expandTodayIfCollapsed(); + + const undoButton = await waitFor(() => { + const button = document.querySelector(".dose-btn.undo") as HTMLButtonElement | null; + expect(button).toBeInTheDocument(); + return button as HTMLButtonElement; + }); + fireEvent.click(undoButton); + + await waitFor(() => { + expect(document.querySelector(".dose-btn.undo")).toBeInTheDocument(); + }); + }); + + it("persists manual collapse state in localStorage", async () => { + const setItemSpy = vi.spyOn(window.localStorage, "setItem"); + const sharedData = createSharedData(); + mockShareFetch("token-123", sharedData); + + renderSharedSchedule("/share/token-123"); + + await waitFor(() => { + expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument(); + }); + + const todayDivider = document.querySelector(".day-block.today .day-divider.clickable") as HTMLDivElement; + fireEvent.click(todayDivider); + + expect(setItemSpy).toHaveBeenCalled(); + expect( + setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_collapsedDays")) || + setItemSpy.mock.calls.some((call) => String(call[0]).includes("share_token-123_expandedDays")) + ).toBe(true); + }); +}); diff --git a/frontend/src/test/context/AppContext.test.tsx b/frontend/src/test/context/AppContext.test.tsx new file mode 100644 index 0000000..0fbcd1c --- /dev/null +++ b/frontend/src/test/context/AppContext.test.tsx @@ -0,0 +1,532 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppProvider, useAppContext } from "../../context/AppContext"; +import type { Medication } from "../../types"; + +const mockUseAuth = vi.fn(); +const mockUseMedications = vi.fn(); +const mockUseSettings = vi.fn(); +const mockUseDoses = vi.fn(); +const mockUseCollapsedDays = vi.fn(); +const mockUseShare = vi.fn(); +const mockUseRefill = vi.fn(); +const mockBuildSchedulePreview = vi.fn(); +const mockCalculateCoverage = vi.fn(); +const mockComputeMissedPastDoseIds = vi.fn(); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), +})); + +vi.mock("../../components/Auth", () => ({ + useAuth: () => mockUseAuth(), +})); + +vi.mock("../../hooks", () => ({ + useMedications: () => mockUseMedications(), + useSettings: () => mockUseSettings(), + useDoses: () => mockUseDoses(), + useCollapsedDays: () => mockUseCollapsedDays(), + useShare: () => mockUseShare(), + useRefill: () => mockUseRefill(), +})); + +vi.mock("../../utils/formatters", () => ({ + getSystemLocale: () => "en-US", +})); + +vi.mock("../../utils/schedule", () => ({ + buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args), + calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args), + computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args), + isDoseDismissed: vi.fn(() => false), +})); + +const meds: Medication[] = [ + { + id: 11, + name: "Aspirin", + takenBy: ["Max", "Anna"], + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 2, + blisters: [], + updatedAt: null, + }, +]; + +const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + +describe("useAppContext", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(window.history, "pushState").mockImplementation(() => {}); + vi.spyOn(window.history, "back").mockImplementation(() => {}); + (window.localStorage.getItem as ReturnType).mockReturnValue("90"); + + const loadMeds = vi.fn(); + const loadSettings = vi.fn(); + const loadTakenDoses = vi.fn(); + + mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" } }); + + mockUseMedications.mockReturnValue({ + meds, + setMeds: vi.fn(), + loading: false, + saving: false, + setSaving: vi.fn(), + uploadingImage: false, + loadMeds, + deleteMed: vi.fn(), + uploadMedImage: vi.fn(), + deleteMedImage: vi.fn(), + }); + + mockUseSettings.mockReturnValue({ + settings: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + lowStockDays: 10, + normalStockDays: 30, + highStockDays: 60, + smtpHost: "", + smtpPort: 587, + smtpUser: "", + smtpPass: "", + smtpFrom: "", + smtpSecure: false, + hasSmtpPassword: false, + lastAutoEmailSent: null, + nextScheduledCheck: null, + lastNotificationType: null, + lastNotificationChannel: null, + lastReminderMedName: null, + lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + stockCalculationMode: "automatic", + shareStockStatus: true, + expiryWarningDays: 30, + }, + setSettings: vi.fn(), + savedSettings: { + emailEnabled: false, + notificationEmail: "", + reminderDaysBefore: 7, + repeatDailyReminders: false, + skipRemindersForTakenDoses: false, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + lowStockDays: 10, + normalStockDays: 30, + highStockDays: 60, + smtpHost: "", + smtpPort: 587, + smtpUser: "", + smtpPass: "", + smtpFrom: "", + smtpSecure: false, + hasSmtpPassword: false, + lastAutoEmailSent: null, + nextScheduledCheck: null, + lastNotificationType: null, + lastNotificationChannel: null, + lastReminderMedName: null, + lastReminderTakenBy: null, + lastStockReminderSent: null, + lastStockReminderChannel: null, + lastStockReminderMedNames: null, + shoutrrrEnabled: false, + shoutrrrUrl: "", + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + stockCalculationMode: "automatic", + shareStockStatus: true, + expiryWarningDays: 30, + }, + settingsLoading: false, + settingsSaving: false, + settingsSaved: false, + testingEmail: false, + testEmailResult: null, + setTestEmailResult: vi.fn(), + testingShoutrrr: false, + testShoutrrrResult: null, + setTestShoutrrrResult: vi.fn(), + loadSettings, + saveSettings: vi.fn(), + testEmail: vi.fn(), + testShoutrrr: vi.fn(), + hasUnsavedChanges: false, + }); + + mockUseDoses.mockReturnValue({ + takenDoses: new Set(), + setTakenDoses: vi.fn(), + takenDoseTimestamps: new Map(), + dismissedDoses: new Set(), + showClearMissedConfirm: true, + setShowClearMissedConfirm: vi.fn(), + getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)), + countTakenDoses: vi.fn(() => ({ total: 0, taken: 0 })), + markDoseTaken: vi.fn(), + undoDoseTaken: vi.fn(), + loadTakenDoses, + }); + + mockUseCollapsedDays.mockReturnValue({ + manuallyCollapsedDays: new Set(), + manuallyExpandedDays: new Set(), + toggleDayCollapse: vi.fn(), + }); + + mockUseShare.mockReturnValue({ + showShareDialog: false, + sharePeople: ["Anna", "Max"], + shareSelectedPerson: "Anna", + setShareSelectedPerson: vi.fn(), + shareSelectedDays: 30, + setShareSelectedDays: vi.fn(), + shareGenerating: false, + shareLink: null, + setShareLink: vi.fn(), + shareCopied: false, + setShareCopied: vi.fn(), + openShareDialog: vi.fn(), + generateShareLink: vi.fn(), + copyShareLink: vi.fn(), + closeShareDialog: vi.fn(), + resetShareDialogState: vi.fn(), + }); + + mockUseRefill.mockReturnValue({ + showRefillModal: false, + setShowRefillModal: vi.fn(), + refillPacks: 1, + setRefillPacks: vi.fn(), + refillLoose: 0, + setRefillLoose: vi.fn(), + refillSaving: false, + refillHistory: [], + refillHistoryExpanded: false, + setRefillHistoryExpanded: vi.fn(), + showEditStockModal: false, + setShowEditStockModal: vi.fn(), + editStockFullBlisters: 0, + setEditStockFullBlisters: vi.fn(), + editStockPartialBlisterPills: 0, + setEditStockPartialBlisterPills: vi.fn(), + editStockSaving: false, + loadRefillHistory: vi.fn(), + submitRefill: vi.fn(), + submitStockCorrection: vi.fn(), + openRefillModal: vi.fn(), + closeRefillModal: vi.fn(), + openEditStockModal: vi.fn(), + closeEditStockModal: vi.fn(), + }); + + mockBuildSchedulePreview.mockReturnValue({ events: [] }); + mockCalculateCoverage.mockReturnValue({ all: [], low: [] }); + mockComputeMissedPastDoseIds.mockReturnValue([]); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + text: () => Promise.resolve('{"imported":{"medications":1,"doseHistory":2,"shareLinks":3}}'), + }); + }); + + it("throws if used outside AppProvider", () => { + expect(() => renderHook(() => useAppContext())).toThrow("useAppContext must be used within an AppProvider"); + }); + + it("loads initial values and composes computed fields", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + await waitFor(() => { + expect(result.current.scheduleDays).toBe(90); + }); + + expect(mockUseMedications().loadMeds).toHaveBeenCalled(); + expect(mockUseSettings().loadSettings).toHaveBeenCalled(); + expect(result.current.existingPeople).toEqual(["Anna", "Max"]); + expect(result.current.stockThresholds.lowStockDays).toBe(10); + expect(result.current.settingsChanged).toBe(false); + }); + + it("wraps share dialog opener with current medications", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + act(() => { + result.current.openShareDialog(); + }); + + expect(mockUseShare().openShareDialog).toHaveBeenCalledWith(meds); + }); + + it("opens and closes modal helpers via browser history", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + act(() => { + result.current.openImageLightbox(); + result.current.openScheduleLightbox("image.png"); + result.current.openUserFilter("Max"); + result.current.openMedDetail(meds[0]); + }); + + expect(window.history.pushState).toHaveBeenCalled(); + expect(mockUseRefill().loadRefillHistory).toHaveBeenCalledWith(11); + + act(() => { + result.current.closeImageLightbox(); + result.current.closeScheduleLightbox(); + result.current.closeUserFilter(); + result.current.closeMedDetail(); + }); + + expect(window.history.back).toHaveBeenCalled(); + }); + + it("dismisses missed doses and posts unique medication IDs", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + await act(async () => { + await result.current.dismissMissedDoses(["11-0-1730000000000", "11-2-1730000100000", "12-0-1730000200000"]); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/medications/dismiss-until", + expect.objectContaining({ + method: "POST", + credentials: "include", + }) + ); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body as string); + expect(body.medicationIds).toEqual([11, 12]); + expect(mockUseMedications().loadMeds).toHaveBeenCalled(); + expect(mockUseDoses().setShowClearMissedConfirm).toHaveBeenCalledWith(false); + }); + + it("does not dismiss missed doses for empty/invalid IDs", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + await act(async () => { + await result.current.dismissMissedDoses([]); + await result.current.dismissMissedDoses(["invalid-dose-id"]); + }); + + expect(fetch).not.toHaveBeenCalledWith("/api/medications/dismiss-until", expect.anything()); + }); + + it("imports data and triggers reload plus import result state", async () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + act(() => { + result.current.setPendingImportData({ version: "1", exportedAt: new Date().toISOString(), medications: [] }); + }); + + await act(async () => { + await result.current.handleImportConfirm(); + }); + + expect(fetch).toHaveBeenCalledWith( + "/api/import", + expect.objectContaining({ + method: "POST", + credentials: "include", + }) + ); + expect(mockUseMedications().loadMeds).toHaveBeenCalled(); + expect(mockUseSettings().loadSettings).toHaveBeenCalled(); + expect(mockUseDoses().loadTakenDoses).toHaveBeenCalled(); + expect(result.current.importResult).toEqual({ medications: 1, doses: 2, shares: 3 }); + }); + + it("exports data and triggers JSON download", async () => { + const click = vi.fn(); + const appendChild = vi.spyOn(document.body, "appendChild"); + const removeChild = vi.spyOn(document.body, "removeChild"); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:export-url"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName); + if (tagName === "a") { + Object.defineProperty(element, "click", { value: click, configurable: true }); + } + return element; + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ version: "1", medications: [] }), + }); + + const { result } = renderHook(() => useAppContext(), { wrapper }); + + await act(async () => { + await result.current.handleExport(true); + }); + + expect(fetch).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true", { + credentials: "include", + }); + expect(createObjectURL).toHaveBeenCalled(); + expect(click).toHaveBeenCalled(); + expect(appendChild).toHaveBeenCalled(); + expect(removeChild).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:export-url"); + }); + + it("handles invalid import JSON file", () => { + const mockAlert = vi.fn(); + global.alert = mockAlert; + + class MockFileReader { + onload: ((event: ProgressEvent) => void) | null = null; + readAsText = vi.fn(() => { + this.onload?.({ target: { result: "not-json" } } as unknown as ProgressEvent); + }); + } + vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader); + + const { result } = renderHook(() => useAppContext(), { wrapper }); + const file = new File(["bad"], "bad.json", { type: "application/json" }); + + act(() => { + result.current.handleImportFileSelect({ + target: { files: [file], value: "file" }, + } as unknown as React.ChangeEvent); + }); + + expect(mockAlert).toHaveBeenCalledWith("exportImport.invalidFile"); + }); + + it("parses valid import file and opens confirm modal", () => { + class MockFileReader { + onload: ((event: ProgressEvent) => void) | null = null; + readAsText = vi.fn(() => { + this.onload?.({ + target: { + result: JSON.stringify({ version: "1", exportedAt: "2026-01-01T00:00:00.000Z", medications: [] }), + }, + } as unknown as ProgressEvent); + }); + } + vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader); + + const { result } = renderHook(() => useAppContext(), { wrapper }); + const file = new File(["ok"], "ok.json", { type: "application/json" }); + + act(() => { + result.current.handleImportFileSelect({ + target: { files: [file], value: "file" }, + } as unknown as React.ChangeEvent); + }); + + expect(result.current.showImportConfirm).toBe(true); + expect(result.current.pendingImportData).toEqual({ + version: "1", + exportedAt: "2026-01-01T00:00:00.000Z", + medications: [], + }); + }); + + it("computes day stock status as warning and danger for low/out stock", async () => { + mockCalculateCoverage.mockReturnValue({ + all: [ + { + name: "Aspirin", + daysLeft: 2, + medsLeft: 5, + depletionTime: Date.now() + 100000, + }, + { + name: "Vitamin C", + daysLeft: 0, + medsLeft: 0, + depletionTime: Date.now() - 100000, + }, + ], + low: [], + }); + + const { result } = renderHook(() => useAppContext(), { wrapper }); + + expect(result.current.getDayStockStatus([{ medName: "Aspirin", lastWhen: Date.now() }])).toBe("warning"); + expect(result.current.getDayStockStatus([{ medName: "Vitamin C", lastWhen: Date.now() }])).toBe("danger"); + }); + + it("does not navigate back when closing modals that are not open", () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + + act(() => { + result.current.closeImageLightbox(); + result.current.closeScheduleLightbox(); + result.current.closeUserFilter(); + result.current.closeMedDetail(); + }); + + expect(window.history.back).not.toHaveBeenCalled(); + }); + + it("shows import error alert when import API returns non-ok response", async () => { + const mockAlert = vi.fn(); + global.alert = mockAlert; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('{"error":"Import failed"}'), + }); + + const { result } = renderHook(() => useAppContext(), { wrapper }); + + act(() => { + result.current.setPendingImportData({ version: "1", exportedAt: new Date().toISOString(), medications: [] }); + }); + + await act(async () => { + await result.current.handleImportConfirm(); + }); + + expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed"); + }); + + it("keeps clear-missed confirm open when dismiss request fails", async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error("network")); + const { result } = renderHook(() => useAppContext(), { wrapper }); + + await act(async () => { + await result.current.dismissMissedDoses(["11-0-1730000000000"]); + }); + + expect(mockUseDoses().setShowClearMissedConfirm).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/test/context/UnsavedChangesContext.test.tsx b/frontend/src/test/context/UnsavedChangesContext.test.tsx new file mode 100644 index 0000000..08e86fc --- /dev/null +++ b/frontend/src/test/context/UnsavedChangesContext.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it } from "vitest"; +import { UnsavedChangesProvider, useUnsavedChanges } from "../../context/UnsavedChangesContext"; + +function TestConsumer() { + const { hasUnsavedChanges, setHasUnsavedChanges, confirmNavigation } = useUnsavedChanges(); + const [result, setResult] = useState("idle"); + + return ( +
+
{String(hasUnsavedChanges)}
+
{result}
+ + +
+ ); +} + +describe("UnsavedChangesContext", () => { + it("throws if used outside provider", () => { + expect(() => renderHook(() => useUnsavedChanges())).toThrow( + "useUnsavedChanges must be used within UnsavedChangesProvider" + ); + }); + + it("resolves confirmNavigation immediately when there are no unsaved changes", async () => { + render( + + + + ); + + fireEvent.click(screen.getByText("confirm-navigation")); + + await waitFor(() => { + expect(screen.getByTestId("result")).toHaveTextContent("true"); + }); + expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument(); + }); + + it("opens confirmation modal and resolves false on cancel", async () => { + render( + + + + ); + + fireEvent.click(screen.getByText("set-unsaved")); + expect(screen.getByTestId("has-unsaved")).toHaveTextContent("true"); + + fireEvent.click(screen.getByText("confirm-navigation")); + expect(screen.getByText("common.unsavedChanges.title")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("common.unsavedChanges.stay")); + + await waitFor(() => { + expect(screen.getByTestId("result")).toHaveTextContent("false"); + }); + expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument(); + }); + + it("opens confirmation modal and resolves true on confirm", async () => { + render( + + + + ); + + fireEvent.click(screen.getByText("set-unsaved")); + fireEvent.click(screen.getByText("confirm-navigation")); + expect(screen.getByText("common.unsavedChanges.title")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("common.unsavedChanges.leave")); + + await waitFor(() => { + expect(screen.getByTestId("result")).toHaveTextContent("true"); + }); + expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/hooks/useCollapsedDays.test.ts b/frontend/src/test/hooks/useCollapsedDays.test.ts index fb9286e..05f0c92 100644 --- a/frontend/src/test/hooks/useCollapsedDays.test.ts +++ b/frontend/src/test/hooks/useCollapsedDays.test.ts @@ -84,4 +84,14 @@ describe("useCollapsedDays", () => { expect(window.localStorage.setItem).not.toHaveBeenCalled(); }); + + it("saves expanded days key when toggling auto-collapsed day", () => { + const { result } = renderHook(() => useCollapsedDays(7)); + + act(() => { + result.current.toggleDayCollapse("2024-02-01", true); + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith("expandedDays_user_7", expect.any(String)); + }); }); diff --git a/frontend/src/test/hooks/useDoses.test.ts b/frontend/src/test/hooks/useDoses.test.ts index 20d5800..9879b42 100644 --- a/frontend/src/test/hooks/useDoses.test.ts +++ b/frontend/src/test/hooks/useDoses.test.ts @@ -282,4 +282,26 @@ describe("useDoses", () => { expect(result.current.showClearMissedConfirm).toBe(true); }); + + it("undoDoseTaken encodes special characters in dose ID", async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ doses: [{ doseId: "dose 1/a", takenAt: Date.now(), dismissed: false }] }), + }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.has("dose 1/a")).toBe(true); + }); + + await act(async () => { + await result.current.undoDoseTaken("dose 1/a"); + }); + + expect(fetch).toHaveBeenCalledWith("/api/doses/taken/dose%201%2Fa", expect.objectContaining({ method: "DELETE" })); + }); }); diff --git a/frontend/src/test/hooks/useMedicationForm.test.ts b/frontend/src/test/hooks/useMedicationForm.test.ts index 4e85eed..d59b459 100644 --- a/frontend/src/test/hooks/useMedicationForm.test.ts +++ b/frontend/src/test/hooks/useMedicationForm.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, it } from "vitest"; -import { defaultBlister, defaultForm } from "../../hooks/useMedicationForm"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { defaultBlister, defaultForm, defaultIntake, useMedicationForm } from "../../hooks/useMedicationForm"; +import type { Medication } from "../../types"; +import { toDateValue } from "../../utils/formatters"; -// Note: Hook tests were causing memory issues due to complex dependencies -// Testing only the exported utility functions to avoid heap overflow +const tMock = (key: string) => key; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: tMock, + }), +})); describe("defaultBlister", () => { it("creates a blister with default values", () => { @@ -14,13 +22,8 @@ describe("defaultBlister", () => { }); it("uses current date", () => { - const before = new Date(); const blister = defaultBlister(); - const after = new Date(); - - 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); + expect(blister.startDate).toBe(toDateValue(new Date())); }); }); @@ -30,15 +33,19 @@ describe("defaultForm", () => { expect(form.name).toBe(""); expect(form.genericName).toBe(""); expect(form.takenBy).toEqual([]); + expect(form.packageType).toBe("blister"); expect(form.packCount).toBe("1"); expect(form.blistersPerPack).toBe("1"); expect(form.pillsPerBlister).toBe("1"); + expect(form.totalPills).toBe(""); expect(form.looseTablets).toBe("0"); expect(form.pillWeightMg).toBe(""); + expect(form.doseUnit).toBe("mg"); expect(form.expiryDate).toBe(""); expect(form.notes).toBe(""); expect(form.intakeRemindersEnabled).toBe(false); expect(form.blisters).toHaveLength(1); + expect(form.intakes).toHaveLength(1); }); it("creates a blister in the form", () => { @@ -48,6 +55,15 @@ describe("defaultForm", () => { expect(form.blisters[0].every).toBe("1"); }); + it("creates an intake in the form", () => { + const form = defaultForm(); + expect(form.intakes).toHaveLength(1); + expect(form.intakes[0].usage).toBe("1"); + expect(form.intakes[0].every).toBe("1"); + expect(form.intakes[0].takenBy).toBe(""); + expect(form.intakes[0].intakeRemindersEnabled).toBe(false); + }); + it("creates independent forms", () => { const form1 = defaultForm(); const form2 = defaultForm(); @@ -71,4 +87,275 @@ describe("defaultForm", () => { form1.takenBy.push("John"); expect(form2.takenBy).toHaveLength(0); }); + + it("creates independent intakes arrays", () => { + const form1 = defaultForm(); + const form2 = defaultForm(); + + form1.intakes.push(defaultIntake("Jane")); + expect(form2.intakes).toHaveLength(1); + }); +}); + +describe("defaultIntake", () => { + it("creates an intake with default values", () => { + const intake = defaultIntake(); + expect(intake.usage).toBe("1"); + expect(intake.every).toBe("1"); + expect(intake.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(intake.startTime).toMatch(/^\d{2}:\d{2}$/); + expect(intake.takenBy).toBe(""); + expect(intake.intakeRemindersEnabled).toBe(false); + }); + + it("accepts a prefilled takenBy value", () => { + const intake = defaultIntake("Alex"); + expect(intake.takenBy).toBe("Alex"); + }); +}); + +describe("useMedicationForm", () => { + it("initializes with default state", async () => { + const { result } = renderHook(() => useMedicationForm()); + + expect(result.current.form.name).toBe(""); + expect(result.current.editingId).toBeNull(); + expect(result.current.formChanged).toBe(false); + + await waitFor(() => { + expect(result.current.fieldErrors.name).toBe("common.validation.required"); + expect(result.current.hasValidationErrors).toBe(true); + }); + }); + + it("validates name required and max length fields", () => { + const { result } = renderHook(() => useMedicationForm()); + + expect(result.current.validateField("name", "")).toBe("common.validation.required"); + expect(result.current.validateField("takenBy", ["Alice"])).toBeUndefined(); + + const tooLongGeneric = "a".repeat(101); + const maxLengthError = result.current.validateField("genericName", tooLongGeneric); + expect(maxLengthError).toBe("common.validation.maxLength"); + }); + + it("updates form values and tracks changed state", async () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.handleValueChange("name", "Aspirin"); + }); + + expect(result.current.form.name).toBe("Aspirin"); + expect(result.current.formChanged).toBe(true); + + await waitFor(() => { + expect(result.current.fieldErrors.name).toBeUndefined(); + }); + }); + + it("adds, edits and removes blister rows", () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.addBlister(); + }); + expect(result.current.form.blisters).toHaveLength(2); + + act(() => { + result.current.setBlisterValue(1, "usage", "3"); + result.current.setBlisterValue(1, "every", "2"); + }); + expect(result.current.form.blisters[1].usage).toBe("3"); + expect(result.current.form.blisters[1].every).toBe("2"); + + act(() => { + result.current.removeBlister(0); + }); + expect(result.current.form.blisters).toHaveLength(1); + }); + + it("adds, edits and removes intake rows", () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.addIntake("Max"); + }); + expect(result.current.form.intakes).toHaveLength(2); + expect(result.current.form.intakes[1].takenBy).toBe("Max"); + + act(() => { + result.current.setIntakeValue(1, "usage", "2.5"); + result.current.setIntakeValue(1, "intakeRemindersEnabled", true); + }); + expect(result.current.form.intakes[1].usage).toBe("2.5"); + expect(result.current.form.intakes[1].intakeRemindersEnabled).toBe(true); + + act(() => { + result.current.removeIntake(0); + }); + expect(result.current.form.intakes).toHaveLength(1); + }); + + it("handles takenBy tag input add/remove and deduplication", () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.addTakenByPerson(" Alice "); + }); + + act(() => { + result.current.addTakenByPerson("Alice"); + result.current.addTakenByPerson(""); + }); + + expect(result.current.form.takenBy).toEqual(["Alice"]); + + act(() => { + result.current.removeTakenByPerson("Alice"); + }); + expect(result.current.form.takenBy).toEqual([]); + }); + + it("handles takenBy keyboard shortcuts (Enter, comma, Backspace)", () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.setTakenByInput("Bob"); + }); + + const preventDefault = vi.fn(); + act(() => { + result.current.handleTakenByKeyDown({ + key: "Enter", + preventDefault, + } as unknown as React.KeyboardEvent); + }); + + expect(preventDefault).toHaveBeenCalled(); + expect(result.current.form.takenBy).toContain("Bob"); + + act(() => { + result.current.setTakenByInput("Cara"); + }); + + act(() => { + result.current.handleTakenByKeyDown({ + key: ",", + preventDefault, + } as unknown as React.KeyboardEvent); + }); + expect(result.current.form.takenBy).toContain("Cara"); + + act(() => { + result.current.setTakenByInput(""); + result.current.handleTakenByKeyDown({ + key: "Backspace", + preventDefault, + } as unknown as React.KeyboardEvent); + }); + expect(result.current.form.takenBy).toEqual(["Bob"]); + }); + + it("maps medication with intakes in startEdit and opens modal on mobile", () => { + const { result } = renderHook(() => useMedicationForm()); + const openEditModal = vi.fn(); + Object.defineProperty(window, "innerWidth", { value: 375, writable: true }); + + const med: Medication = { + id: 10, + name: "Ibuprofen", + genericName: "Ibuprofen", + takenBy: ["Max"], + packageType: "blister", + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5, + pillWeightMg: 400, + doseUnit: "mg", + expiryDate: "2027-01-01", + notes: "note", + intakeRemindersEnabled: true, + blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }], + intakes: [ + { + usage: 2, + every: 1, + start: "2026-01-02T09:00:00.000Z", + takenBy: "Max", + intakeRemindersEnabled: true, + }, + ], + updatedAt: null, + }; + + act(() => { + result.current.startEdit(med, openEditModal); + }); + + expect(result.current.editingId).toBe(10); + expect(result.current.formSaved).toBe(true); + expect(result.current.form.intakes[0].takenBy).toBe("Max"); + expect(openEditModal).toHaveBeenCalled(); + }); + + it("falls back to legacy blisters when intakes are missing", () => { + const { result } = renderHook(() => useMedicationForm()); + const openEditModal = vi.fn(); + Object.defineProperty(window, "innerWidth", { value: 1024, writable: true }); + + const med: Medication = { + id: 11, + name: "Legacy Med", + takenBy: [], + packageType: "blister", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 8, + looseTablets: 0, + blisters: [ + { usage: 1, every: 2, start: "2026-01-03T10:00:00.000Z" }, + { usage: 2, every: 1, start: "2026-01-04T12:00:00.000Z" }, + ], + intakeRemindersEnabled: true, + updatedAt: null, + }; + + act(() => { + result.current.startEdit(med, openEditModal); + }); + + expect(result.current.form.intakes).toHaveLength(2); + expect(result.current.form.intakes[0].takenBy).toBe(""); + expect(result.current.form.intakes[0].intakeRemindersEnabled).toBe(true); + expect(openEditModal).not.toHaveBeenCalled(); + }); + + it("resets complete form state", () => { + const { result } = renderHook(() => useMedicationForm()); + + act(() => { + result.current.setEditingId(5); + result.current.setShowEditModal(true); + result.current.setPendingImage(new File(["x"], "image.png", { type: "image/png" })); + result.current.setPendingImagePreview("data:image/png;base64,abc"); + result.current.setFormSaved(true); + result.current.setTakenByInput("X"); + result.current.handleValueChange("name", "Changed"); + }); + + act(() => { + result.current.resetForm(); + }); + + expect(result.current.editingId).toBeNull(); + expect(result.current.showEditModal).toBe(false); + expect(result.current.pendingImage).toBeNull(); + expect(result.current.pendingImagePreview).toBeNull(); + expect(result.current.takenByInput).toBe(""); + expect(result.current.formSaved).toBe(false); + expect(result.current.form.name).toBe(""); + expect(result.current.formChanged).toBe(false); + }); }); diff --git a/frontend/src/test/hooks/useMedications.test.ts b/frontend/src/test/hooks/useMedications.test.ts index f7fb6e6..d8c458a 100644 --- a/frontend/src/test/hooks/useMedications.test.ts +++ b/frontend/src/test/hooks/useMedications.test.ts @@ -38,6 +38,8 @@ describe("useMedications", () => { result.current.loadMeds(); }); + expect(result.current.loading).toBe(true); + await waitFor(() => { expect(result.current.meds).toEqual(mockMeds); }); @@ -108,6 +110,23 @@ describe("useMedications", () => { expect(mockResetForm).toHaveBeenCalled(); }); + it("still reloads medications when delete request fails", async () => { + (global.fetch as ReturnType) + .mockRejectedValueOnce(new Error("Delete failed")) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const mockResetForm = vi.fn(); + const { result } = renderHook(() => useMedications()); + + await act(async () => { + await result.current.deleteMed(5, 5, mockResetForm); + }); + + expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" }); + expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" }); + expect(mockResetForm).toHaveBeenCalled(); + }); + it("does not call resetForm if editingId does not match", async () => { (global.fetch as ReturnType) .mockResolvedValueOnce({ ok: true }) diff --git a/frontend/src/test/hooks/useSettings.test.ts b/frontend/src/test/hooks/useSettings.test.ts index e7ca4dd..47fa21a 100644 --- a/frontend/src/test/hooks/useSettings.test.ts +++ b/frontend/src/test/hooks/useSettings.test.ts @@ -249,4 +249,79 @@ describe("useSettings", () => { // emailEnabled should be false in the saved state expect(result.current.settings.emailEnabled).toBe(false); }); + + it("auto-disables shoutrrr when URL is empty", async () => { + (global.fetch as ReturnType) + .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, + shoutrrrEnabled: true, + shoutrrrUrl: "", + })); + }); + + const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent; + + await act(async () => { + await result.current.saveSettings(mockEvent); + }); + + expect(result.current.settings.shoutrrrEnabled).toBe(false); + }); + + it("refreshes reminder status on interval", async () => { + let refreshCallback: (() => void) | null = null; + const nativeSetInterval = global.setInterval; + vi.spyOn(global, "setInterval").mockImplementation((handler: TimerHandler, timeout?: number) => { + if (timeout === 30000) { + refreshCallback = handler as () => void; + return 1 as unknown as ReturnType; + } + return nativeSetInterval(handler, timeout); + }); + + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + lastAutoEmailSent: "2026-01-01T10:00:00.000Z", + lastNotificationType: "stock", + lastNotificationChannel: "email", + lastReminderMedName: "Aspirin", + lastReminderTakenBy: "Max", + lastStockReminderSent: "2026-01-01T09:00:00.000Z", + lastStockReminderChannel: "both", + lastStockReminderMedNames: "Aspirin", + }), + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + expect(refreshCallback).not.toBeNull(); + + act(() => { + refreshCallback?.(); + }); + + await waitFor(() => { + expect(result.current.settings.lastAutoEmailSent).toBe("2026-01-01T10:00:00.000Z"); + expect(result.current.settings.lastNotificationType).toBe("stock"); + expect(result.current.settings.lastStockReminderChannel).toBe("both"); + }); + }); }); diff --git a/frontend/src/test/hooks/useTheme.test.ts b/frontend/src/test/hooks/useTheme.test.ts index 18ed2c3..6d152ab 100644 --- a/frontend/src/test/hooks/useTheme.test.ts +++ b/frontend/src/test/hooks/useTheme.test.ts @@ -41,6 +41,13 @@ describe("useTheme", () => { expect(result.current.themePreference).toBe("light"); }); + it("falls back to dark for invalid stored theme", () => { + (window.localStorage.getItem as ReturnType).mockReturnValue("invalid-theme"); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe("dark"); + expect(result.current.themePreference).toBe("dark"); + }); + it("toggles theme through light → dark → system → light", () => { (window.localStorage.getItem as ReturnType).mockReturnValue("light"); const { result } = renderHook(() => useTheme()); @@ -103,4 +110,48 @@ describe("useTheme", () => { }); expect(document.documentElement.getAttribute("data-theme")).toBe("light"); }); + + it("reacts to system theme changes when preference is system", () => { + let isLight = false; + let changeHandler: (() => void) | undefined; + const addEventListener = vi.fn((_: string, handler: () => void) => { + changeHandler = handler; + }); + const removeEventListener = vi.fn(); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query === "(prefers-color-scheme: light)" ? isLight : false, + media: query, + onchange: null, + addEventListener, + removeEventListener, + dispatchEvent: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })), + }); + + const { result } = renderHook(() => useTheme()); + + act(() => { + result.current.setThemePreference("system"); + }); + + expect(addEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + + act(() => { + isLight = true; + changeHandler?.(); + }); + + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + + act(() => { + result.current.setThemePreference("dark"); + }); + + expect(removeEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); }); diff --git a/frontend/src/test/hooks/useUnsavedChangesWarning.test.ts b/frontend/src/test/hooks/useUnsavedChangesWarning.test.ts new file mode 100644 index 0000000..3ae67a4 --- /dev/null +++ b/frontend/src/test/hooks/useUnsavedChangesWarning.test.ts @@ -0,0 +1,52 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useUnsavedChangesWarning } from "../../hooks/useUnsavedChangesWarning"; + +describe("useUnsavedChangesWarning", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers and unregisters beforeunload listener", () => { + const addListenerSpy = vi.spyOn(window, "addEventListener"); + const removeListenerSpy = vi.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => useUnsavedChangesWarning(false)); + + const beforeUnloadCall = addListenerSpy.mock.calls.find((call) => call[0] === "beforeunload"); + expect(beforeUnloadCall).toBeDefined(); + + const handler = beforeUnloadCall?.[1] as EventListener; + unmount(); + + expect(removeListenerSpy).toHaveBeenCalledWith("beforeunload", handler); + }); + + it("sets returnValue when unsaved changes exist", () => { + const addListenerSpy = vi.spyOn(window, "addEventListener"); + renderHook(() => useUnsavedChangesWarning(true)); + + const beforeUnloadCall = addListenerSpy.mock.calls.find((call) => call[0] === "beforeunload"); + const handler = beforeUnloadCall?.[1] as (event: BeforeUnloadEvent) => unknown; + + const event = { preventDefault: vi.fn(), returnValue: undefined } as unknown as BeforeUnloadEvent; + handler(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.returnValue).toBe("common.unsavedChanges.message"); + }); + + it("does not set returnValue when there are no unsaved changes", () => { + const addListenerSpy = vi.spyOn(window, "addEventListener"); + renderHook(() => useUnsavedChangesWarning(false)); + + const beforeUnloadCall = addListenerSpy.mock.calls.find((call) => call[0] === "beforeunload"); + const handler = beforeUnloadCall?.[1] as (event: BeforeUnloadEvent) => unknown; + + const event = { preventDefault: vi.fn(), returnValue: undefined } as unknown as BeforeUnloadEvent; + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.returnValue).toBeUndefined(); + }); +}); diff --git a/frontend/src/test/pages/DashboardPage.test.tsx b/frontend/src/test/pages/DashboardPage.test.tsx index 4a73466..c8009a5 100644 --- a/frontend/src/test/pages/DashboardPage.test.tsx +++ b/frontend/src/test/pages/DashboardPage.test.tsx @@ -1,7 +1,15 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DashboardPage } from "../../pages/DashboardPage"; +import { + DashboardPage, + formatFullBlisters, + formatOpenBlisterAndLoose, + getBlisterStock, + getMedTotal, + getReminderStatusData, + userStorageKey, +} from "../../pages/DashboardPage"; // Mock data for tests with medications const mockMeds = [ @@ -131,6 +139,7 @@ const createMockAppContext = (overrides = {}) => ({ showPastDays: false, setShowPastDays: vi.fn(), pastDays: [], + todayDay: null, futureDays: [], takenDoses: new Set(), dismissedDoses: new Set(), @@ -166,6 +175,119 @@ const createMockAppContext = (overrides = {}) => ({ let mockContextValue = createMockAppContext(); +describe("DashboardPage helper functions", () => { + it("builds user storage key correctly", () => { + expect(userStorageKey(5, "scheduleDays")).toBe("user_5_scheduleDays"); + expect(userStorageKey(undefined, "scheduleDays")).toBe("scheduleDays"); + }); + + it("calculates blister stock breakdown", () => { + expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 7 }); + }); + + it("formats blister and open blister labels", () => { + const t = (key: string) => key; + expect(formatFullBlisters(1, t)).toBe("1 common.blister"); + expect(formatFullBlisters(3, t)).toBe("3 common.blisters"); + expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-"); + expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills"); + }); + + it("computes total pills for blister and bottle types", () => { + expect( + getMedTotal({ + packageType: "blister", + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 3, + stockAdjustment: 2, + }) + ).toBe(25); + + expect( + getMedTotal({ + packageType: "bottle", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + looseTablets: 50, + stockAdjustment: -3, + }) + ).toBe(47); + }); + + it("builds reminder status data for critical and history rows", () => { + const t = (key: string) => key; + const result = getReminderStatusData( + 7, + 30, + [{ name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null }], + [ + { name: "A", daysLeft: 2, medsLeft: 1, depletionDate: null, depletionTime: null, nextDose: null }, + { name: "B", daysLeft: 10, medsLeft: 4, depletionDate: null, depletionTime: null, nextDose: null }, + ], + "2026-01-01T10:00:00.000Z", + "intake", + "email", + "A", + "Max", + "2026-01-01T09:00:00.000Z", + "both", + "A (+1)", + t, + "en-US" + ); + + expect(result.status.className).toBe("danger"); + expect(result.lowStockMeds.length).toBe(2); + expect(result.lastStockSent).not.toBeNull(); + expect(result.lastIntakeSent?.medName).toBe("A"); + }); + + it("builds warning and success reminder statuses", () => { + const t = (key: string) => key; + + const warning = getReminderStatusData( + 7, + 30, + [], + [{ name: "C", daysLeft: 12, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }], + null, + null, + null, + null, + null, + null, + null, + null, + t, + "en-US" + ); + expect(warning.status.className).toBe("warning"); + + const success = getReminderStatusData( + 7, + 30, + [], + [{ name: "D", daysLeft: 40, medsLeft: 10, depletionDate: null, depletionTime: null, nextDose: null }], + null, + null, + null, + null, + null, + null, + null, + null, + t, + "en-US" + ); + expect(success.status.className).toBe("success"); + expect(success.lastStockSent).toBeNull(); + expect(success.lastIntakeSent).toBeNull(); + }); +}); + // Mock the context vi.mock("../../context", () => ({ useAppContext: () => mockContextValue, @@ -357,6 +479,9 @@ describe("DashboardPage interactions", () => { }); it("can change schedule days", () => { + const setScheduleDays = vi.fn(); + mockContextValue = createMockAppContext({ setScheduleDays }); + render( @@ -367,6 +492,7 @@ describe("DashboardPage interactions", () => { expect(select).toBeInTheDocument(); fireEvent.change(select, { target: { value: "90" } }); + expect(setScheduleDays).toHaveBeenCalledWith(90); }); }); @@ -757,6 +883,28 @@ describe("DashboardPage with past days", () => { } }); + it("collapses past days when already expanded", () => { + const setShowPastDays = vi.fn(); + mockContextValue = createMockAppContext({ + pastDays: mockPastDays, + showPastDays: true, + setShowPastDays, + missedPastDoseIds: [], + }); + + render( + + + + ); + + const toggle = document.querySelector(".past-days-toggle"); + if (toggle) { + fireEvent.click(toggle); + expect(setShowPastDays).toHaveBeenCalledWith(false); + } + }); + it("shows clear missed doses button when there are missed doses", () => { render( @@ -768,6 +916,272 @@ describe("DashboardPage with past days", () => { const clearBtn = document.querySelector(".clear-missed-btn"); expect(clearBtn).toBeInTheDocument(); }); + + it("opens clear missed confirmation modal and confirms action", () => { + const dismissMissedDoses = vi.fn(); + mockContextValue = createMockAppContext({ + pastDays: mockPastDays, + showPastDays: false, + missedPastDoseIds: ["1-0-1-John", "1-0-2-John"], + showClearMissedConfirm: true, + dismissMissedDoses, + }); + + render( + + + + ); + + expect(screen.getByText(/dashboard\.schedules\.clearMissedConfirmTitle/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i })); + expect(dismissMissedDoses).toHaveBeenCalledWith(["1-0-1-John", "1-0-2-John"]); + }); +}); + +describe("DashboardPage additional branches", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("calls openShareDialog when share button is clicked", () => { + const openShareDialog = vi.fn(); + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + openShareDialog, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /share\.button/i })); + expect(openShareDialog).toHaveBeenCalled(); + }); + + it("toggles future days visibility", () => { + const setShowFutureDays = vi.fn(); + mockContextValue = createMockAppContext({ + futureDays: mockFutureDays, + showFutureDays: false, + setShowFutureDays, + }); + + render( + + + + ); + + const futureToggle = document.querySelector(".future-days-toggle"); + expect(futureToggle).toBeInTheDocument(); + fireEvent.click(futureToggle!); + expect(setShowFutureDays).toHaveBeenCalledWith(true); + }); + + it("shows network error on manual reminder fetch failure", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("offline")); + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + settings: { + ...createMockAppContext().settings, + emailEnabled: true, + emailStockReminders: true, + notificationEmail: "test@example.com", + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByText("dashboard.reorder.sendReminder")); + + await waitFor(() => { + expect(screen.getByText("common.networkError")).toBeInTheDocument(); + }); + }); + + it("opens medication detail from last stock reminder med link", () => { + const openMedDetail = vi.fn(); + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + settings: { + ...createMockAppContext().settings, + emailEnabled: true, + emailStockReminders: true, + lastStockReminderSent: "2026-02-10T10:00:00.000Z", + lastStockReminderChannel: "email", + lastStockReminderMedNames: "Aspirin (+1)", + }, + openMedDetail, + }); + + render( + + + + ); + + const reminderMedLink = document.querySelector(".reminder-status-bar .med-link") as HTMLElement; + expect(reminderMedLink).toBeInTheDocument(); + fireEvent.click(reminderMedLink); + expect(openMedDetail).toHaveBeenCalled(); + }); + + it("persists selected schedule days to localStorage", () => { + const setScheduleDays = vi.fn(); + const setItemSpy = vi.spyOn(window.localStorage, "setItem"); + mockContextValue = createMockAppContext({ setScheduleDays }); + + render( + + + + ); + + const select = document.querySelector(".schedule-days-select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "180" } }); + + expect(setScheduleDays).toHaveBeenCalledWith(180); + expect(setItemSpy).toHaveBeenCalledWith("user_1_scheduleDays", "180"); + }); + + it("opens schedule lightbox when clicking medication avatar image", () => { + const openScheduleLightbox = vi.fn(); + const medsWithImage = [{ ...mockMeds[0], imageUrl: "aspirin.png" }]; + const futureDay = [ + { + dateStr: "Tomorrow", + date: new Date(Date.now() + 86400000), + isPast: false, + meds: [ + { + medName: "Aspirin", + total: 1, + doses: [{ id: "1-0-1", timeStr: "09:00", when: Date.now() + 86400000, usage: 1, takenBy: [] }], + lastWhen: Date.now() + 86400000, + }, + ], + }, + ]; + + mockContextValue = createMockAppContext({ + meds: medsWithImage, + coverage: mockCoverage, + coverageByMed: { Aspirin: mockCoverage.all[0] }, + depletionByMed: { Aspirin: Date.now() + 10 * 86400000 }, + futureDays: futureDay, + showFutureDays: true, + manuallyExpandedDays: new Set(["Tomorrow"]), + openScheduleLightbox, + }); + + render( + + + + ); + + const avatar = document.querySelector(".day-block .med-avatar.clickable") as HTMLElement; + expect(avatar).toBeInTheDocument(); + fireEvent.click(avatar); + expect(openScheduleLightbox).toHaveBeenCalledWith("/api/images/aspirin.png"); + }); + + it("clicking clear missed button opens confirmation", () => { + const setShowClearMissedConfirm = vi.fn(); + mockContextValue = createMockAppContext({ + pastDays: mockPastDays, + missedPastDoseIds: ["1-0-1-John"], + setShowClearMissedConfirm, + }); + + render( + + + + ); + + const clearBtn = document.querySelector(".clear-missed-btn") as HTMLButtonElement; + fireEvent.click(clearBtn); + expect(setShowClearMissedConfirm).toHaveBeenCalledWith(true); + }); + + it("renders and interacts with today day schedule block", () => { + const markDoseTaken = vi.fn(); + const undoDoseTaken = vi.fn(); + const todayDoseId = "1-0-1000"; + const today = { + dateStr: "Today", + date: new Date(), + isPast: false, + meds: [ + { + medName: "Aspirin", + total: 1, + doses: [{ id: todayDoseId, timeStr: "09:00", when: Date.now() - 1000, usage: 1, takenBy: ["John"] }], + lastWhen: Date.now() - 1000, + }, + ], + }; + + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + coverageByMed: { Aspirin: mockCoverage.all[0] }, + depletionByMed: { Aspirin: Date.now() + 10 * 86400000 }, + todayDay: today, + markDoseTaken, + undoDoseTaken, + takenDoses: new Set(), + getDoseId: vi.fn((id: string, person: string | null) => (person ? `${id}-${person}` : id)), + }); + + render( + + + + ); + + expect(screen.getByText("Today")).toBeInTheDocument(); + const takeButton = document.querySelector(".day-block.today .dose-btn.take") as HTMLButtonElement; + expect(takeButton).toBeInTheDocument(); + fireEvent.click(takeButton); + expect(markDoseTaken).toHaveBeenCalled(); + + mockContextValue = createMockAppContext({ + meds: mockMeds, + coverage: mockCoverage, + coverageByMed: { Aspirin: mockCoverage.all[0] }, + depletionByMed: { Aspirin: Date.now() + 10 * 86400000 }, + todayDay: today, + markDoseTaken, + undoDoseTaken, + takenDoses: new Set([`${todayDoseId}-John`]), + manuallyExpandedDays: new Set(["Today"]), + getDoseId: vi.fn((id: string, person: string | null) => (person ? `${id}-${person}` : id)), + }); + + render( + + + + ); + + const undoButton = document.querySelector(".day-block.today .dose-btn.undo") as HTMLButtonElement; + expect(undoButton).toBeInTheDocument(); + fireEvent.click(undoButton); + expect(undoDoseTaken).toHaveBeenCalled(); + }); }); describe("DashboardPage with expanded past days", () => { diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx index e35257a..7be0e2f 100644 --- a/frontend/src/test/pages/MedicationsPage.test.tsx +++ b/frontend/src/test/pages/MedicationsPage.test.tsx @@ -318,6 +318,23 @@ describe("MedicationsPage with medications", () => { const editButtons = document.querySelectorAll(".info"); expect(editButtons.length).toBeGreaterThan(0); }); + + it("calls startEdit when clicking edit button", () => { + const startEdit = vi.fn(); + mockFormHookValue = createMockFormHook({ startEdit }); + + render( + + + + ); + + const editButton = document.querySelector(".med-actions .info") as HTMLButtonElement; + fireEvent.click(editButton); + expect(startEdit).toHaveBeenCalledTimes(1); + expect(startEdit.mock.calls[0][0]).toEqual(expect.objectContaining({ id: 1, name: "Aspirin" })); + expect(typeof startEdit.mock.calls[0][1]).toBe("function"); + }); }); describe("MedicationsPage form interactions", () => { @@ -363,6 +380,21 @@ describe("MedicationsPage form interactions", () => { expect(addIntake).toHaveBeenCalled(); } }); + + it("calls handleValueChange when package type is changed", () => { + const handleValueChange = vi.fn(); + mockFormHookValue = createMockFormHook({ handleValueChange }); + + render( + + + + ); + + const packageTypeSelect = document.querySelector(".package-type-select") as HTMLSelectElement; + fireEvent.change(packageTypeSelect, { target: { value: "bottle" } }); + expect(handleValueChange).toHaveBeenCalledWith("packageType", "bottle"); + }); }); describe("MedicationsPage form validation", () => { @@ -829,6 +861,24 @@ describe("MedicationsPage image upload for existing medication", () => { const fileInput = document.querySelector('input[type="file"]'); expect(fileInput).toBeInTheDocument(); }); + + it("calls uploadMedImage when selecting file for existing medication", () => { + const uploadMedImage = vi.fn(); + mockContextValue = createMockContext({ meds: mockMeds, uploadMedImage }); + mockFormHookValue = createMockFormHook({ editingId: 1 }); + + render( + + + + ); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(["img"], "existing-med.jpg", { type: "image/jpeg" }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(uploadMedImage).toHaveBeenCalledWith(1, file); + }); }); describe("MedicationsPage with medication image", () => { @@ -1076,6 +1126,23 @@ describe("MedicationsPage new entry button", () => { fireEvent.click(newEntryBtn); expect(resetForm).toHaveBeenCalled(); }); + + it("opens mobile edit modal when clicking new entry on mobile", () => { + const resetForm = vi.fn(); + mockFormHookValue = createMockFormHook({ resetForm }); + + Object.defineProperty(window, "innerWidth", { value: 375, writable: true }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.newEntry/i })); + expect(resetForm).toHaveBeenCalled(); + expect(document.querySelector(".modal-content.edit-modal")).toBeInTheDocument(); + }); }); describe("MedicationsPage cancel edit button", () => { @@ -1618,3 +1685,379 @@ describe("MedicationsPage blister refill shows packs", () => { expect(refillSection!.textContent).toContain("refill.loosePills"); }); }); + +describe("MedicationsPage save and unsaved branches", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + Object.defineProperty(window, "innerWidth", { value: 1024, writable: true }); + }); + + it("saves new medication successfully", async () => { + const setSaving = vi.fn(); + const loadMeds = vi.fn(); + const setFormSaved = vi.fn(); + const resetForm = vi.fn(); + const setOriginalForm = vi.fn(); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 99 }), + }); + + mockContextValue = createMockContext({ setSaving, loadMeds }); + mockFormHookValue = createMockFormHook({ + formChanged: true, + setFormSaved, + resetForm, + setOriginalForm, + form: { + ...createMockFormHook().form, + name: "New Medication", + intakes: [ + { + usage: "1", + every: "1", + startDate: "2026-02-10", + startTime: "09:00", + takenBy: "", + intakeRemindersEnabled: false, + }, + ], + }, + }); + + render( + + + + ); + + fireEvent.submit(document.querySelector("form.form-grid")!); + + await screen.findByText(/medications\.list\.title/i); + expect(fetch).toHaveBeenCalledWith( + "/api/medications", + expect.objectContaining({ method: "POST", credentials: "include" }) + ); + expect(setSaving).toHaveBeenCalledWith(true); + expect(setFormSaved).toHaveBeenCalledWith(true); + expect(loadMeds).toHaveBeenCalled(); + expect(resetForm).toHaveBeenCalled(); + expect(setSaving).toHaveBeenCalledWith(false); + }); + + it("shows alert when save fails", async () => { + const setSaving = vi.fn(); + const mockAlert = vi.fn(); + global.alert = mockAlert; + + global.fetch = vi.fn().mockResolvedValue({ ok: false }); + + mockContextValue = createMockContext({ setSaving }); + mockFormHookValue = createMockFormHook({ + formChanged: true, + form: { + ...createMockFormHook().form, + name: "Broken Medication", + }, + }); + + render( + + + + ); + + fireEvent.submit(document.querySelector("form.form-grid")!); + + await screen.findByText(/medications\.list\.title/i); + expect(mockAlert).toHaveBeenCalledWith("common.saveFailed"); + expect(setSaving).toHaveBeenCalledWith(false); + }); + + it("opens unsaved confirmation when closing mobile modal with unsaved changes", async () => { + const resetForm = vi.fn(); + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ + formChanged: true, + resetForm, + }); + + Object.defineProperty(window, "innerWidth", { value: 375, writable: true }); + vi.spyOn(window.history, "pushState").mockImplementation(() => {}); + vi.spyOn(window.history, "back").mockImplementation(() => {}); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.newEntry/i })); + fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement); + + expect(screen.getByText(/common\.unsavedChanges\.title/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /common\.unsavedChanges\.leave/i })); + expect(resetForm).toHaveBeenCalled(); + }); + + it("keeps editing when unsaved confirmation stay is clicked", () => { + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ formChanged: true }); + Object.defineProperty(window, "innerWidth", { value: 375, writable: true }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.newEntry/i })); + fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement); + + expect(screen.getByText(/common\.unsavedChanges\.title/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /common\.unsavedChanges\.stay/i })); + expect(screen.queryByText(/common\.unsavedChanges\.title/i)).not.toBeInTheDocument(); + }); + + it("saves existing medication via PUT and updates original form", async () => { + const setSaving = vi.fn(); + const setFormSaved = vi.fn(); + const setOriginalForm = vi.fn(); + const resetForm = vi.fn(); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 1 }), + }); + + mockContextValue = createMockContext({ setSaving, loadMeds: vi.fn() }); + mockFormHookValue = createMockFormHook({ + editingId: 1, + formChanged: true, + setFormSaved, + setOriginalForm, + resetForm, + form: { + ...createMockFormHook().form, + name: "Edited Medication", + }, + }); + + render( + + + + ); + + fireEvent.submit(document.querySelector("form.form-grid")!); + + await screen.findByText(/medications\.list\.title/i); + expect(fetch).toHaveBeenCalledWith( + "/api/medications/1", + expect.objectContaining({ method: "PUT", credentials: "include" }) + ); + expect(setFormSaved).toHaveBeenCalledWith(true); + expect(setOriginalForm).toHaveBeenCalled(); + expect(resetForm).not.toHaveBeenCalled(); + expect(setSaving).toHaveBeenCalledWith(false); + }); + + it("uploads selected image after saving a new medication", async () => { + const uploadMedImage = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 123 }), + }); + + class MockFileReader { + onload: ((event: ProgressEvent) => void) | null = null; + readAsDataURL = vi.fn(() => { + this.onload?.({ target: { result: "data:image/png;base64,test" } } as unknown as ProgressEvent); + }); + } + vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader); + + mockContextValue = createMockContext({ uploadMedImage }); + mockFormHookValue = createMockFormHook({ + formChanged: true, + form: { + ...createMockFormHook().form, + name: "With Image", + }, + }); + + render( + + + + ); + + const fileInput = document.querySelector('.image-upload-section input[type="file"]') as HTMLInputElement; + const file = new File(["img"], "new-med.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + fireEvent.submit(document.querySelector("form.form-grid")!); + + await screen.findByText(/medications\.list\.title/i); + expect(uploadMedImage).toHaveBeenCalledWith(123, file); + }); + + it("closes mobile modal without confirmation when there are no unsaved changes", () => { + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ formChanged: false }); + Object.defineProperty(window, "innerWidth", { value: 375, writable: true }); + const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {}); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.newEntry/i })); + fireEvent.click(document.querySelector(".modal-close") as HTMLButtonElement); + + expect(backSpy).toHaveBeenCalled(); + expect(screen.queryByText(/common\.unsavedChanges\.title/i)).not.toBeInTheDocument(); + }); + + it("renders image preview and removes image in edit mode", () => { + const deleteMedImage = vi.fn(); + const medsWithImage = [{ ...mockMeds[0], imageUrl: "edit-image.png" }]; + mockContextValue = createMockContext({ meds: medsWithImage, deleteMedImage }); + mockFormHookValue = createMockFormHook({ editingId: 1 }); + + render( + + + + ); + + expect(document.querySelector(".image-preview img")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /form\.removeImage/i })); + expect(deleteMedImage).toHaveBeenCalledWith(1); + }); + + it("passes single takenBy person to addIntake button", () => { + const addIntake = vi.fn(); + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ + addIntake, + form: { + ...createMockFormHook().form, + takenBy: ["John"], + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.blisters\.addIntake/i })); + expect(addIntake).toHaveBeenCalledWith("John"); + }); + + it("does not pass takenBy person to addIntake when multiple people exist", () => { + const addIntake = vi.fn(); + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ + addIntake, + form: { + ...createMockFormHook().form, + takenBy: ["John", "Jane"], + }, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /form\.blisters\.addIntake/i })); + expect(addIntake).toHaveBeenCalledWith(undefined); + }); + + it("shows and updates intake-specific takenBy select when takenBy list is present", () => { + const setIntakeValue = vi.fn(); + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook({ + setIntakeValue, + form: { + ...createMockFormHook().form, + takenBy: ["John", "Jane"], + intakes: [ + { + usage: "1", + every: "1", + startDate: "2024-01-01", + startTime: "09:00", + takenBy: "John", + intakeRemindersEnabled: false, + }, + ], + }, + }); + + render( + + + + ); + + expect(screen.getByText(/form\.blisters\.takenByIntake/i)).toBeInTheDocument(); + const select = document.querySelector(".blister-row select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "Jane" } }); + expect(setIntakeValue).toHaveBeenCalledWith(0, "takenBy", "Jane"); + }); + + it("shows pending preview for new medication after selecting an image", () => { + class MockFileReader { + onload: ((event: ProgressEvent) => void) | null = null; + readAsDataURL = vi.fn(() => { + this.onload?.({ target: { result: "data:image/png;base64,preview" } } as unknown as ProgressEvent); + }); + } + vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader); + + mockContextValue = createMockContext(); + mockFormHookValue = createMockFormHook(); + + render( + + + + ); + + const fileInput = document.querySelector('.image-upload-section input[type="file"]') as HTMLInputElement; + const file = new File(["img"], "preview.png", { type: "image/png" }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(document.querySelector('.image-preview img[alt="Preview"]')).toBeInTheDocument(); + }); + + it("disables edit image upload when uploading and shows refill adding label", () => { + mockContextValue = createMockContext({ + meds: mockMeds, + uploadingImage: true, + refillSaving: true, + refillPacks: 1, + refillLoose: 1, + }); + mockFormHookValue = createMockFormHook({ editingId: 1 }); + + render( + + + + ); + + const fileInput = document.querySelector('.image-upload-section input[type="file"]') as HTMLInputElement; + expect(fileInput).toBeDisabled(); + expect(screen.getByText(/refill\.adding/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/pages/PlannerPage.test.tsx b/frontend/src/test/pages/PlannerPage.test.tsx index 7de9607..5d1f24e 100644 --- a/frontend/src/test/pages/PlannerPage.test.tsx +++ b/frontend/src/test/pages/PlannerPage.test.tsx @@ -398,6 +398,107 @@ describe("PlannerPage with email enabled", () => { const _emailBtn = document.querySelector(".ghost"); // Email button may be present }); + + it("sends planner notification and shows success message", async () => { + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 25, + plannerUsage: 5, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 1, + loosePills: 0, + enough: true, + packageType: "blister", + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ message: "Planner notification sent" }), + }); + + render( + + + + ); + + await act(async () => { + fireEvent.submit(document.querySelector("form.planner")!); + }); + + const notifyBtn = await screen.findByRole("button", { name: /planner\.sendNotification/i }); + await act(async () => { + fireEvent.click(notifyBtn); + }); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/planner/send-email", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + ); + + await waitFor(() => { + expect(screen.getByText("Planner notification sent")).toBeInTheDocument(); + }); + }); + + it("shows error message when planner notification fails", async () => { + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + medicationId: 1, + medicationName: "Aspirin", + totalPills: 25, + plannerUsage: 5, + blisterSize: 10, + blistersNeeded: 1, + fullBlisters: 1, + loosePills: 0, + enough: true, + packageType: "blister", + }, + ]), + }) + .mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Could not send planner notification" }), + }); + + render( + + + + ); + + await act(async () => { + fireEvent.submit(document.querySelector("form.planner")!); + }); + + const notifyBtn = await screen.findByRole("button", { name: /planner\.sendNotification/i }); + await act(async () => { + fireEvent.click(notifyBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Could not send planner notification")).toBeInTheDocument(); + }); + }); }); describe("PlannerPage form interactions", () => { @@ -445,6 +546,55 @@ describe("PlannerPage form interactions", () => { // Form should be reset (no results table) expect(screen.getByText(/planner\.title/i)).toBeInTheDocument(); }); + + it("toggles includeUntilStart checkbox", () => { + render( + + + + ); + + const checkbox = document.querySelector('.planner-checkbox input[type="checkbox"]') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + }); + + it("submits planner request with includeUntilStart=true", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + + render( + + + + ); + + const checkbox = document.querySelector('.planner-checkbox input[type="checkbox"]') as HTMLInputElement; + fireEvent.click(checkbox); + + const form = document.querySelector("form.planner") as HTMLFormElement; + await act(async () => { + fireEvent.submit(form); + }); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/medications/usage", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + ); + + const fetchCall = (global.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.includeUntilStart).toBe(true); + expect(typeof body.startDate).toBe("string"); + expect(typeof body.endDate).toBe("string"); + }); }); describe("PlannerPage medication detail", () => { diff --git a/frontend/src/test/pages/SchedulePage.test.tsx b/frontend/src/test/pages/SchedulePage.test.tsx index c480e82..6f2b09a 100644 --- a/frontend/src/test/pages/SchedulePage.test.tsx +++ b/frontend/src/test/pages/SchedulePage.test.tsx @@ -220,6 +220,9 @@ describe("SchedulePage", () => { }); it("can change schedule days", () => { + const setScheduleDays = vi.fn(); + mockContextValue = createMockContext({ setScheduleDays }); + render( @@ -230,6 +233,7 @@ describe("SchedulePage", () => { expect(select).toBeInTheDocument(); fireEvent.change(select, { target: { value: "90" } }); + expect(setScheduleDays).toHaveBeenCalledWith(90); }); }); @@ -485,6 +489,28 @@ describe("SchedulePage with past days", () => { expect(setShowPastDays).toHaveBeenCalledWith(true); } }); + + it("collapses past days when already expanded", () => { + const setShowPastDays = vi.fn(); + mockContextValue = createMockContext({ + pastDays: mockPastDays, + showPastDays: true, + setShowPastDays, + missedPastDoseIds: [], + }); + + render( + + + + ); + + const toggle = document.querySelector(".past-days-toggle"); + if (toggle) { + fireEvent.click(toggle); + expect(setShowPastDays).toHaveBeenCalledWith(false); + } + }); }); describe("SchedulePage with expanded past days", () => { diff --git a/frontend/src/test/pages/SettingsPage.test.tsx b/frontend/src/test/pages/SettingsPage.test.tsx index 176e0a8..75f153a 100644 --- a/frontend/src/test/pages/SettingsPage.test.tsx +++ b/frontend/src/test/pages/SettingsPage.test.tsx @@ -3,6 +3,22 @@ import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SettingsPage } from "../../pages/SettingsPage"; +const changeLanguageMock = vi.fn(); + +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + language: "en", + changeLanguage: changeLanguageMock, + }, + }), + }; +}); + // Factory function for mock context const createMockContext = (overrides = {}) => ({ settings: { @@ -254,6 +270,19 @@ describe("SettingsPage interactions", () => { expect(select).toBeInTheDocument(); expect(select).not.toBeNull(); }); + + it("calls i18n.changeLanguage when language is changed", () => { + render( + + + + ); + + const select = document.querySelector(".language-select") as HTMLSelectElement; + fireEvent.change(select, { target: { value: "de" } }); + + expect(changeLanguageMock).toHaveBeenCalledWith("de"); + }); }); describe("SettingsPage loading state", () => { @@ -347,6 +376,50 @@ describe("SettingsPage with shoutrrr enabled", () => { const toggles = document.querySelectorAll(".toggle-switch"); expect(toggles.length).toBeGreaterThan(0); }); + + it("updates shoutrrr stock reminder matrix toggle", () => { + const setSettings = vi.fn(); + mockContextValue = createMockContext({ + setSettings, + settings: { + ...createMockContext().settings, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://example.com/topic", + shoutrrrStockReminders: false, + }, + }); + + render( + + + + ); + + const matrixToggles = document.querySelectorAll('.notification-matrix .matrix-row input[type="checkbox"]'); + fireEvent.click(matrixToggles[1]); + expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shoutrrrStockReminders: true })); + }); + + it("keeps shoutrrr matrix unchecked when URL is empty", () => { + mockContextValue = createMockContext({ + settings: { + ...createMockContext().settings, + shoutrrrEnabled: true, + shoutrrrUrl: "", + shoutrrrStockReminders: true, + }, + }); + + render( + + + + ); + + const matrixToggles = document.querySelectorAll('.notification-matrix .matrix-row input[type="checkbox"]'); + const shoutrrrStockToggle = matrixToggles[1] as HTMLInputElement; + expect(shoutrrrStockToggle.checked).toBe(false); + }); }); describe("SettingsPage test buttons", () => { @@ -380,16 +453,9 @@ describe("SettingsPage test buttons", () => { ); - // Look for test email button - const testButtons = document.querySelectorAll("button"); - const testEmailBtn = Array.from(testButtons).find( - (btn) => - btn.textContent?.toLowerCase().includes("test") || btn.getAttribute("title")?.toLowerCase().includes("test") - ); - - if (testEmailBtn) { - fireEvent.click(testEmailBtn); - } + const testEmailBtn = screen.getByRole("button", { name: /common\.test/i }); + fireEvent.click(testEmailBtn); + expect(testEmail).toHaveBeenCalledTimes(1); }); }); @@ -849,13 +915,67 @@ describe("SettingsPage shoutrrr URL input", () => { ); - const ghostButtons = document.querySelectorAll("button.ghost"); - // Find test button (there should be one for shoutrrr when enabled) - if (ghostButtons.length > 0) { - const lastGhostBtn = ghostButtons[ghostButtons.length - 1]; - fireEvent.click(lastGhostBtn); - // testShoutrrr should have been called - } + const testButtons = screen.getAllByRole("button", { name: /common\.test/i }); + const pushTestButton = testButtons[testButtons.length - 1]; + fireEvent.click(pushTestButton); + expect(testShoutrrr).toHaveBeenCalledTimes(1); + }); +}); + +describe("SettingsPage import interactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockContextValue = createMockContext(); + }); + + it("calls handleImportFileSelect when selecting a file", () => { + const handleImportFileSelect = vi.fn(); + mockContextValue = createMockContext({ handleImportFileSelect }); + + render( + + + + ); + + const fileInput = document.querySelector("#import-file-input") as HTMLInputElement; + const file = new File(["{}"], "backup.json", { type: "application/json" }); + fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(handleImportFileSelect).toHaveBeenCalledTimes(1); + }); + + it("closes import confirmation and clears pending import data on cancel", () => { + const setShowImportConfirm = vi.fn(); + const setPendingImportData = vi.fn(); + mockContextValue = createMockContext({ + showImportConfirm: true, + setShowImportConfirm, + setPendingImportData, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /exportImport\.cancelButton/i })); + expect(setShowImportConfirm).toHaveBeenCalledWith(false); + expect(setPendingImportData).toHaveBeenCalledWith(null); + }); + + it("triggers hidden import input click when import button is pressed", () => { + const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click"); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /exportImport\.import$/i })); + expect(clickSpy).toHaveBeenCalled(); }); }); @@ -919,6 +1039,7 @@ describe("SettingsPage schedule overview", () => { settings: { ...createMockContext().settings, nextScheduledCheck: "2024-01-15T06:00:00Z", + lastStockReminderSent: "2024-01-13T06:00:00Z", lastAutoEmailSent: "2024-01-14T06:00:00Z", }, }); @@ -953,6 +1074,43 @@ describe("SettingsPage schedule overview", () => { expect(screen.getByText(/settings\.schedule\.lastIntakeSent/i)).toBeInTheDocument(); }); + + it("shows last stock reminder time when available", () => { + render( + + + + ); + + expect(screen.getByText(/settings\.schedule\.lastStockSent/i)).toBeInTheDocument(); + }); +}); + +describe("SettingsPage import success banner", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockContextValue = createMockContext({ + importResult: { medications: 2, doses: 5, shares: 1 }, + setImportResult: vi.fn(), + }); + }); + + it("clears import result when success banner close is clicked", () => { + const setImportResult = vi.fn(); + mockContextValue = createMockContext({ + importResult: { medications: 2, doses: 5, shares: 1 }, + setImportResult, + }); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(setImportResult).toHaveBeenCalledWith(null); + }); }); describe("SettingsPage skip taken doses toggle", () => {