Compare commits
No commits in common. "877aff8d6dfa812625e3f7c1c1fe5887557118cb" and "d140ed938a19b8322e8a4eb39bf64ceeeec445ea" have entirely different histories.
877aff8d6d
...
d140ed938a
6 changed files with 0 additions and 399 deletions
|
|
@ -1,212 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
// 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<string | null> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
await deletePreference(CONSENT_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PriceFetchConsentToggle() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isPremium = useIsPremium();
|
|
||||||
const [hasConsent, setHasConsent] = useState<boolean>(false);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(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 (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("settings.privacy.title")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Price fetch consent row */}
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
{t("settings.privacy.priceFetchConsent.label")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
|
|
||||||
{t("settings.privacy.priceFetchConsent.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={hasConsent}
|
|
||||||
disabled={!isPremium}
|
|
||||||
title={
|
|
||||||
!isPremium
|
|
||||||
? t("settings.privacy.priceFetchConsent.notPremium")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={handleToggle}
|
|
||||||
className={[
|
|
||||||
"shrink-0 relative inline-flex items-center h-6 w-11 rounded-full border-2 border-transparent",
|
|
||||||
"transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]",
|
|
||||||
hasConsent
|
|
||||||
? "bg-[var(--primary)]"
|
|
||||||
: "bg-[var(--muted)]",
|
|
||||||
!isPremium
|
|
||||||
? "opacity-40 cursor-not-allowed"
|
|
||||||
: "cursor-pointer",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform duration-200",
|
|
||||||
hasConsent ? "translate-x-5" : "translate-x-0",
|
|
||||||
].join(" ")}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirmation dialog for revoke */}
|
|
||||||
{showConfirm && (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("settings.privacy.priceFetchConsent.revokeButton")}
|
|
||||||
className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 space-y-3"
|
|
||||||
>
|
|
||||||
<p className="text-sm text-[var(--foreground)]">
|
|
||||||
{t("settings.privacy.priceFetchConsent.confirmRevoke")}
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancelRevoke}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] transition-colors"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirmRevoke}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{t("settings.privacy.priceFetchConsent.revokeButton")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -637,7 +637,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy",
|
|
||||||
"priceFetchConsent": {
|
"priceFetchConsent": {
|
||||||
"label": "Price fetching via Maximus",
|
"label": "Price fetching via Maximus",
|
||||||
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",
|
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",
|
||||||
|
|
|
||||||
|
|
@ -637,7 +637,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Confidentialité",
|
|
||||||
"priceFetchConsent": {
|
"priceFetchConsent": {
|
||||||
"label": "Récupération de prix via Maximus",
|
"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.",
|
"description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import AccountCard from "../components/settings/AccountCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
||||||
import CategoriesCard from "../components/settings/CategoriesCard";
|
import CategoriesCard from "../components/settings/CategoriesCard";
|
||||||
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -303,9 +302,6 @@ export default function SettingsPage() {
|
||||||
{/* Data management */}
|
{/* Data management */}
|
||||||
<DataManagementCard />
|
<DataManagementCard />
|
||||||
|
|
||||||
{/* Privacy — price fetching consent (premium only) */}
|
|
||||||
<PriceFetchConsentToggle />
|
|
||||||
|
|
||||||
{/* Data safety notice */}
|
{/* Data safety notice */}
|
||||||
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,6 @@ export async function setPreference(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePreference(key: string): Promise<void> {
|
|
||||||
const db = await getDb();
|
|
||||||
await db.execute(
|
|
||||||
"DELETE FROM user_preferences WHERE key = $1",
|
|
||||||
[key]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImportFolder(): Promise<string | null> {
|
export async function getImportFolder(): Promise<string | null> {
|
||||||
return getPreference("import_folder");
|
return getPreference("import_folder");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue