fix(liveuamap): make enrichment resilient and non-blocking

This commit is contained in:
Shadowbroker
2026-08-18 15:52:46 -06:00
parent 2e7a0038b5
commit ed97e9e4bc
12 changed files with 1217 additions and 156 deletions
@@ -0,0 +1,92 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useLiveUamapScraperOptIn } from '@/hooks/useLiveUamapScraperOptIn';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('useLiveUamapScraperOptIn', () => {
it('never blocks Global Incidents when the operator declines LiveUAMap', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(
JSON.stringify({
platform_requires_opt_in: true,
ui_opted_in: false,
ui_choice_recorded: false,
scraper_enabled: false,
env_override: null,
api_configured: false,
enrichment_enabled: false,
provider_mode: 'gdelt-only',
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
platform_requires_opt_in: true,
ui_opted_in: false,
ui_choice_recorded: true,
scraper_enabled: false,
env_override: null,
api_configured: false,
enrichment_enabled: false,
provider_mode: 'gdelt-only',
}),
{ status: 200 },
),
);
const confirmMock = vi.spyOn(window, 'confirm').mockReturnValue(false);
const { result } = renderHook(() => useLiveUamapScraperOptIn());
await waitFor(() => expect(result.current.status).not.toBeNull());
let blocked = true;
act(() => {
blocked = result.current.needsConsentBeforeEnable('global_incidents', true);
});
expect(blocked).toBe(false);
expect(confirmMock).toHaveBeenCalledOnce();
await waitFor(
() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
},
{ timeout: 1000 },
);
const [, options] = fetchMock.mock.calls[1];
expect(options?.method).toBe('POST');
expect(options?.body).toBe(JSON.stringify({ opted_in: false }));
});
it('does not prompt when a supported API provider is configured', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(
JSON.stringify({
platform_requires_opt_in: true,
ui_opted_in: false,
ui_choice_recorded: false,
scraper_enabled: false,
env_override: null,
api_configured: true,
enrichment_enabled: true,
provider_mode: 'api',
}),
{ status: 200 },
),
);
const confirmMock = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { result } = renderHook(() => useLiveUamapScraperOptIn());
await waitFor(() => expect(result.current.status?.api_configured).toBe(true));
expect(result.current.needsConsentBeforeEnable('global_incidents', true)).toBe(false);
expect(confirmMock).not.toHaveBeenCalled();
});
});
+47 -12
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { API_BASE } from '@/lib/api';
export type LiveUamapScraperStatus = {
@@ -8,10 +8,15 @@ export type LiveUamapScraperStatus = {
ui_opted_in: boolean;
scraper_enabled: boolean;
env_override: 'on' | 'off' | null;
ui_choice_recorded?: boolean;
api_configured?: boolean;
enrichment_enabled?: boolean;
provider_mode?: 'api' | 'scraper' | 'gdelt-only';
};
export function useLiveUamapScraperOptIn(enabled = true) {
const [status, setStatus] = useState<LiveUamapScraperStatus | null>(null);
const choicePromptedRef = useRef(false);
const refreshStatus = useCallback(async () => {
try {
@@ -29,20 +34,11 @@ export function useLiveUamapScraperOptIn(enabled = true) {
void refreshStatus();
}, [enabled, refreshStatus]);
const needsConsentBeforeEnable = useCallback(
(layerId: string, turningOn: boolean) =>
layerId === 'global_incidents' &&
turningOn &&
Boolean(status?.platform_requires_opt_in) &&
!status?.ui_opted_in,
[status],
);
const confirmOptIn = useCallback(async () => {
const setOptIn = useCallback(async (optedIn: boolean) => {
const res = await fetch(`${API_BASE}/api/liveuamap/scraper-opt-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opted_in: true }),
body: JSON.stringify({ opted_in: optedIn }),
});
if (!res.ok) {
throw new Error(`LiveUAMap opt-in failed (${res.status})`);
@@ -52,6 +48,45 @@ export function useLiveUamapScraperOptIn(enabled = true) {
return body;
}, []);
const needsConsentBeforeEnable = useCallback(
(layerId: string, turningOn: boolean) => {
if (layerId !== 'global_incidents' || !turningOn) return false;
const choiceRecorded = status?.ui_choice_recorded ?? status?.ui_opted_in ?? false;
const shouldOfferBrowserEnrichment =
Boolean(status?.platform_requires_opt_in) &&
!choiceRecorded &&
!status?.api_configured &&
status?.env_override === null;
if (
shouldOfferBrowserEnrichment &&
!choicePromptedRef.current &&
typeof window !== 'undefined'
) {
choicePromptedRef.current = true;
const optedIn = window.confirm(
"Global Incidents will turn on with GDELT either way. Add optional LiveUAMap pins too? LiveUAMap will see this server's IP. OK enables LiveUAMap; Cancel keeps GDELT-only incidents.",
);
// Do not make the Global Incidents toggle wait on an optional provider.
// Give the layer-state update a moment to reach the backend before the
// opt-in endpoint opportunistically starts an immediate refresh.
window.setTimeout(() => {
void setOptIn(optedIn).catch((error) => {
console.warn('LiveUAMap preference update failed:', error);
});
}, 250);
}
// LiveUAMap is enrichment, never a prerequisite for Global Incidents.
return false;
},
[setOptIn, status],
);
const confirmOptIn = useCallback(() => setOptIn(true), [setOptIn]);
return {
status,
refreshStatus,