Skip to content

Commit bf5b4f7

Browse files
escapedcatclaude
andauthored
refactor: one radius-search fetcher behind the three merchant-list reducers (#1171) (#1196)
* refactor(merchant-list): one radius-search fetcher behind the three list reducers #1171 fetchAndReplaceList, fetchCountOnly, and fetchEnrichedDetails each built the same /v4/places/search/ URL with their own copy of the transport policy and validation. A private searchPlacesInRadius now owns the URL shape, the 10s timeout, the array-shape validation, and the invalid-row drop; each reducer keeps its own row policy and failure loudness. The enrichment path gets the same explicit array-shape check as the other two — previously a non-array response there failed only via an incidental TypeError inside filterValidPlaces (same observable outcome: caught, warned, cache untouched); the explicit invariant plus a new test pin the behavior instead of relying on the accident. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(merchant-list): actionable message for non-array search responses #1171 Copilot follow-up: say what was expected and what actually arrived (typeof only — no payload dump) so the console warning is debuggable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(merchant-list): restore the console.warn spy even on failure #1171 qodo follow-up: the suite's beforeEach clears mocks but doesn't restore spied globals, so an assertion failing before mockRestore() would leave console.warn mocked for later tests. try/finally guarantees restoration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 26a9d68 commit bf5b4f7

2 files changed

Lines changed: 62 additions & 30 deletions

File tree

src/lib/merchantListStore.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,30 @@ describe("merchantListStore", () => {
674674

675675
await fetchPromise;
676676
});
677+
678+
it("should leave the cache untouched on a non-array response", async () => {
679+
// try/finally: a failing assertion must not leave console.warn
680+
// mocked for later tests (beforeEach clears, but doesn't restore)
681+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
682+
try {
683+
// Seed the cache through the list path
684+
const initialPlace = createMockPlace({ id: 1 });
685+
(api.get as Mock).mockResolvedValueOnce({ data: [initialPlace] });
686+
await merchantList.fetchAndReplaceList({ lat: 0, lon: 0 }, 10);
687+
688+
// An HTML error page served with 200 must not poison the cache
689+
(api.get as Mock).mockResolvedValueOnce({ data: "<html>oops</html>" });
690+
await merchantList.fetchEnrichedDetails({ lat: 0, lon: 0 }, 10);
691+
692+
const state = get(merchantList);
693+
expect(state.placeDetailsCache.has(1)).toBe(true);
694+
expect(state.placeDetailsCache.size).toBe(1);
695+
expect(state.isEnrichingDetails).toBe(false);
696+
expect(warnSpy).toHaveBeenCalled();
697+
} finally {
698+
warnSpy.mockRestore();
699+
}
700+
});
677701
});
678702

