diff --git a/package-lock.json b/package-lock.json index c73c93f4..692b590a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,14 @@ "version": "1.0.0", "dependencies": { "@anthropic-ai/claude-code": "^2.1.44", + "@anthropic-ai/sdk": "^0.78.0", "@sentry/node": "^10.40.0", "discord.js": "^14.25.1", "dotenv": "^17.3.1", "express": "^5.2.1", "ioredis": "^5.9.3", "jsonwebtoken": "^9.0.3", - "mem0ai": "^2.2.2", + "mem0ai": "^2.2.3", "node-pg-migrate": "^8.0.4", "pg": "^8.18.0", "winston": "^3.19.0", @@ -30,7 +31,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=20.11.0" + "node": ">=22.0.0" } }, "node_modules/@anthropic-ai/claude-code": { @@ -56,6 +57,26 @@ "@img/sharp-win32-x64": "^0.34.2" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.78.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", + "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -92,6 +113,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -3938,6 +3968,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -5317,6 +5360,12 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", diff --git a/tests/modules/events.coverage.test.js b/tests/modules/events.coverage.test.js new file mode 100644 index 00000000..18d93edf --- /dev/null +++ b/tests/modules/events.coverage.test.js @@ -0,0 +1,511 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +vi.mock('../../src/modules/afkHandler.js', () => ({ + handleAfkMentions: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/rateLimit.js', () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ limited: false }), +})); + +vi.mock('../../src/modules/linkFilter.js', () => ({ + checkLinks: vi.fn().mockResolvedValue({ blocked: false }), +})); + +vi.mock('../../src/modules/engagement.js', () => ({ + trackMessage: vi.fn().mockResolvedValue(undefined), + trackReaction: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/reputation.js', () => ({ + handleXpGain: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/spam.js', () => ({ + isSpam: vi.fn().mockReturnValue(false), + sendSpamAlert: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/triage.js', () => ({ + accumulateMessage: vi.fn().mockResolvedValue(undefined), + evaluateNow: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/welcome.js', () => ({ + recordCommunityActivity: vi.fn(), + sendWelcomeMessage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/utils/errors.js', () => ({ + getUserFriendlyMessage: vi.fn().mockReturnValue('friendly error'), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeReply: vi.fn((target, payload) => target.reply?.(payload)), + safeEditReply: vi.fn((target, payload) => target.editReply?.(payload)), + safeSend: vi.fn((target, payload) => target.send?.(payload)), +})); + +vi.mock('../../src/modules/reviewHandler.js', () => ({ + handleReviewClaim: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/commands/showcase.js', () => ({ + handleShowcaseUpvote: vi.fn().mockResolvedValue(undefined), + handleShowcaseModalSubmit: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/challengeScheduler.js', () => ({ + handleSolveButton: vi.fn().mockResolvedValue(undefined), + handleHintButton: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/modules/starboard.js', () => ({ + handleReactionAdd: vi.fn().mockResolvedValue(undefined), + handleReactionRemove: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../../src/commands/showcase.js'; +import { getPool } from '../../src/db.js'; +import { warn } from '../../src/logger.js'; +import { handleHintButton, handleSolveButton } from '../../src/modules/challengeScheduler.js'; +import { getConfig } from '../../src/modules/config.js'; +import { + registerChallengeButtonHandler, + registerErrorHandlers, + registerMessageCreateHandler, + registerReactionHandlers, + registerReadyHandler, + registerReviewClaimHandler, + registerShowcaseButtonHandler, + registerShowcaseModalHandler, +} from '../../src/modules/events.js'; +import { checkLinks } from '../../src/modules/linkFilter.js'; +import { checkRateLimit } from '../../src/modules/rateLimit.js'; +import { handleReviewClaim } from '../../src/modules/reviewHandler.js'; +import { handleReactionAdd, handleReactionRemove } from '../../src/modules/starboard.js'; +import { accumulateMessage, evaluateNow } from '../../src/modules/triage.js'; +import { recordCommunityActivity } from '../../src/modules/welcome.js'; +import { safeEditReply, safeReply } from '../../src/utils/safeSend.js'; + +function makeInteraction(overrides = {}) { + return { + isButton: () => true, + isModalSubmit: () => false, + customId: 'id', + guildId: 'guild-1', + replied: false, + deferred: false, + user: { id: 'u1' }, + reply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('events coverage follow-up', () => { + beforeEach(() => { + vi.clearAllMocks(); + getConfig.mockReturnValue({ + moderation: { enabled: true }, + ai: { enabled: true, channels: [] }, + review: { enabled: true }, + starboard: { enabled: true }, + }); + getPool.mockReturnValue({ query: vi.fn() }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('covers ready-handler model and starboard branches', () => { + const once = vi.fn(); + const client = { once, user: { tag: 'Bot#0001' }, guilds: { cache: { size: 2 } } }; + + registerReadyHandler( + client, + { + ai: { enabled: true }, + triage: { model: 'claude-sonnet-custom' }, + starboard: { enabled: true, channelId: 'sb-1', threshold: 5 }, + }, + null, + ); + + const cb = once.mock.calls[0][1]; + cb(); + + expect(once).toHaveBeenCalledWith('clientReady', expect.any(Function)); + }); + + it('covers messageCreate branches for moderation and ai disabled', async () => { + const handlers = new Map(); + const client = { + user: { id: 'bot-id' }, + on: (event, fn) => handlers.set(event, fn), + }; + + getConfig.mockReturnValue({ + moderation: { enabled: false }, + ai: { enabled: false }, + }); + + registerMessageCreateHandler(client, {}, null); + const handler = handlers.get('messageCreate'); + + const message = { + author: { id: 'u1', bot: false }, + guild: { id: 'g1' }, + content: 'hello', + channel: { id: 'c1', sendTyping: vi.fn().mockResolvedValue(undefined) }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + reply: vi.fn().mockResolvedValue(undefined), + }; + + await handler(message); + + expect(checkRateLimit).not.toHaveBeenCalled(); + expect(checkLinks).not.toHaveBeenCalled(); + expect(evaluateNow).not.toHaveBeenCalled(); + expect(accumulateMessage).not.toHaveBeenCalled(); + expect(recordCommunityActivity).toHaveBeenCalledWith(message, { + moderation: { enabled: false }, + ai: { enabled: false }, + }); + }); + + it('returns early when rate-limit says limited', async () => { + checkRateLimit.mockResolvedValueOnce({ limited: true }); + + const handlers = new Map(); + const client = { user: { id: 'bot-id' }, on: (event, fn) => handlers.set(event, fn) }; + registerMessageCreateHandler(client, {}, null); + const handler = handlers.get('messageCreate'); + + await handler({ + author: { id: 'u1', bot: false }, + guild: { id: 'g1' }, + content: 'hello', + channel: { id: 'c1', sendTyping: vi.fn().mockResolvedValue(undefined) }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + reply: vi.fn().mockResolvedValue(undefined), + }); + + expect(checkRateLimit).toHaveBeenCalled(); + expect(checkLinks).not.toHaveBeenCalled(); + }); + + it('returns early when link-filter says blocked', async () => { + checkRateLimit.mockResolvedValueOnce({ limited: false }); + checkLinks.mockResolvedValueOnce({ blocked: true }); + + const handlers = new Map(); + const client = { user: { id: 'bot-id' }, on: (event, fn) => handlers.set(event, fn) }; + registerMessageCreateHandler(client, {}, null); + const handler = handlers.get('messageCreate'); + + await handler({ + author: { id: 'u1', bot: false }, + guild: { id: 'g1' }, + content: 'hello', + channel: { id: 'c1', sendTyping: vi.fn().mockResolvedValue(undefined) }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: null }, + reference: null, + reply: vi.fn().mockResolvedValue(undefined), + }); + + expect(checkLinks).toHaveBeenCalled(); + expect(recordCommunityActivity).not.toHaveBeenCalled(); + }); + + it('covers reply-detection fetch branch and channels fallback', async () => { + getConfig.mockReturnValue({ + moderation: { enabled: true }, + ai: { enabled: true }, // intentionally no channels key for || [] branch + }); + + const handlers = new Map(); + const client = { user: { id: 'bot-id' }, on: (event, fn) => handlers.set(event, fn) }; + registerMessageCreateHandler(client, {}, null); + const handler = handlers.get('messageCreate'); + + const fetch = vi.fn().mockResolvedValue({ author: { id: 'bot-id' } }); + const message = { + author: { id: 'u1', bot: false }, + guild: { id: 'g1' }, + content: 'replying', + channel: { + id: 'c1', + sendTyping: vi.fn().mockResolvedValue(undefined), + isThread: vi.fn().mockReturnValue(false), + messages: { fetch }, + }, + mentions: { has: vi.fn().mockReturnValue(false), repliedUser: { id: 'someone-else' } }, + reference: { messageId: 'm1' }, + reply: vi.fn().mockResolvedValue(undefined), + }; + + await handler(message); + + expect(fetch).toHaveBeenCalledWith('m1'); + expect(evaluateNow).toHaveBeenCalledWith('c1', expect.any(Object), client, null); + }); + + it('covers reaction handler partial and no-guild branches', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerReactionHandlers(client, {}); + + const addHandler = handlers.get('messageReactionAdd'); + + const partialReaction = { + message: { + id: 'm1', + partial: true, + fetch: vi.fn().mockResolvedValue(undefined), + guild: { id: 'g1' }, + }, + }; + await addHandler(partialReaction, { id: 'u1', bot: false }); + expect(handleReactionAdd).toHaveBeenCalled(); + + const noGuildReaction = { + message: { + id: 'm2', + partial: false, + guild: null, + }, + }; + await addHandler(noGuildReaction, { id: 'u2', bot: false }); + expect(handleReactionAdd).toHaveBeenCalledTimes(1); + }); + + it('covers reaction remove bot/partial/starboard-disabled branches', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerReactionHandlers(client, {}); + + const removeHandler = handlers.get('messageReactionRemove'); + + const reaction = { + message: { + id: 'm1', + partial: false, + guild: { id: 'g1' }, + }, + }; + + await removeHandler(reaction, { id: 'bot', bot: true }); + expect(handleReactionRemove).not.toHaveBeenCalled(); + + const partialReaction = { + message: { + id: 'm2', + partial: true, + fetch: vi.fn().mockResolvedValue(undefined), + guild: { id: 'g1' }, + }, + }; + getConfig.mockReturnValueOnce({ starboard: { enabled: false } }); + await removeHandler(partialReaction, { id: 'u1', bot: false }); + expect(handleReactionRemove).not.toHaveBeenCalled(); + }); + + it('covers review claim handler paths', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerReviewClaimHandler(client); + + const handler = handlers.get('interactionCreate'); + + await handler(makeInteraction({ isButton: () => false })); + await handler(makeInteraction({ customId: 'not_review_claim' })); + + getConfig.mockReturnValueOnce({ review: { enabled: false } }); + await handler(makeInteraction({ customId: 'review_claim_123' })); + expect(handleReviewClaim).not.toHaveBeenCalled(); + + await handler(makeInteraction({ customId: 'review_claim_123' })); + expect(handleReviewClaim).toHaveBeenCalledTimes(1); + + handleReviewClaim.mockRejectedValueOnce(new Error('boom')); + const failing = makeInteraction({ customId: 'review_claim_123' }); + await handler(failing); + expect(safeReply).toHaveBeenCalledWith(failing, expect.objectContaining({ ephemeral: true })); + + handleReviewClaim.mockRejectedValueOnce(new Error('boom')); + const alreadyDone = makeInteraction({ customId: 'review_claim_123', replied: true }); + await handler(alreadyDone); + expect(safeReply).not.toHaveBeenCalledWith(alreadyDone, expect.anything()); + }); + + it('covers showcase upvote handler branches', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerShowcaseButtonHandler(client); + + const handler = handlers.get('interactionCreate'); + + await handler(makeInteraction({ isButton: () => false })); + await handler(makeInteraction({ customId: 'wrong_prefix' })); + + getPool.mockImplementationOnce(() => { + throw new Error('no db'); + }); + const noDb = makeInteraction({ customId: 'showcase_upvote_1' }); + await handler(noDb); + expect(safeReply).toHaveBeenCalledWith( + noDb, + expect.objectContaining({ content: '❌ Database is not available.' }), + ); + + const ok = makeInteraction({ customId: 'showcase_upvote_1' }); + await handler(ok); + expect(handleShowcaseUpvote).toHaveBeenCalledWith(ok, expect.any(Object)); + + handleShowcaseUpvote.mockRejectedValueOnce(new Error('upvote fail')); + const failing = makeInteraction({ customId: 'showcase_upvote_2' }); + await handler(failing); + expect(safeReply).toHaveBeenCalledWith(failing, expect.objectContaining({ ephemeral: true })); + + handleShowcaseUpvote.mockRejectedValueOnce(new Error('upvote fail')); + const alreadyDone = makeInteraction({ customId: 'showcase_upvote_3', deferred: true }); + await handler(alreadyDone); + expect(safeReply).not.toHaveBeenCalledWith(alreadyDone, expect.anything()); + }); + + it('covers showcase modal handler branches', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerShowcaseModalHandler(client); + + const handler = handlers.get('interactionCreate'); + + await handler(makeInteraction({ isModalSubmit: () => false })); + await handler( + makeInteraction({ + isModalSubmit: () => true, + customId: 'other_modal', + }), + ); + + getPool.mockImplementationOnce(() => { + throw new Error('db missing'); + }); + const noDb = makeInteraction({ + isModalSubmit: () => true, + customId: 'showcase_submit_modal', + }); + await handler(noDb); + expect(safeReply).toHaveBeenCalledWith( + noDb, + expect.objectContaining({ content: '❌ Database is not available.' }), + ); + + const ok = makeInteraction({ + isModalSubmit: () => true, + customId: 'showcase_submit_modal', + }); + await handler(ok); + expect(handleShowcaseModalSubmit).toHaveBeenCalledWith(ok, expect.any(Object)); + + handleShowcaseModalSubmit.mockRejectedValueOnce(new Error('submit failed')); + const notDeferred = makeInteraction({ + isModalSubmit: () => true, + customId: 'showcase_submit_modal', + deferred: false, + }); + await handler(notDeferred); + expect(safeReply).toHaveBeenCalledWith( + notDeferred, + expect.objectContaining({ content: '❌ Something went wrong.' }), + ); + + handleShowcaseModalSubmit.mockRejectedValueOnce(new Error('submit failed')); + const deferred = makeInteraction({ + isModalSubmit: () => true, + customId: 'showcase_submit_modal', + deferred: true, + }); + await handler(deferred); + expect(safeEditReply).toHaveBeenCalledWith( + deferred, + expect.objectContaining({ content: '❌ Something went wrong.' }), + ); + }); + + it('covers challenge button handler branches', async () => { + const handlers = new Map(); + const client = { on: (event, fn) => handlers.set(event, fn) }; + registerChallengeButtonHandler(client); + + const handler = handlers.get('interactionCreate'); + + await handler(makeInteraction({ isButton: () => false })); + await handler(makeInteraction({ customId: 'something_else' })); + + await handler(makeInteraction({ customId: 'challenge_solve_not-a-number' })); + expect(warn).toHaveBeenCalledWith( + 'Invalid challenge button customId', + expect.objectContaining({ customId: 'challenge_solve_not-a-number' }), + ); + + const solve = makeInteraction({ customId: 'challenge_solve_3' }); + await handler(solve); + expect(handleSolveButton).toHaveBeenCalledWith(solve, 3); + + const hint = makeInteraction({ customId: 'challenge_hint_2' }); + await handler(hint); + expect(handleHintButton).toHaveBeenCalledWith(hint, 2); + + handleSolveButton.mockRejectedValueOnce(new Error('solve fail')); + const failing = makeInteraction({ customId: 'challenge_solve_7' }); + await handler(failing); + expect(safeReply).toHaveBeenCalledWith(failing, expect.objectContaining({ ephemeral: true })); + + handleHintButton.mockRejectedValueOnce(new Error('hint fail')); + const alreadyDone = makeInteraction({ customId: 'challenge_hint_9', replied: true }); + await handler(alreadyDone); + expect(safeReply).not.toHaveBeenCalledWith(alreadyDone, expect.anything()); + }); + + it('covers registerErrorHandlers fallback error-string branch', () => { + const on = vi.fn(); + const client = { on }; + const processOnSpy = vi.spyOn(process, 'on').mockImplementation(() => process); + + registerErrorHandlers(client); + + const unhandled = processOnSpy.mock.calls.find((c) => c[0] === 'unhandledRejection')?.[1]; + expect(unhandled).toBeTypeOf('function'); + + // no .message on purpose, so String(err) branch executes + unhandled(undefined); + + // second call should skip process.on registration + registerErrorHandlers(client); + + const unhandledRegCalls = processOnSpy.mock.calls.filter((c) => c[0] === 'unhandledRejection'); + expect(unhandledRegCalls).toHaveLength(1); + + processOnSpy.mockRestore(); + }); +}); diff --git a/tests/sentry.init.test.js b/tests/sentry.init.test.js new file mode 100644 index 00000000..6869fa4d --- /dev/null +++ b/tests/sentry.init.test.js @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { initMock, captureExceptionMock, captureMessageMock } = vi.hoisted(() => ({ + initMock: vi.fn(), + captureExceptionMock: vi.fn(), + captureMessageMock: vi.fn(), +})); + +vi.mock('@sentry/node', () => ({ + init: initMock, + captureException: captureExceptionMock, + captureMessage: captureMessageMock, +})); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('sentry init coverage', () => { + it('does not initialize when DSN is missing', async () => { + vi.resetModules(); + vi.stubEnv('SENTRY_DSN', ''); + + const mod = await import('../src/sentry.js'); + + expect(mod.sentryEnabled).toBe(false); + expect(initMock).not.toHaveBeenCalled(); + expect(mod.Sentry.captureException).toBe(captureExceptionMock); + expect(mod.Sentry.captureMessage).toBe(captureMessageMock); + }); + + it('initializes with explicit environment and supports beforeSend filtering/scrubbing', async () => { + vi.resetModules(); + vi.stubEnv('SENTRY_DSN', 'https://public@example.ingest.sentry.io/123'); + vi.stubEnv('SENTRY_ENVIRONMENT', 'staging'); + vi.stubEnv('SENTRY_TRACES_RATE', '0.25'); + + const mod = await import('../src/sentry.js'); + + expect(mod.sentryEnabled).toBe(true); + expect(initMock).toHaveBeenCalledTimes(1); + + const initArgs = initMock.mock.calls[0][0]; + expect(initArgs.environment).toBe('staging'); + expect(initArgs.tracesSampleRate).toBe(0.25); + expect(initArgs.autoSessionTracking).toBe(true); + expect(initArgs.initialScope).toEqual({ + tags: { service: 'volvox-bot' }, + }); + + const aborted = { + exception: { values: [{ value: 'AbortError: request cancelled' }] }, + }; + expect(initArgs.beforeSend(aborted)).toBeNull(); + + const alsoAborted = { + exception: { values: [{ value: 'The operation was aborted due to timeout' }] }, + }; + expect(initArgs.beforeSend(alsoAborted)).toBeNull(); + + const event = { + extra: { + ip: '127.0.0.1', + password: 'super-secret', + token: 'token-value', + authorization: 'Bearer abc', + safe: 'ok', + }, + }; + const scrubbed = initArgs.beforeSend(event); + expect(scrubbed).toBe(event); + expect(scrubbed.extra).toEqual({ safe: 'ok' }); + + const noExtra = { message: 'keep me' }; + expect(initArgs.beforeSend(noExtra)).toBe(noExtra); + }); + + it('falls back to NODE_ENV and default trace rate for invalid values', async () => { + vi.resetModules(); + vi.stubEnv('SENTRY_DSN', 'https://public@example.ingest.sentry.io/123'); + vi.stubEnv('SENTRY_ENVIRONMENT', ''); + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('SENTRY_TRACES_RATE', 'not-a-number'); + + await import('../src/sentry.js'); + + const initArgs = initMock.mock.calls[0][0]; + expect(initArgs.environment).toBe('development'); + expect(initArgs.tracesSampleRate).toBe(0.1); + }); + + it('falls back to production when neither SENTRY_ENVIRONMENT nor NODE_ENV are set', async () => { + vi.resetModules(); + vi.stubEnv('SENTRY_DSN', 'https://public@example.ingest.sentry.io/123'); + vi.stubEnv('SENTRY_ENVIRONMENT', ''); + vi.stubEnv('NODE_ENV', ''); + + await import('../src/sentry.js'); + + const initArgs = initMock.mock.calls[0][0]; + expect(initArgs.environment).toBe('production'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 3262d513..3a92cacd 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -12,7 +12,7 @@ export default defineConfig({ exclude: ['src/deploy-commands.js'], thresholds: { statements: 80, - branches: 83, + branches: 85, functions: 80, lines: 80, }, diff --git a/web/src/hooks/use-moderation-cases.ts b/web/src/hooks/use-moderation-cases.ts index f54a97fb..a3caec16 100644 --- a/web/src/hooks/use-moderation-cases.ts +++ b/web/src/hooks/use-moderation-cases.ts @@ -90,7 +90,7 @@ export function useModerationCases({ useEffect(() => { if (!guildId) return; void fetchCases(guildId, page, sortDesc, actionFilter, userSearch); - }, [guildId, page, actionFilter, userSearch, fetchCases]); + }, [guildId, page, actionFilter, userSearch, fetchCases, sortDesc]); // Client-side sort toggle useEffect(() => { @@ -98,7 +98,7 @@ export function useModerationCases({ if (!prev) return prev; return { ...prev, cases: [...prev.cases].reverse() }; }); - }, [sortDesc]); + }, []); useEffect(() => { return () => abortRef.current?.abort();