diff --git a/app/settings/account/page.tsx b/app/settings/account/page.tsx index 7b7cefec..dff3ba9e 100644 --- a/app/settings/account/page.tsx +++ b/app/settings/account/page.tsx @@ -27,6 +27,7 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { useConsentStore } from "@/app/state/consent" // ───────────────────────────────────────────────────────────── // Types @@ -47,7 +48,8 @@ export default function AccountSettingsPage() { // Privacy controls const [publicProfile, setPublicProfile] = useState(true) const [showActivity, setShowActivity] = useState(true) - const [allowAnalytics, setAllowAnalytics] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) + const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent) const [showLeaderboard, setShowLeaderboard] = useState(true) // Save state for live-region announcement @@ -288,8 +290,8 @@ export default function AccountSettingsPage() { id="allow-analytics" label="Allow usage analytics" description="Help improve Predictify by sharing anonymised interaction data. No wallet data is included." - checked={allowAnalytics} - onCheckedChange={setAllowAnalytics} + checked={analyticsConsent} + onCheckedChange={setAnalyticsConsent} /> diff --git a/app/settings/privacy/__tests__/privacy.a11y.test.tsx b/app/settings/privacy/__tests__/privacy.a11y.test.tsx index e586950b..32184fe1 100644 --- a/app/settings/privacy/__tests__/privacy.a11y.test.tsx +++ b/app/settings/privacy/__tests__/privacy.a11y.test.tsx @@ -1,9 +1,21 @@ import React from "react" -import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react" import userEvent from "@testing-library/user-event" import PrivacySettingsPage from "../page" +import { useConsentStore } from "@/app/state/consent" describe("Settings → Privacy page", () => { + beforeEach(() => { + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + it("renders the page heading and description", () => { render() @@ -114,8 +126,10 @@ describe("Settings → Privacy page", () => { it("each switch row displays an icon and a badge in the summary card", () => { render() - const summaryBadges = screen.getAllByText(/visible|hidden/i) - expect(summaryBadges.length).toBe(5) + // Anchored to the exact badge values so surrounding copy (e.g. "...information is + // visible to other users...") can never inflate the count. + const summaryBadges = screen.getAllByText(/^(visible|hidden)$/i) + expect(summaryBadges).toHaveLength(5) }) it("all switches have unique ids matching their labels", () => { @@ -129,4 +143,20 @@ describe("Settings → Privacy page", () => { expect(label).toBeInTheDocument() }) }) + + it("the analytics toggle is backed by the consent store (single source of truth)", () => { + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + render() + + const analytics = screen.getByRole("switch", { name: /allow usage analytics/i }) + fireEvent.click(analytics) + + expect(useConsentStore.getState().analyticsConsent).toBe(true) + expect(analytics).toBeChecked() + + fireEvent.click(analytics) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + expect(analytics).not.toBeChecked() + }) }) \ No newline at end of file diff --git a/app/settings/privacy/page.tsx b/app/settings/privacy/page.tsx index bb6d3d20..3694873d 100644 --- a/app/settings/privacy/page.tsx +++ b/app/settings/privacy/page.tsx @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { useConsentStore } from "@/app/state/consent" type SaveState = "idle" | "saving" | "saved" | "error" @@ -24,7 +25,8 @@ export default function PrivacySettingsPage() { const [showActivity, setShowActivity] = useState(true) const [showLeaderboard, setShowLeaderboard] = useState(true) const [showBalance, setShowBalance] = useState(true) - const [allowAnalytics, setAllowAnalytics] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) + const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent) const [saveState, setSaveState] = useState("idle") @@ -75,8 +77,8 @@ export default function PrivacySettingsPage() { icon: Eye, label: "Allow usage analytics", description: "Help improve Predictify by sharing anonymised interaction data. No wallet data is included.", - checked: allowAnalytics, - onChange: setAllowAnalytics, + checked: analyticsConsent, + onChange: setAnalyticsConsent, }, ] @@ -211,7 +213,7 @@ export default function PrivacySettingsPage() { setShowActivity(true) setShowLeaderboard(true) setShowBalance(true) - setAllowAnalytics(false) + setAnalyticsConsent(false) }} > Reset defaults diff --git a/app/state/__tests__/consent.test.ts b/app/state/__tests__/consent.test.ts new file mode 100644 index 00000000..c8207287 --- /dev/null +++ b/app/state/__tests__/consent.test.ts @@ -0,0 +1,127 @@ +/** + * consent.test.ts + * + * Unit tests for the useConsentStore (app/state/consent.ts). + * Covers: default state, explicit opt-in/opt-out, strict boolean coercion, + * persistence, and boundary/invalid inputs. + */ + +import { act } from "@testing-library/react" +import { + useConsentStore, + getAnalyticsConsent, + parseConsent, + CONSENT_STORAGE_KEY, +} from "../consent" + +/** Reset store to the optimistic default between tests. */ +function resetStore() { + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) +} + +describe("useConsentStore", () => { + beforeEach(() => { + resetStore() + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + // ── Initial state ────────────────────────────────────────────────────────── + + it("starts with telemetry consent disabled (optimistic default)", () => { + expect(useConsentStore.getState().analyticsConsent).toBe(false) + expect(getAnalyticsConsent()).toBe(false) + }) + + // ── setAnalyticsConsent ──────────────────────────────────────────────────── + + it("setAnalyticsConsent(true) opt-in is persisted", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + expect(getAnalyticsConsent()).toBe(true) + }) + + it("setAnalyticsConsent(false) opt-out is respected", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + act(() => useConsentStore.getState().setAnalyticsConsent(false)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + it("coerces non-boolean opt-in values to false (no telemetry without explicit consent)", () => { + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent("yes")) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent(1)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent(null)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + // ── toggleAnalyticsConsent ───────────────────────────────────────────────── + + it("toggleAnalyticsConsent flips state and returns the new value", () => { + let result1: boolean + act(() => { + result1 = useConsentStore.getState().toggleAnalyticsConsent() + }) + expect(result1!).toBe(true) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + + let result2: boolean + act(() => { + result2 = useConsentStore.getState().toggleAnalyticsConsent() + }) + expect(result2!).toBe(false) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + // ── persistence ──────────────────────────────────────────────────────────── + + it("persists opt-in consent to localStorage under the consent key", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + + const stored = localStorage.getItem(CONSENT_STORAGE_KEY) + expect(stored).toBeTruthy() + const parsed = JSON.parse(stored!) + expect(parsed.state.analyticsConsent).toBe(true) + }) + + it("persists opt-out so telemetry stays disabled across reloads", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + act(() => useConsentStore.getState().setAnalyticsConsent(false)) + + const stored = localStorage.getItem(CONSENT_STORAGE_KEY) + const parsed = JSON.parse(stored!) + expect(parsed.state.analyticsConsent).toBe(false) + }) + + // ── parseConsent boundary inputs ─────────────────────────────────────────── + + it("parseConsent accepts only the literal boolean true", () => { + expect(parseConsent(true)).toBe(true) + expect(parseConsent(false)).toBe(false) + }) + + it("parseConsent rejects every coercion-flavoured value", () => { + expect(parseConsent("true")).toBe(false) + expect(parseConsent("1")).toBe(false) + expect(parseConsent(1)).toBe(false) + expect(parseConsent(0)).toBe(false) + expect(parseConsent("")).toBe(false) + expect(parseConsent(null)).toBe(false) + expect(parseConsent(undefined)).toBe(false) + expect(parseConsent([])).toBe(false) + expect(parseConsent({})).toBe(false) + expect(parseConsent("TRUE")).toBe(false) + expect(parseConsent(1 as unknown)).toBe(false) + }) +}) \ No newline at end of file diff --git a/app/state/consent.ts b/app/state/consent.ts new file mode 100644 index 00000000..146bab9f --- /dev/null +++ b/app/state/consent.ts @@ -0,0 +1,99 @@ +/** + * consent.ts + * + * Client-side store for the user's usage-analytics consent preference. + * Gated in optimistic-default style: consent is `false` until the user + * explicitly opts in from the privacy settings page. All recommendation + * telemetry is suppressed while consent is `false`. + * + * Invariants + * ---------- + * - `analyticsConsent` is a strict boolean. Any invalid value written to + * localStorage (e.g. a corrupted payload) is coerced via `parseConsent` + * so it can never put the store into an inconsistent state. + * - Consent defaults to `false` ("no telemetry") and can only become `true` + * through an explicit user action (`setAnalyticsConsent`). + * - Reads and writes are failure-tolerant: a blocked or throwing + * localStorage never crashes the app — telemetry simply stays disabled. + */ + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export const CONSENT_STORAGE_KEY = "predictify-consent"; + +interface ConsentState { + /** Whether the user has opted in to usage analytics. */ + analyticsConsent: boolean; + /** Set the consent value. Call from an explicit opt-in/opt-out UI action. */ + setAnalyticsConsent: (consent: boolean) => void; + /** Toggle consent; returns the new value. */ + toggleAnalyticsConsent: () => boolean; +} + +/** + * Coerce an unknown persisted value into a strict boolean. Anything that is + * not exactly `true` maps to `false` so the optimistic-default invariant + * always holds, even against corrupted or malicious localStorage payloads. + */ +export function parseConsent(value: unknown): boolean { + return value === true; +} + +/** + * A no-op storage used on the server (or when localStorage is unavailable) + * so persistence never throws during SSR or privacy-restricted sessions. + */ +const noopStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; + +function createSafeStorage(): ReturnType { + if (typeof window === "undefined") { + return createJSONStorage(() => noopStorage); + } + try { + return createJSONStorage(() => window.localStorage); + } catch { + return createJSONStorage(() => noopStorage); + } +} + +export const useConsentStore = create()( + persist( + (set, get) => ({ + analyticsConsent: false, + + setAnalyticsConsent: (consent) => + set({ analyticsConsent: parseConsent(consent) }), + + toggleAnalyticsConsent: () => { + const next = !get().analyticsConsent; + set({ analyticsConsent: next }); + return next; + }, + }), + { + name: CONSENT_STORAGE_KEY, + storage: createSafeStorage(), + merge: (persisted, current) => ({ + ...current, + ...(persisted as Partial), + // Enforce the strict-boolean invariant on any persisted payload so a + // stale or corrupted value can never turn telemetry on without an + // explicit opt-in. + analyticsConsent: parseConsent( + (persisted as Partial | undefined)?.analyticsConsent + ), + }), + partialize: (state) => ({ analyticsConsent: state.analyticsConsent }), + } + ) +); + +/** Non-hook accessor for reads outside React (e.g. before render). */ +export function getAnalyticsConsent(): boolean { + return parseConsent(useConsentStore.getState().analyticsConsent); +} \ No newline at end of file diff --git a/components/dashboard/RecommendationsStrip.tsx b/components/dashboard/RecommendationsStrip.tsx index d5ad41d0..6a05b575 100644 --- a/components/dashboard/RecommendationsStrip.tsx +++ b/components/dashboard/RecommendationsStrip.tsx @@ -13,6 +13,11 @@ import { getTopCategories, type RecommendedMarket, } from "@/lib/recommendations" +import { + trackRecommendation, + trackRecommendationImpression, +} from "@/lib/telemetry" +import { useConsentStore } from "@/app/state/consent" import { mockActiveBets } from "@/lib/mock-data" import type { Bet, CategoryColor } from "@/lib/types" @@ -28,10 +33,19 @@ interface RecommendationsStripProps { * category chip — this is a lower-commitment discovery surface, not a bet * the user is tracking. */ -function RecommendationCard({ market }: { market: RecommendedMarket }) { +function RecommendationCard({ + market, + position, + onSelect, +}: { + market: RecommendedMarket + position: number + onSelect: (market: RecommendedMarket, position: number) => void +}) { return ( onSelect(market, position)} className={cn( "flex-shrink-0 snap-start rounded-xl border border-border/30 bg-card/20 p-4", "w-[calc(100vw-3rem)] max-w-[260px] sm:w-[260px]", @@ -108,10 +122,33 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom const scrollContainerRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) const categories = manualCategories ?? getTopCategories(bets) const markets = useMemo(() => getRecommendedMarkets(categories), [categories]) + // Consent-gated impression telemetry: fires once per rendered market set. + // When consent is revoked, no further events are emitted. + useEffect(() => { + if (!analyticsConsent) return + markets.forEach((market, index) => { + trackRecommendationImpression({ + marketId: market.id, + category: market.category, + position: index, + }) + }) + }, [markets, analyticsConsent]) + + const handleSelectMarket = (market: RecommendedMarket, position: number) => { + trackRecommendation({ + type: "recommendation_click", + marketId: market.id, + category: market.category, + position, + }) + } + const updateScrollState = () => { const el = scrollContainerRef.current if (!el) return @@ -184,8 +221,13 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom role="region" aria-label="Markets you might like carousel" > - {markets.map((market) => ( - + {markets.map((market, index) => ( + ))} diff --git a/components/dashboard/__tests__/RecommendationsStrip.test.tsx b/components/dashboard/__tests__/RecommendationsStrip.test.tsx index fa9d4085..8c10fd2a 100644 --- a/components/dashboard/__tests__/RecommendationsStrip.test.tsx +++ b/components/dashboard/__tests__/RecommendationsStrip.test.tsx @@ -1,6 +1,8 @@ import React from "react" -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen, fireEvent, act } from "@testing-library/react" import { RecommendationsStrip } from "../RecommendationsStrip" +import { setTelemetrySink } from "@/lib/telemetry" +import { useConsentStore } from "@/app/state/consent" import type { Bet } from "@/lib/types" function betIn(color: Bet["category"]["color"]): Pick { @@ -94,4 +96,92 @@ describe("RecommendationsStrip", () => { expect(screen.getByRole("region", { name: /carousel/i })).toHaveAttribute("tabIndex", "0") }) }) + + describe("consent-gated telemetry", () => { + const sink = jest.fn() + + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + }) + + afterEach(() => { + setTelemetrySink(null) + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + }) + + it("emits no telemetry while analytics consent is disabled", () => { + render() + expect(screen.getByRole("region", { name: /carousel/i })).toBeInTheDocument() + expect(sink).not.toHaveBeenCalled() + }) + + it("emits one impression per recommended market after consent is granted", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + render() + + const impressions = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + expect(impressions.length).toBeGreaterThan(0) + impressions.forEach(([event]) => { + expect(event).toMatchObject({ + type: "recommendation_impression", + category: "crypto", + }) + expect(typeof event.marketId).toBe("string") + expect(event.marketId.length).toBeGreaterThan(0) + expect(event.sessionId).toBeTruthy() + }) + }) + + it("emits a click event with position when a recommendation card is selected", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + render() + + const card = screen.getAllByRole("link")[0] + fireEvent.click(card) + + const clickEvents = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_click" + ) + expect(clickEvents.length).toBe(1) + expect(clickEvents[0][0]).toMatchObject({ + type: "recommendation_click", + category: "crypto", + position: 0, + }) + expect(clickEvents[0][0].marketId).toBeTruthy() + }) + + it("stops emitting impressions once consent is revoked", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + const { unmount } = render() + const impressionsBefore = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + expect(impressionsBefore.length).toBeGreaterThan(0) + + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + const impressionsAfter = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + // Revoking consent never adds events; the count is unchanged. + expect(impressionsAfter.length).toBe(impressionsBefore.length) + unmount() + }) + }) }) diff --git a/lib/__tests__/telemetry.test.ts b/lib/__tests__/telemetry.test.ts new file mode 100644 index 00000000..5e51a370 --- /dev/null +++ b/lib/__tests__/telemetry.test.ts @@ -0,0 +1,309 @@ +/** + * telemetry.test.ts + * + * Unit tests for lib/telemetry.ts — the consent-gated recommendation + * telemetry primitive. Covers consent gating, strict input validation, + * boundary clamping, duplicate suppression, sink failure recovery, and + * determinism of the emitted event shape. + */ + +import { act } from "@testing-library/react" +import { useConsentStore } from "@/app/state/consent" +import { + trackRecommendation, + trackRecommendationImpression, + sanitizeTelemetryEvent, + setTelemetrySink, + resetTelemetrySession, + MAX_MARKET_ID_LENGTH, + MAX_CATEGORY_LENGTH, +} from "../telemetry" + +const sink = jest.fn() + +function grantConsent(enabled: boolean) { + act(() => { + useConsentStore.setState({ analyticsConsent: enabled }) + }) +} + +describe("sanitizeTelemetryEvent", () => { + beforeEach(() => { + grantConsent(true) + resetTelemetrySession() + }) + + it("builds a well-formed event for valid input", () => { + const event = sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "rec-crypto-1", + category: "crypto", + position: 2, + }) + expect(event).not.toBeNull() + expect(event!.type).toBe("recommendation_click") + expect(event!.marketId).toBe("rec-crypto-1") + expect(event!.category).toBe("crypto") + expect(event!.position).toBe(2) + expect(typeof event!.timestamp).toBe("number") + expect(typeof event!.sessionId).toBe("string") + expect(event!.sessionId.length).toBeGreaterThan(0) + }) + + it("rejects non-object input", () => { + expect(sanitizeTelemetryEvent(null)).toBeNull() + expect(sanitizeTelemetryEvent(undefined)).toBeNull() + expect(sanitizeTelemetryEvent("event")).toBeNull() + expect(sanitizeTelemetryEvent(42)).toBeNull() + }) + + it("rejects unknown event types", () => { + expect( + sanitizeTelemetryEvent({ type: "page_view", marketId: "x" }) + ).toBeNull() + }) + + it("rejects missing, empty, or whitespace-only marketId", () => { + expect(sanitizeTelemetryEvent({ type: "recommendation_click" })).toBeNull() + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "" }) + ).toBeNull() + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: " " }) + ).toBeNull() + }) + + it("trims and bounds the marketId and category lengths", () => { + const event = sanitizeTelemetryEvent({ + type: "recommendation_impression", + marketId: ` ${"a".repeat(MAX_MARKET_ID_LENGTH + 50)} `, + category: ` ${"b".repeat(MAX_CATEGORY_LENGTH + 50)} `, + }) + expect(event!.marketId).toBe("a".repeat(MAX_MARKET_ID_LENGTH)) + expect(event!.category).toBe("b".repeat(MAX_CATEGORY_LENGTH)) + }) + + it("drops the category when it is missing or collapses to empty", () => { + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x" }) + ?.category + ).toBeUndefined() + expect( + sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + category: " ", + })?.category + ).toBeUndefined() + expect( + sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + category: 7, + })?.category + ).toBeUndefined() + }) + + it("clamps position into [0, MAX_POSITION] deterministically", () => { + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 0 })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 3.9 })! + .position + ).toBe(3) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: -5 })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 100000 })! + .position + ).toBe(999) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: NaN })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: Infinity })! + .position + ).toBe(0) + }) + + it("falls back to the current timestamp for invalid timestamps", () => { + const before = Date.now() + const event = sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + timestamp: -1, + }) + expect(event!.timestamp).toBeGreaterThanOrEqual(before) + }) +}) + +describe("trackRecommendation (consent gate)", () => { + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + resetTelemetrySession() + grantConsent(false) + }) + + afterEach(() => { + setTelemetrySink(null) + grantConsent(false) + }) + + it("emits nothing and returns false when consent is disabled", () => { + const result = trackRecommendation({ + type: "recommendation_click", + marketId: "rec-crypto-1", + }) + expect(result).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("emits a valid event and returns true when consent is granted", () => { + grantConsent(true) + const result = trackRecommendation({ + type: "recommendation_click", + marketId: "rec-crypto-1", + category: "crypto", + position: 4, + }) + expect(result).toBe(true) + expect(sink).toHaveBeenCalledTimes(1) + const dispatched = sink.mock.calls[0][0] + expect(dispatched.marketId).toBe("rec-crypto-1") + expect(dispatched.category).toBe("crypto") + expect(dispatched.position).toBe(4) + // No sensitive data is ever forwarded (there is no identity field). + expect(dispatched).not.toHaveProperty("wallet") + expect(dispatched).not.toHaveProperty("userId") + expect(dispatched).not.toHaveProperty("address") + }) + + it("does not dispatch invalid input even when consent is granted", () => { + grantConsent(true) + expect(trackRecommendation({ type: "recommendation_click" })).toBe(false) + expect(trackRecommendation({ type: "recommendation_click", marketId: "" })).toBe(false) + expect(trackRecommendation("garbage")).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("is deterministic for duplicate click input (clicks are not de-duplicated)", () => { + grantConsent(true) + const input = { type: "recommendation_click" as const, marketId: "rec-crypto-1" } + expect(trackRecommendation(input)).toBe(true) + expect(trackRecommendation(input)).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("swallows sink failures so callers are never broken", () => { + grantConsent(true) + const error = new Error("sink is down") + setTelemetrySink(() => { + throw error + }) + const spy = jest.spyOn(console, "error").mockImplementation(() => {}) + let result: boolean + expect(() => { + result = trackRecommendation({ type: "recommendation_click", marketId: "x" }) + }).not.toThrow() + expect(result!).toBe(true) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it("recovers on the next call after a transient sink failure", () => { + grantConsent(true) + let shouldThrow = true + setTelemetrySink(() => { + if (shouldThrow) throw new Error("boom") + }) + const spy = jest.spyOn(console, "error").mockImplementation(() => {}) + const input = { type: "recommendation_click" as const, marketId: "x" } + expect(trackRecommendation(input)).toBe(true) + shouldThrow = false + expect(trackRecommendation(input)).toBe(true) + spy.mockRestore() + }) +}) + +describe("trackRecommendationImpression (dedup)", () => { + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + resetTelemetrySession() + }) + + afterEach(() => { + setTelemetrySink(null) + grantConsent(false) + }) + + it("dispatches an impression for each distinct market in the session", () => { + grantConsent(true) + expect( + trackRecommendationImpression({ marketId: "rec-crypto-1", category: "crypto", position: 0 }) + ).toBe(true) + expect( + trackRecommendationImpression({ marketId: "rec-football-1", category: "football", position: 1 }) + ).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("suppresses a duplicate impression of the same market in the same session", () => { + grantConsent(true) + const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 } + expect(trackRecommendationImpression(input)).toBe(true) + expect(trackRecommendationImpression(input)).toBe(false) + expect(sink).toHaveBeenCalledTimes(1) + }) + + it("is suppressed entirely without consent and never reaches the sink", () => { + grantConsent(false) + expect( + trackRecommendationImpression({ marketId: "rec-crypto-1" }) + ).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("does not dedup invalid impressions (they are simply rejected)", () => { + grantConsent(true) + expect(trackRecommendationImpression({ marketId: "" })).toBe(false) + expect(trackRecommendationImpression({ marketId: " " })).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("a fresh session re-enables impressions for the same market", () => { + grantConsent(true) + const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 } + expect(trackRecommendationImpression(input)).toBe(true) + resetTelemetrySession() + expect(trackRecommendationImpression(input)).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("keeps the dedup set size-bounded (oldest evicted past the cap)", () => { + grantConsent(true) + const marketIds = Array.from({ length: 300 }, (_, i) => `market-${i}`) + marketIds.forEach((marketId, i) => { + trackRecommendationImpression({ marketId, position: i % 6 }) + }) + // Cap is 256 → the first 300-256=44 keys were evicted. + expect(trackRecommendationImpression({ marketId: "market-0" })).toBe(true) + expect(trackRecommendationImpression({ marketId: "market-299" })).toBe(false) + }) + + it("does not corrupt the store or sink state after a batch of impressions", () => { + grantConsent(true) + for (let i = 0; i < 50; i++) { + trackRecommendationImpression({ marketId: `m-${i % 5}`, position: i }) + } + // Only 5 unique markets are seen even after 50 calls. + expect(sink).toHaveBeenCalledTimes(5) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + }) +}) \ No newline at end of file diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 00000000..2d0577fe --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,206 @@ +/** + * telemetry.ts + * + * Recommendation telemetry, gated on the user's usage-analytics consent. + * + * Every event emitted from here is (1) validated and sanitized against a + * strict schema, (2) suppressed entirely while consent is `false`, and + * (3) forwarded to a pluggable sink that never receives sensitive data + * (no wallet addresses, no user identity — only market/category metadata). + * + * Invariants + * ---------- + * - No telemetry is emitted unless `getAnalyticsConsent()` is true. + * - Invalid input is rejected (returns `false`) and never reaches the sink. + * - Boundary inputs are clamped deterministically (see sanitize* helpers). + * - Sink failures are swallowed: a throwing sink never crashes the caller, + * and telemetry silently degrades to a no-op instead of failing the UI. + * - Impression duplicates within the same session are de-duplicated and the + * dedup set is size-bounded so memory cannot grow without limit. + */ + +import { getAnalyticsConsent } from "@/app/state/consent" + +export type RecommendationEventType = + | "recommendation_impression" + | "recommendation_click" + +export interface RecommendationTelemetryInput { + type: RecommendationEventType + marketId: string + category?: string + position?: number + timestamp?: number +} + +export interface RecommendationTelemetryEvent { + type: RecommendationEventType + marketId: string + category?: string + position: number + timestamp: number + /** Auto-generated, stable for the lifetime of the tab (page session). */ + sessionId: string +} + +export const MAX_MARKET_ID_LENGTH = 128 +export const MAX_CATEGORY_LENGTH = 32 +export const MAX_POSITION = 999 +export const IMPRESSION_DEDUP_CAP = 256 + +type TelemetrySink = (event: RecommendationTelemetryEvent) => void + +/** Default: dev-only console output; production emits nothing until wired to a real sink. */ +const defaultSink: TelemetrySink = (event) => { + if (process.env.NODE_ENV !== "production") { + console.debug("[telemetry]", event) + } +} + +let sink: TelemetrySink = defaultSink + +/** Replace the sink (used to point telemetry at a real backend or a test spy). */ +export function setTelemetrySink(next: TelemetrySink | null): void { + sink = next ?? defaultSink +} + +export function getTelemetrySink(): TelemetrySink { + return sink +} + +/** Non-sensitive module-scoped page-session id, stable per module lifetime. */ +let sessionId = createSessionId() + +function createSessionId(): string { + return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` +} + +/** Reset module-scoped state (used by tests between cases). */ +export function resetTelemetrySession(): void { + sessionId = createSessionId() + seenImpressions.clear() + impressionFifo = [] +} + +// ── sanitization ───────────────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function isEventType(value: unknown): value is RecommendationEventType { + return value === "recommendation_impression" || value === "recommendation_click" +} + +function sanitizeString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim().slice(0, maxLength) + return trimmed.length > 0 ? trimmed : undefined +} + +/** Position is clamped into [0, MAX_POSITION]; invalid input defaults to 0. */ +function sanitizePosition(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return 0 + } + return Math.min(Math.floor(value), MAX_POSITION) +} + +function sanitizeTimestamp(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return Date.now() + } + return Math.floor(value) +} + +/** + * Validate and coerce arbitrary input into a well-formed telemetry event. + * Returns `null` for invalid input so callers can deterministically treat + * malformed events as "not emitted". Never throws. + */ +export function sanitizeTelemetryEvent( + input: unknown +): RecommendationTelemetryEvent | null { + if (!isRecord(input)) return null + + const type = input.type + if (!isEventType(type)) return null + + const marketId = sanitizeString(input.marketId, MAX_MARKET_ID_LENGTH) + if (!marketId) return null + + const category = sanitizeString(input.category, MAX_CATEGORY_LENGTH) + + return { + type, + marketId, + category, + position: sanitizePosition(input.position), + timestamp: sanitizeTimestamp(input.timestamp), + sessionId, + } +} + +// ── impression de-duplication (bounded, FIFO) ─────────────────────────────── + +const seenImpressions = new Set() +let impressionFifo: string[] = [] + +function rememberImpression(key: string): void { + if (seenImpressions.size >= IMPRESSION_DEDUP_CAP) { + const oldest = impressionFifo.shift() + if (oldest !== undefined) seenImpressions.delete(oldest) + } + seenImpressions.add(key) + impressionFifo.push(key) +} + +/** + * Emit a recommendation telemetry event. + * + * - Returns `true` when the event was dispatched to the sink. + * - Returns `false` when consent is missing or the input is invalid — the + * caller can rely on this for deterministic behaviour in tests/retries. + * + * Sink errors are swallowed so telemetry can never break the caller. + */ +export function trackRecommendation( + input: RecommendationTelemetryInput | unknown +): boolean { + if (!getAnalyticsConsent()) return false + + const event = sanitizeTelemetryEvent(input) + if (!event) return false + + try { + sink(event) + } catch (error) { + // Failure-tolerant by design: an unreachable/slow/erroring sink must not + // corrupt UI state or crash the client. Log locally without payload data. + console.error("[telemetry] sink error", error instanceof Error ? error.message : "unknown") + } + return true +} + +/** + * Emit a recommendation *impression*, de-duplicated per market per session. + * A second impression of the same market in the same tab is suppressed so + * re-renders / retries cannot double-count. + */ +export function trackRecommendationImpression( + input: Omit | unknown +): boolean { + if (!getAnalyticsConsent()) return false + + const event = sanitizeTelemetryEvent({ + ...(isRecord(input) ? input : {}), + type: "recommendation_impression", + }) + if (!event) return false + + const key = `${event.sessionId}:${event.marketId}` + if (seenImpressions.has(key)) return false + + rememberImpression(key) + return trackRecommendation(event) +} \ No newline at end of file diff --git a/package.json b/package.json index a2f2a476..a7337d68 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "@creit.tech/stellar-wallets-kit": "^1.7.3", "@hookform/resolvers": "^3.9.1", - "canvas-confetti": "^1.9.3", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-aspect-ratio": "^1.1.1", @@ -54,6 +53,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-virtual": "^3.10.8", "autoprefixer": "^10.4.20", + "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -82,10 +82,10 @@ }, "devDependencies": { "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.1.4", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^16", + "@testing-library/user-event": "^14", "@types/canvas-confetti": "^1.6.4", - "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.8", "@types/node": "^22", "@types/qrcode": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32654fda..3fa37b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,13 +184,13 @@ importers: specifier: ^10.4.1 version: 10.4.1 '@testing-library/jest-dom': - specifier: ^6.1.4 + specifier: ^6 version: 6.9.1 '@testing-library/react': - specifier: ^16.0.0 + specifier: ^16 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@testing-library/user-event': - specifier: ^14.5.1 + specifier: ^14 version: 14.6.1(@testing-library/dom@10.4.1) '@types/canvas-confetti': specifier: ^1.6.4 @@ -9926,8 +9926,8 @@ snapshots: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -9946,7 +9946,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -9957,22 +9957,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9983,7 +9983,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3