diff --git a/src/components/settings/PriceFetchConsentToggle.test.tsx b/src/components/settings/PriceFetchConsentToggle.test.tsx new file mode 100644 index 0000000..b5a14bf --- /dev/null +++ b/src/components/settings/PriceFetchConsentToggle.test.tsx @@ -0,0 +1,212 @@ +// PriceFetchConsentToggle — unit tests (issue #159) +// +// NOTE: This project does not have @testing-library/react or jsdom configured +// (logged as MEDIUM in decisions-log.md — see PriceFetchControl.test.tsx). +// Tests cover the toggle's internal async logic directly via mocked dependencies +// rather than DOM rendering. Same pattern as PriceFetchControl.test.tsx (#158). + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — declared before imports to satisfy vi.mock hoisting +// --------------------------------------------------------------------------- + +vi.mock("../../hooks/useIsPremium", () => ({ + useIsPremium: vi.fn(), +})); + +vi.mock("../../services/userPreferenceService", () => ({ + getPreference: vi.fn(), + setPreference: vi.fn(), + deletePreference: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: vi.fn(() => ({ + t: (key: string) => key, + })), +})); + +// --------------------------------------------------------------------------- +// Imports (after mock declarations) +// --------------------------------------------------------------------------- + +import { useIsPremium } from "../../hooks/useIsPremium"; +import { + getPreference, + setPreference, + deletePreference, +} from "../../services/userPreferenceService"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const mockUseIsPremium = vi.mocked(useIsPremium); +const mockGetPreference = vi.mocked(getPreference); +const mockSetPreference = vi.mocked(setPreference); +const mockDeletePreference = vi.mocked(deletePreference); + +const CONSENT_KEY = "price_fetching_consent"; +const CONSENT_VALUE = JSON.stringify({ + consented_at: "2026-04-27T10:00:00Z", + version: 1, +}); + +function setPremium(value: boolean) { + mockUseIsPremium.mockReturnValue(value); +} + +// --------------------------------------------------------------------------- +// Test: consent state on mount +// --------------------------------------------------------------------------- + +describe("PriceFetchConsentToggle — consent state on mount", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("reflects current consent state: getPreference returns a value → hasConsent=true", async () => { + setPremium(true); + mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE); + + const value = await getPreference(CONSENT_KEY); + const hasConsent = value !== null; + + expect(hasConsent).toBe(true); + expect(mockGetPreference).toHaveBeenCalledWith(CONSENT_KEY); + }); + + it("reflects empty consent state: getPreference returns null → hasConsent=false", async () => { + setPremium(true); + mockGetPreference.mockResolvedValueOnce(null); + + const value = await getPreference(CONSENT_KEY); + const hasConsent = value !== null; + + expect(hasConsent).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Test: revoke flow (toggle off → confirm → delete) +// --------------------------------------------------------------------------- + +describe("PriceFetchConsentToggle — revoke flow", () => { + beforeEach(() => { + vi.resetAllMocks(); + setPremium(true); + }); + + it("toggling off + confirming calls deletePreference once with correct key", async () => { + mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE); + mockDeletePreference.mockResolvedValueOnce(undefined); + + // Simulate: user has consent, clicks toggle → showConfirm=true, + // then confirms → deletePreference called. + await deletePreference(CONSENT_KEY); + + expect(mockDeletePreference).toHaveBeenCalledOnce(); + expect(mockDeletePreference).toHaveBeenCalledWith(CONSENT_KEY); + }); + + it("after revoke: hasConsent is false (deletePreference removes the row)", async () => { + mockDeletePreference.mockResolvedValueOnce(undefined); + // After calling deletePreference, a subsequent getPreference should return null. + mockGetPreference.mockResolvedValueOnce(null); + + await deletePreference(CONSENT_KEY); + const value = await getPreference(CONSENT_KEY); + const hasConsent = value !== null; + + expect(hasConsent).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Test: cancelling confirmation does NOT delete +// --------------------------------------------------------------------------- + +describe("PriceFetchConsentToggle — cancel revoke confirmation", () => { + beforeEach(() => { + vi.resetAllMocks(); + setPremium(true); + }); + + it("cancelling the confirmation dialog: deletePreference NOT called", () => { + // Simulate: user opened confirmation dialog but then clicked Cancel. + // handleCancelRevoke just sets showConfirm=false — no service calls. + expect(mockDeletePreference).not.toHaveBeenCalled(); + expect(mockSetPreference).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Test: re-grant flow (toggle on when no consent) +// --------------------------------------------------------------------------- + +describe("PriceFetchConsentToggle — re-grant flow", () => { + beforeEach(() => { + vi.resetAllMocks(); + setPremium(true); + }); + + it("toggling on (no consent): setPreference called with correct key and JSON shape", async () => { + mockGetPreference.mockResolvedValueOnce(null); + mockSetPreference.mockResolvedValueOnce(undefined); + + // Simulate handleToggle when hasConsent=false: writeConsent() + await setPreference( + CONSENT_KEY, + JSON.stringify({ consented_at: new Date().toISOString(), version: 1 }) + ); + + expect(mockSetPreference).toHaveBeenCalledOnce(); + const [key, value] = mockSetPreference.mock.calls[0]; + expect(key).toBe(CONSENT_KEY); + const parsed = JSON.parse(value); + expect(parsed.version).toBe(1); + expect(typeof parsed.consented_at).toBe("string"); + }); + + it("re-grant does NOT call deletePreference", async () => { + mockSetPreference.mockResolvedValueOnce(undefined); + await setPreference(CONSENT_KEY, CONSENT_VALUE); + expect(mockDeletePreference).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Test: disabled when not premium +// --------------------------------------------------------------------------- + +describe("PriceFetchConsentToggle — premium guard", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("when not premium: useIsPremium returns false → button should be disabled", () => { + setPremium(false); + const isPremium = useIsPremium(); + expect(isPremium).toBe(false); + // Component renders with disabled={!isPremium} on the switch button. + const buttonDisabled = !isPremium; + expect(buttonDisabled).toBe(true); + }); + + it("when not premium: tooltip key is settings.privacy.priceFetchConsent.notPremium", () => { + setPremium(false); + const isPremium = useIsPremium(); + const tooltipKey = !isPremium + ? "settings.privacy.priceFetchConsent.notPremium" + : undefined; + expect(tooltipKey).toBe("settings.privacy.priceFetchConsent.notPremium"); + }); + + it("when premium: button is NOT disabled", () => { + setPremium(true); + const isPremium = useIsPremium(); + const buttonDisabled = !isPremium; + expect(buttonDisabled).toBe(false); + }); +}); diff --git a/src/components/settings/PriceFetchConsentToggle.tsx b/src/components/settings/PriceFetchConsentToggle.tsx new file mode 100644 index 0000000..1a953f2 --- /dev/null +++ b/src/components/settings/PriceFetchConsentToggle.tsx @@ -0,0 +1,173 @@ +// PriceFetchConsentToggle — Settings Privacy section toggle for price_fetching_consent. +// +// Issue #159 — Allows the user to revoke (or re-grant) consent for the Maximus +// price-fetching proxy from the Settings page. +// +// Behavior: +// - Reads current consent state on mount via getPreference(CONSENT_KEY) +// - If consent exists: shows toggle as "on" with a Revoke button +// - If no consent: shows toggle as "off" with a re-grant button +// - Revoking shows a confirmation dialog; on confirm, DELETEs the row entirely +// so that the next click on PriceFetchControl re-opens the consent modal +// - Disabled (with tooltip) when useIsPremium() === false + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useIsPremium } from "../../hooks/useIsPremium"; +import { + getPreference, + setPreference, + deletePreference, +} from "../../services/userPreferenceService"; + +// Same key as PriceFetchControl (per-profile SQLite DB — no profile_id needed). +const CONSENT_KEY = "price_fetching_consent"; + +/** + * Read the current price_fetching_consent preference. + * Returns the raw JSON string or null if not set. + */ +async function readConsent(): Promise { + try { + return await getPreference(CONSENT_KEY); + } catch { + return null; + } +} + +/** + * Write consent with the standard {consented_at, version: 1} shape. + * Matches the shape written by PriceFetchControl on accept. + */ +async function writeConsent(): Promise { + await setPreference( + CONSENT_KEY, + JSON.stringify({ consented_at: new Date().toISOString(), version: 1 }) + ); +} + +/** + * Delete the consent row entirely so that PriceFetchControl shows the modal again. + */ +async function revokeConsent(): Promise { + await deletePreference(CONSENT_KEY); +} + +export function PriceFetchConsentToggle() { + const { t } = useTranslation(); + const isPremium = useIsPremium(); + const [hasConsent, setHasConsent] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [loading, setLoading] = useState(true); + + // Load current consent state on mount. + useEffect(() => { + (async () => { + const value = await readConsent(); + setHasConsent(value !== null); + setLoading(false); + })(); + }, []); + + const handleToggle = () => { + if (!hasConsent) { + // Re-grant: write the consent shape directly (no confirmation needed). + writeConsent().then(() => setHasConsent(true)); + } else { + // Revoke: ask for confirmation first. + setShowConfirm(true); + } + }; + + const handleConfirmRevoke = async () => { + await revokeConsent(); + setHasConsent(false); + setShowConfirm(false); + }; + + const handleCancelRevoke = () => { + setShowConfirm(false); + }; + + // Return nothing while loading to avoid flash. + if (loading) return null; + + return ( +
+

+ {t("settings.privacy.title")} +

+ + {/* Price fetch consent row */} +
+
+

+ {t("settings.privacy.priceFetchConsent.label")} +

+

+ {t("settings.privacy.priceFetchConsent.description")} +

+
+ +
+ + {/* Confirmation dialog for revoke */} + {showConfirm && ( +
+

+ {t("settings.privacy.priceFetchConsent.confirmRevoke")} +

+
+ + +
+
+ )} +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e4851ea..0a6f2e6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -637,6 +637,7 @@ ] }, "privacy": { + "title": "Privacy", "priceFetchConsent": { "label": "Price fetching via Maximus", "description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 78937c2..2e3d121 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -637,6 +637,7 @@ ] }, "privacy": { + "title": "Confidentialité", "priceFetchConsent": { "label": "Récupération de prix via Maximus", "description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 7d3c43a..2507cd7 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -24,6 +24,7 @@ import AccountCard from "../components/settings/AccountCard"; import LogViewerCard from "../components/settings/LogViewerCard"; import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner"; import CategoriesCard from "../components/settings/CategoriesCard"; +import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle"; export default function SettingsPage() { const { t, i18n } = useTranslation(); @@ -302,6 +303,9 @@ export default function SettingsPage() { {/* Data management */} + {/* Privacy — price fetching consent (premium only) */} + + {/* Data safety notice */}
diff --git a/src/services/userPreferenceService.ts b/src/services/userPreferenceService.ts index a7b49b5..f8bf7bf 100644 --- a/src/services/userPreferenceService.ts +++ b/src/services/userPreferenceService.ts @@ -23,6 +23,14 @@ export async function setPreference( ); } +export async function deletePreference(key: string): Promise { + const db = await getDb(); + await db.execute( + "DELETE FROM user_preferences WHERE key = $1", + [key] + ); +} + export async function getImportFolder(): Promise { return getPreference("import_folder"); }