From e4218c992d503658dfd633f5eff5396f2718b1b6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 5 May 2026 12:04:00 +0200 Subject: [PATCH 1/4] feat(frontend/recs): auto-refresh on page open when stale; drop freshness indicator + Refresh button (closes #284) - Add `STALE_THRESHOLD_MS = 24h` constant at module scope - Add `triggerAutoRefreshIfStale()` helper: checks freshness API and fires `refreshRecommendationsAPI()` as fire-and-forget when cache is null or older than 24h; shows in-flight / success / failure toasts - Wire the helper into `loadRecommendations()` replacing the old `renderFreshness('recommendations-freshness', loadRecommendations)` call - Remove `
` DOM node from index.html (dashboard-freshness section remains; freshness.ts is kept for it) - Import `getRecommendationsFreshness` and `refreshRecommendations` from `./api/recommendations` directly; drop the unused `renderFreshness` import - Add 5 new tests in `describe('auto-refresh on page open (#284)')`: cold cache, stale cache, fresh cache, cold+error, refresh failure - Mock `../toast` in the test file; reset freshness mock to "fresh" in beforeEach so existing tests are unaffected by the new toast calls --- .../src/__tests__/recommendations.test.ts | 130 +++++++++++++++++- frontend/src/index.html | 1 - frontend/src/recommendations.ts | 89 +++++++++++- 3 files changed, 212 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 1abe0edf..762366ab 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -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,105 @@ 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: [], + }); + } + + 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', + }), + ); + }); + }); + 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

-