679703
describe("request cancellation", () => {

src/lib/merchantListStore.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,30 @@ function filterValidPlaces<T extends { id?: unknown }>(items: T[]): T[] {
128128
return items.filter((item): item is T => typeof item?.id === "number");
129129
}
130130

131+
// The one radius-search fetcher behind the three list reducers
132+
// (fetchAndReplaceList, fetchCountOnly, fetchEnrichedDetails). Owns the URL
133+
// shape, the 10s transport policy, the array-shape validation (the API can
134+
// return an HTML error page), and the dropping of rows without a numeric id.
135+
// What each reducer does with the rows — and how loudly it fails — stays
136+
// that reducer's own policy.
137+
async function searchPlacesInRadius<T extends { id?: unknown }>(
138+
center: { lat: number; lon: number },
139+
radiusKm: number,
140+
fields: string,
141+
signal: AbortSignal,
142+
): Promise<T[]> {
143+
const response = await api.get<T[]>(
144+
`${API_BASE}/v4/places/search/?lat=${center.lat}&lon=${center.lon}&radius_km=${radiusKm}&fields=${fields}`,
145+
{ timeout: 10000, signal },
146+
);
147+
if (!Array.isArray(response.data)) {
148+
throw new Error(
149+
`Radius search returned invalid data: expected an array, got ${typeof response.data}`,
150+
);
151+
}
152+
return filterValidPlaces(response.data);
153+
}
154+
131155
function createMerchantListStore() {
132156
const store = writable<MerchantListState>(initialState);
133157
const { subscribe, set, update } = store;
@@ -229,20 +253,13 @@ function createMerchantListStore() {
229253
update((state) => ({ ...state, isLoadingList: true }));
230254

231255
try {
232-
const fields = buildFieldsParam(PLACE_FIELD_SETS.LIST_ITEM);
233-
const response = await api.get<Place[]>(
234-
`${API_BASE}/v4/places/search/?lat=${center.lat}&lon=${center.lon}&radius_km=${radiusKm}&fields=${fields}`,
235-
{ timeout: 10000, signal: listAbortController.signal },
256+
const validPlaces = await searchPlacesInRadius<Place>(
257+
center,
258+
radiusKm,
259+
buildFieldsParam(PLACE_FIELD_SETS.LIST_ITEM),
260+
listAbortController.signal,
236261
);
237262

238-
// Validate response is an array (API may return HTML error page)
239-
if (!Array.isArray(response.data)) {
240-
throw new Error("API returned invalid data format");
241-
}
242-
243-
// Filter out invalid items missing required id field
244-
const validPlaces = filterValidPlaces(response.data);
245-
246263
// Build cache for enriched display (icons, addresses, etc.)
247264
const placeDetailsCache = new Map<number, Place>();
248265
validPlaces.forEach((place) => placeDetailsCache.set(place.id, place));
@@ -342,18 +359,9 @@ function createMerchantListStore() {
342359
try {
343360
// Typed to the payload actually requested — these rows are not
344361
// full Places and must not be handed to anything expecting one.
345-
const response = await api.get<Pick<Place, "id" | "verified_at">[]>(
346-
`${API_BASE}/v4/places/search/?lat=${center.lat}&lon=${center.lon}&radius_km=${radiusKm}&fields=${fields}`,
347-
{ timeout: 10000, signal: listAbortController.signal },
348-
);
349-
350-
// Validate response is an array (API may return HTML error page)
351-
if (!Array.isArray(response.data)) {
352-
throw new Error("API returned invalid data format");
353-
}
354-
355-
// Filter out invalid items missing required id field
356-
const validItems = filterValidPlaces(response.data);
362+
const validItems = await searchPlacesInRadius<
363+
Pick<Place, "id" | "verified_at">
364+
>(center, radiusKm, fields, listAbortController.signal);
357365
const recencyPlaces = filterPlacesByRecency(
358366
validItems,
359367
verifiedWithinYears,
@@ -397,14 +405,14 @@ function createMerchantListStore() {
397405
update((state) => ({ ...state, isEnrichingDetails: true }));
398406

399407
try {
400-
const fields = buildFieldsParam(PLACE_FIELD_SETS.LIST_ITEM);
401-
const response = await api.get<Place[]>(
402-
`${API_BASE}/v4/places/search/?lat=${center.lat}&lon=${center.lon}&radius_km=${radiusKm}&fields=${fields}`,
403-
{ timeout: 10000, signal: detailsAbortController.signal },
408+
const validPlaces = await searchPlacesInRadius<Place>(
409+
center,
410+
radiusKm,
411+
buildFieldsParam(PLACE_FIELD_SETS.LIST_ITEM),
412+
detailsAbortController.signal,
404413
);
405414

406-
// Filter out invalid items and merge into existing cache
407-
const validPlaces = filterValidPlaces(response.data);
415+
// Merge into existing cache
408416
update((state) => {
409417
const mergedCache = new Map(state.placeDetailsCache);
410418
validPlaces.forEach((place) => mergedCache.set(place.id, place));

0 commit comments

Comments
 (0)