mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(reaction): await toggle handler to prevent null payload on refresh
The toggle mutation was fire-and-forget, calling the handler without awaiting. This detached the write from the request lifecycle — when a pod drained, the write could be dropped before completion, causing the mutation to return null and reactions to not survive a page refresh. The handler manages slow work (rewards, notifications) internally and must be awaited for those effects and the data write to complete before the response is sent. Replace the fire-and-forget pattern with a direct await. Add regression test confirming the mutation returns the handler result and propagates handler errors to the caller.
This commit is contained in:
@@ -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<typeof ReactionController>()),
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user