diff --git a/__tests__/unit/services/reaction-count.test.ts b/__tests__/unit/services/reaction-count.test.ts new file mode 100644 index 000000000..e6a4aad9e --- /dev/null +++ b/__tests__/unit/services/reaction-count.test.ts @@ -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(_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 }); + }); +}); diff --git a/src/services/timeline/processors/reactions.ts b/src/services/timeline/processors/reactions.ts index d2310426f..7fa662190 100644 --- a/src/services/timeline/processors/reactions.ts +++ b/src/services/timeline/processors/reactions.ts @@ -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(_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)[countKey]; + return typeof value === 'number' ? value : 0; +} + async function toggleReaction( eventId: string, targetUserId: string, @@ -44,7 +68,7 @@ async function toggleReaction( return { success: true, active: false, - count: (data as Record)?.[countKey] || 0, + count: readCount(data, countKey), }; } catch (dbError) { logger.warn(`RPC ${removeRpc} not available, using fallback`, dbError, 'Timeline'); @@ -77,7 +101,7 @@ async function toggleReaction( return { success: true, active: true, - count: (data as Record)?.[countKey] || 0, + count: readCount(data, countKey), }; } catch (dbError) { logger.warn(`RPC ${addRpc} not available, using fallback`, dbError, 'Timeline');