Skip to content
Merged
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
68 changes: 68 additions & 0 deletions __tests__/unit/services/reaction-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* The count a reaction reports back must survive the shape PostgREST sends.
*
* `like_timeline_event` and its three siblings are declared
* `RETURNS TABLE(<name>_count integer)`, and a set-returning function comes
* back as an ARRAY of rows: `[{ like_count: 1 }]`. The client indexed that
* array as though it were the row, so the lookup was always `undefined` and the
* `|| 0` fallback turned every successful like into a count of zero.
*
* It hid well. The filled heart comes from a boolean the client sets itself, so
* liking something looked right while the number beside it stayed blank — and
* for the eight months the RPC was also raising 42703, there was no successful
* response to notice it in.
*
* Pinned against both shapes: the array PostgREST actually sends, and the bare
* object it would send if one of these were ever rewritten to return a scalar.
*/

import { toggleLike } from '@/services/timeline/processors/reactions';

const rpc = jest.fn();
const from = jest.fn();

jest.mock('@/services/timeline/processors/social-shared', () => ({
db: {
rpc: (...args: unknown[]) => rpc(...args),
from: (...args: unknown[]) => from(...args),
},
getCurrentUserId: async () => 'user-1',
}));

/** No existing reaction row, so toggling adds one and calls the add RPC. */
function noExistingReaction() {
from.mockReturnValue({
select: () => ({
eq: () => ({ eq: () => ({ single: async () => ({ data: null }) }) }),
}),
});
}

describe('reaction counts', () => {
beforeEach(() => {
jest.clearAllMocks();
noExistingReaction();
});

it('reads the count out of the array PostgREST returns for RETURNS TABLE', async () => {
rpc.mockResolvedValue({ data: [{ like_count: 7 }], error: null });

await expect(toggleLike('event-1', 'user-1')).resolves.toEqual({
success: true,
liked: true,
likeCount: 7,
});
});

it('also accepts a bare object, so a scalar rewrite cannot silently zero it', async () => {
rpc.mockResolvedValue({ data: { like_count: 3 }, error: null });

await expect(toggleLike('event-1', 'user-1')).resolves.toMatchObject({ likeCount: 3 });
});

it('reports zero when the response carries no count at all', async () => {
rpc.mockResolvedValue({ data: [], error: null });

await expect(toggleLike('event-1', 'user-1')).resolves.toMatchObject({ likeCount: 0 });
});
});
28 changes: 26 additions & 2 deletions src/services/timeline/processors/reactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ interface ReactionConfig {
countKey: string;
}

/**
* Read the new count out of an RPC response.
*
* All four of these functions are `RETURNS TABLE(<name>_count integer)`, and
* PostgREST renders a set-returning function as an ARRAY of rows — `[{
* like_count: 1 }]`. This used to index the array as if it were the row, so the
* lookup was always undefined and the `|| 0` turned every successful reaction
* into a count of zero.
*
* The effect was subtle enough to survive the whole time the RPCs were also
* raising 42703: liking something persisted correctly and then rendered as if
* nobody had, because the button state comes from `active` (a literal) while
* the number comes from here. Accepts either shape, so it cannot break again if
* one of these is ever rewritten to return a scalar.
*/
function readCount(data: unknown, countKey: string): number {
const row = Array.isArray(data) ? data[0] : data;
if (!row || typeof row !== 'object') {
return 0;
}
const value = (row as Record<string, unknown>)[countKey];
return typeof value === 'number' ? value : 0;
}

async function toggleReaction(
eventId: string,
targetUserId: string,
Expand Down Expand Up @@ -44,7 +68,7 @@ async function toggleReaction(
return {
success: true,
active: false,
count: (data as Record<string, number>)?.[countKey] || 0,
count: readCount(data, countKey),
};
} catch (dbError) {
logger.warn(`RPC ${removeRpc} not available, using fallback`, dbError, 'Timeline');
Expand Down Expand Up @@ -77,7 +101,7 @@ async function toggleReaction(
return {
success: true,
active: true,
count: (data as Record<string, number>)?.[countKey] || 0,
count: readCount(data, countKey),
};
} catch (dbError) {
logger.warn(`RPC ${addRpc} not available, using fallback`, dbError, 'Timeline');
Expand Down
Loading