diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 1abe0edf..19fda95d 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1,7 +1,7 @@ /** * Recommendations module tests */ -import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, clearRecommendationDetailCache, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells } from '../recommendations'; +import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, clearRecommendationDetailCache, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight } from '../recommendations'; // Mock the api module jest.mock('../api', () => ({ @@ -25,6 +25,13 @@ jest.mock('../api', () => ({ // Default resolution returns a benign empty payload so tests that // merely open + close the drawer (and don't care about the detail // fetch) don't trip on an undefined-promise return. +// +// Default freshness: fresh (1h ago) so pre-#284 tests that call +// loadRecommendations() don't inadvertently trigger auto-refresh and +// fire extra showToast() calls that would break existing assertions. +// NOTE: ONE_HOUR_AGO can't be used here because jest.mock() factory +// functions are hoisted before variable declarations. The date is +// computed inline; the beforeEach block resets it via the mock handle. jest.mock('../api/recommendations', () => ({ getRecommendationDetail: jest.fn().mockResolvedValue({ id: 'rec-default', @@ -32,7 +39,19 @@ jest.mock('../api/recommendations', () => ({ confidence_bucket: 'low', provenance_note: '', }), - getRecommendationsFreshness: jest.fn().mockResolvedValue({ last_collected_at: null, last_collection_error: null }), + getRecommendationsFreshness: jest.fn().mockResolvedValue({ + last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + last_collection_error: null, + }), + refreshRecommendations: jest.fn().mockResolvedValue({}), +})); + +// Mock showToast so auto-refresh (#284) tests can assert on toast calls +// without touching the DOM. Returns a dismiss handle per the ToastHandle +// interface so callers that invoke handle.dismiss() don't crash. +const mockShowToast = jest.fn<{ dismiss: () => void }, [unknown]>(() => ({ dismiss: jest.fn() })); +jest.mock('../toast', () => ({ + showToast: (opts: unknown) => mockShowToast(opts), })); // Mock state module @@ -68,6 +87,7 @@ jest.mock('../utils', () => ({ import * as api from '../api'; import * as state from '../state'; +import * as recsApi from '../api/recommendations'; describe('Recommendations Module', () => { beforeEach(() => { @@ -85,6 +105,15 @@ describe('Recommendations Module', () => { jest.clearAllMocks(); jest.useFakeTimers(); window.alert = jest.fn(); + + // Default freshness for pre-#284 tests: fresh (1h ago) → auto-refresh + // does NOT fire, so existing tests are unaffected by the new toast calls. + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + last_collection_error: null, + }); + (recsApi.refreshRecommendations as jest.Mock).mockResolvedValue({}); + mockShowToast.mockReturnValue({ dismiss: jest.fn() }); }); afterEach(() => { @@ -797,6 +826,160 @@ describe('Recommendations Module', () => { }); }); + describe('auto-refresh on page open (#284)', () => { + // Helper: set up the minimal DOM + api mock that loadRecommendations needs. + function mockGetRecs() { + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [], + regions: [], + }); + } + + beforeEach(() => { + // Reset the dedup guard so each test starts with no refresh in flight. + resetAutoRefreshInFlight(); + }); + + test('cold cache (null last_collected_at) — refresh fires + in-flight toast shown', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: null, + last_collection_error: null, + }); + + await loadRecommendations(); + // Flush microtasks so the async refreshRecommendations call runs. + await Promise.resolve(); + + expect(recsApi.refreshRecommendations).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Refreshing recommendations…', kind: 'info' }), + ); + }); + + test('stale cache (>24h ago) — refresh fires + in-flight toast shown', async () => { + mockGetRecs(); + const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: twentyFiveHoursAgo, + last_collection_error: null, + }); + + await loadRecommendations(); + await Promise.resolve(); + + expect(recsApi.refreshRecommendations).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Refreshing recommendations…', kind: 'info' }), + ); + }); + + test('fresh cache (<24h ago) — refresh does NOT fire + no toast', async () => { + mockGetRecs(); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: oneHourAgo, + last_collection_error: null, + }); + + await loadRecommendations(); + await Promise.resolve(); + + expect(recsApi.refreshRecommendations).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + test('cold cache + collection error — error toast surfaces the message', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: null, + last_collection_error: 'Provider X returned 403 Forbidden', + }); + + await loadRecommendations(); + await Promise.resolve(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Provider X returned 403 Forbidden'), + kind: 'error', + }), + ); + }); + + test('refresh failure — error toast shown with message', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: null, + last_collection_error: null, + }); + (recsApi.refreshRecommendations as jest.Mock).mockRejectedValue(new Error('Network timeout')); + + await loadRecommendations(); + // Two microtask ticks: one for the freshness call, one for the refresh rejection. + await Promise.resolve(); + await Promise.resolve(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Recommendations refresh failed: Network timeout', + kind: 'error', + }), + ); + }); + + test('dedup — concurrent stale loads fire refreshRecommendations only once', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: null, + last_collection_error: null, + }); + + // A pending promise that won't resolve until we call resolveRefresh(). + let resolveRefresh!: () => void; + const pendingRefresh = new Promise((r) => { resolveRefresh = r; }); + (recsApi.refreshRecommendations as jest.Mock).mockReturnValue(pendingRefresh); + + // Fire two stale loads without awaiting the first refresh to settle. + const first = loadRecommendations(); + const second = loadRecommendations(); + + await first; + await second; + // Flush the microtasks that kick off triggerAutoRefreshIfStale. + await Promise.resolve(); + await Promise.resolve(); + + // Only one API call despite two stale-load triggers. + expect(recsApi.refreshRecommendations).toHaveBeenCalledTimes(1); + + // Clean up: resolve the pending promise so no timers hang after the test. + resolveRefresh(); + await pendingRefresh; + }); + + test('persistent in-flight toast — shown with timeout: null so it stays until settled', async () => { + mockGetRecs(); + (recsApi.getRecommendationsFreshness as jest.Mock).mockResolvedValue({ + last_collected_at: null, + last_collection_error: null, + }); + + await loadRecommendations(); + await Promise.resolve(); + + // Find the in-flight "Refreshing…" toast call and confirm it has no + // auto-dismiss timeout so it outlives long-running refreshes. + const inFlightCall = mockShowToast.mock.calls.find( + ([opts]) => (opts as { message: string }).message === 'Refreshing recommendations…', + ); + expect(inFlightCall).toBeDefined(); + const inFlightOpts = inFlightCall![0] as { timeout?: number | null }; + expect(inFlightOpts.timeout).toBeNull(); + }); + }); + describe('openPurchaseModal', () => { // Issue #111 (iii): openPurchaseModal is now async (it pre-fetches // per-account service overrides to seed each row's Payment default). diff --git a/frontend/src/index.html b/frontend/src/index.html index 0074ea2f..901aefd8 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -88,7 +88,6 @@

Upcoming Scheduled Purchases

-