diff --git a/src/server/routers/__tests__/reaction.router.awaited.test.ts b/src/server/routers/__tests__/reaction.router.awaited.test.ts new file mode 100644 index 0000000000..01a66f5779 --- /dev/null +++ b/src/server/routers/__tests__/reaction.router.awaited.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TokenScope } from '~/shared/constants/token-scope.constants'; +import type * as ReactionController from '~/server/controllers/reaction.controller'; + +/** + * `reaction.toggle` must AWAIT and RETURN the handler. A prior "fire-and-forget" wiring + * (call the handler, return void) detached the toggle write from the request — the mutation + * resolved to `undefined` (a null payload over superjson) and the write could be dropped on + * pod drain, so a reaction did not survive a refresh. See civitai#868kyuk3w. + */ + +const { toggleReactionHandler } = vi.hoisted(() => ({ + toggleReactionHandler: vi.fn(async () => 'created' as const), +})); + +vi.mock('~/server/controllers/reaction.controller', async (importOriginal) => ({ + ...(await importOriginal()), + toggleReactionHandler, +})); + +import { reactionRouter } from '../reaction.router'; + +const user = { + id: 5, + isModerator: false, + tier: 'free', + username: 'reactor', + onboarding: 0x1f, + muted: false, +}; + +function caller() { + return reactionRouter.createCaller({ + acceptableOrigin: true, + user, + apiKeyId: null, + tokenScope: TokenScope.Full, + req: { headers: {} } as never, + res: { setHeader: () => undefined } as never, + cache: { edgeTTL: 0 }, + features: {} as never, + track: undefined, + } as never); +} + +const input = { entityType: 'image', entityId: 1, reaction: 'Like' } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('reaction.toggle — awaited, not fire-and-forget', () => { + it('returns the handler result rather than undefined', async () => { + const result = await caller().toggle(input); + + // The regression returned `undefined` here (the reported null payload). + expect(result).toBe('created'); + expect(toggleReactionHandler).toHaveBeenCalledTimes(1); + const arg = toggleReactionHandler.mock.calls[0]?.[0] as { input?: typeof input }; + expect(arg?.input).toMatchObject({ entityType: 'image', entityId: 1, reaction: 'Like' }); + }); + + it('propagates a handler rejection instead of swallowing it', async () => { + toggleReactionHandler.mockRejectedValueOnce(new Error('write failed')); + + // Detaching the handler made a failed write invisible to the caller; awaiting surfaces it. + await expect(caller().toggle(input)).rejects.toThrow('write failed'); + }); +}); diff --git a/src/server/routers/reaction.router.ts b/src/server/routers/reaction.router.ts index 0c2361f574..264ed8c221 100644 --- a/src/server/routers/reaction.router.ts +++ b/src/server/routers/reaction.router.ts @@ -2,7 +2,6 @@ import { toggleReactionHandler } from './../controllers/reaction.controller'; import { toggleReactionSchema, reactionRateLimits } from './../schema/reaction.schema'; import { router, guardedProcedure } from '~/server/trpc'; import { rateLimit } from '~/server/middleware.trpc'; -import { handleLogError } from '~/server/utils/errorHandling'; import { TokenScope } from '~/shared/constants/token-scope.constants'; export const reactionRouter = router({ @@ -10,10 +9,8 @@ export const reactionRouter = router({ .meta({ requiredScope: TokenScope.SocialWrite }) .input(toggleReactionSchema) .use(rateLimit(reactionRateLimits)) - .mutation(({ ctx, input }) => { - // Fire-and-forget: frontend already does optimistic updates via Zustand - // and ignores the response value entirely (no onSuccess/onError callbacks). - // Auth + rate limit middleware have already run at this point. - toggleReactionHandler({ ctx, input }).catch(handleLogError); - }), + // Must stay awaited: the handler backgrounds its slow work (rewards, notifications) + // internally. Detaching the whole handler dropped the toggle write on pod drain and + // returned a null payload. + .mutation(toggleReactionHandler), });