Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions frontend/src/lib/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,84 @@ describe('API Client', () => {
const second = await api.getFeaturedMarkets();
expect(second).toEqual([{ id: 1, title: 'Resolved Market' }]);
});

it('does not invalidate cache when mutation returns success:false (#1162)', async () => {
// Prime a statistics cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 10 }),
});
await api.getStatistics();
expect(global.fetch).toHaveBeenCalledTimes(1);

// newsletterSubscribe returns 200 with success:false (business-logic failure).
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ success: false, message: 'Email already subscribed' }),
});
await api.newsletterSubscribe({ email: 'existing@example.com' });
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache must NOT be invalidated — next getStatistics should return cached value.
const cachedStats = await api.getStatistics();
expect(cachedStats).toEqual({ total_markets: 10 });
// fetch should not have been called again (still 2 calls total).
expect(global.fetch).toHaveBeenCalledTimes(2);
});

it('invalidates cache when mutation returns success:true (#1162)', async () => {
// Prime a statistics cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 10 }),
});
await api.getStatistics();
expect(global.fetch).toHaveBeenCalledTimes(1);

// newsletterSubscribe succeeds.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ success: true, message: 'Subscribed!' }),
});
await api.newsletterSubscribe({ email: 'new@example.com' });
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache MUST be invalidated — next getStatistics should hit the network.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ total_markets: 11 }),
});
const freshStats = await api.getStatistics();
expect(freshStats).toEqual({ total_markets: 11 });
expect(global.fetch).toHaveBeenCalledTimes(3);
});

it('invalidates cache for mutations that do not use success envelope (#1162)', async () => {
// Prime a markets cache entry.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify([{ id: 1, title: 'Market' }]),
});
await api.getFeaturedMarkets();
expect(global.fetch).toHaveBeenCalledTimes(1);

// resolveMarket returns { invalidated_keys: N } (no success field).
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify({ invalidated_keys: 2 }),
});
await api.resolveMarket(1);
expect(global.fetch).toHaveBeenCalledTimes(2);

// Cache MUST be invalidated — next getFeaturedMarkets hits the network.
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
text: async () => JSON.stringify([{ id: 1, title: 'Resolved' }]),
});
const freshMarkets = await api.getFeaturedMarkets();
expect(freshMarkets).toEqual([{ id: 1, title: 'Resolved' }]);
expect(global.fetch).toHaveBeenCalledTimes(3);
});
});

describe('DELETE requests', () => {
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,21 @@ async function request<T>(

// On mutations, invalidate only the affected resource tags instead of
// the entire cache. Fall back to a full clear for untagged mutations.
//
// Guard: some endpoints return { success: false, message: '...' } with a
// 200 status to signal business-logic failure (e.g. newsletterSubscribe).
// Only bust the cache when the mutation actually succeeded — i.e. when the
// response has no `success` field (non-envelope endpoints) or when
// `success` is explicitly `true`.
if (method === "POST" || method === "DELETE") {
if (options.cacheTags?.length) {
apiCache.invalidateByTags(options.cacheTags);
} else {
apiCache.invalidateByPattern('.*');
const bodyObj = (typeof data === 'object' && data !== null) ? data as Record<string, unknown> : null;
const succeeded = bodyObj === null || !('success' in bodyObj) || bodyObj['success'] === true;
if (succeeded) {
if (options.cacheTags?.length) {
apiCache.invalidateByTags(options.cacheTags);
} else {
apiCache.invalidateByPattern('.*');
}
}
}

Expand Down