diff --git a/src/app/actions/recommendations.test.ts b/src/app/actions/recommendations.test.ts new file mode 100644 index 0000000..2774b7f --- /dev/null +++ b/src/app/actions/recommendations.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mocks = vi.hoisted(() => { + return { + mockGetUser: vi.fn(), + mockServiceFrom: vi.fn(), + mockCacheGet: vi.fn(), + mockCacheSet: vi.fn(), + mockCacheDel: vi.fn(), + mockRateLimit: vi.fn(), + mockTryGetDb: vi.fn(), + mockSql: vi.fn((strings, ...values) => ({ strings, values })), + }; +}); + +vi.mock('@/lib/supabase/server', () => ({ + getServerSupabase: vi.fn(() => ({ + auth: { getUser: mocks.mockGetUser }, + })), +})); + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: vi.fn(() => ({ + from: mocks.mockServiceFrom, + })), +})); + +vi.mock('@/lib/cache', () => ({ + cacheGet: mocks.mockCacheGet, + cacheSet: mocks.mockCacheSet, + cacheDel: mocks.mockCacheDel, +})); + +vi.mock('@/lib/rate-limit', () => ({ + rateLimit: mocks.mockRateLimit, +})); + +vi.mock('@/lib/db/client', () => ({ + tryGetDb: mocks.mockTryGetDb, + schema: { + recommendations: { + id: 'r.id', + issueId: 'r.issueId', + difficulty: 'r.diff', + xpReward: 'r.xp', + status: 'r.status', + userId: 'r.userId', + recommendedAt: 'r.recAt', + }, + issues: { + id: 'i.id', + repoFullName: 'i.repo', + githubIssueNumber: 'i.num', + title: 'i.title', + url: 'i.url', + }, + }, +})); + +vi.mock('drizzle-orm', () => ({ + sql: mocks.mockSql, +})); + +import { + getRecommendations, + claimRecommendation, + skipRecommendation, + linkPrToRec, + unlinkPrFromRec, + unclaimRecommendation, +} from './recommendations'; +import { getServerSupabase } from '@/lib/supabase/server'; +import { getServiceSupabase } from '@/lib/supabase/service'; + +const mockDbLimit = vi.fn(); +const mockDbOrderBy = vi.fn(() => ({ limit: mockDbLimit })); +const mockDbWhere = vi.fn(() => ({ orderBy: mockDbOrderBy })); +const mockDbInnerJoin = vi.fn(() => ({ where: mockDbWhere })); +const mockDbFrom = vi.fn(() => ({ innerJoin: mockDbInnerJoin })); +const mockDbSelect = vi.fn(() => ({ from: mockDbFrom })); + +const mockDb = { select: mockDbSelect }; + +const createMockChain = (chainResult: unknown, singleResult: unknown = null) => { + const chain: Record = { + select: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + single: vi.fn(() => Promise.resolve(singleResult)), + maybeSingle: vi.fn(() => Promise.resolve(singleResult)), + then: function (resolve: (value: unknown) => void, reject: (reason?: unknown) => void) { + if (chainResult instanceof Error) { + return Promise.reject(chainResult).catch(reject); + } + return Promise.resolve(chainResult).then(resolve); + }, + }; + return chain; +}; + +describe('Recommendations Server Actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'test-user-id' } } }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockServiceFrom.mockImplementation(() => createMockChain(null, null)); + }); + + describe('getRecommendations', () => { + it('returns cached result when cache is warm', async () => { + const cached = [{ id: 1, title: 'Cached Rec' }]; + mocks.mockCacheGet.mockResolvedValueOnce(cached); + + const result = await getRecommendations(); + + expect(result).toEqual({ ok: true, data: cached }); + expect(mocks.mockCacheGet).toHaveBeenCalledWith('recs:test-user-id'); + expect(mocks.mockTryGetDb).not.toHaveBeenCalled(); + }); + + it('queries DB and caches when cache is cold', async () => { + mocks.mockCacheGet.mockResolvedValueOnce(null); + mocks.mockTryGetDb.mockReturnValueOnce(mockDb); + + const dbRows = [ + { + id: 1, + issueId: 10, + difficulty: 'E', + xpReward: 100, + status: 'open', + repoFullName: 'test/repo', + issueNumber: 42, + title: 'Fix issue', + url: 'https://github.com/test/repo/issues/42', + }, + ]; + mockDbLimit.mockResolvedValueOnce(dbRows); + + const result = await getRecommendations(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(1); + expect(result.data[0]?.title).toBe('Fix issue'); + } + expect(mocks.mockCacheSet).toHaveBeenCalledWith( + 'recs:test-user-id', + expect.any(Array), + 60 * 60, + ); + }); + + it('returns empty array when DB is not configured', async () => { + mocks.mockCacheGet.mockResolvedValueOnce(null); + mocks.mockTryGetDb.mockReturnValueOnce(null); + + const result = await getRecommendations(); + + expect(result).toEqual({ ok: true, data: [] }); + }); + + it('returns not_configured error if auth is not configured', async () => { + vi.mocked(getServerSupabase).mockReturnValueOnce( + null as unknown as ReturnType, + ); + const result = await getRecommendations(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_configured'); + }); + + it('returns not_authenticated error if user is not signed in', async () => { + mocks.mockGetUser.mockResolvedValueOnce({ data: { user: null } }); + const result = await getRecommendations(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_authenticated'); + }); + + it('returns rate_limited error if limit exceeded', async () => { + mocks.mockRateLimit.mockResolvedValueOnce({ ok: false }); + const result = await getRecommendations(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('rate_limited'); + }); + }); + + describe('claimRecommendation', () => { + it('updates status to claimed and sets claimed_at, invalidating cache', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce(createMockChain({ count: 0 })) // count claims + .mockReturnValueOnce(createMockChain(null, { data: { id: 1 }, error: null })) // update + .mockReturnValueOnce(createMockChain({})); // insert activity_log + + const result = await claimRecommendation(1); + + expect(result).toEqual({ ok: true, data: { id: 1 } }); + expect(mocks.mockCacheDel).toHaveBeenCalledWith('recs:test-user-id'); + }); + + it('returns already_claimed error if status is not open', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce(createMockChain({ count: 0 })) // count claims + .mockReturnValueOnce(createMockChain(null, { data: null, error: null })); // update returns null row + + const result = await claimRecommendation(1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('already_claimed'); + } + }); + + it('returns claim_limit error if user has 3 or more claims', async () => { + mocks.mockServiceFrom.mockReturnValueOnce(createMockChain({ count: 3 })); // count claims + + const result = await claimRecommendation(1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('claim_limit'); + } + }); + + it('returns not_configured error if service role missing', async () => { + vi.mocked(getServiceSupabase).mockReturnValueOnce( + null as unknown as ReturnType, + ); + const result = await claimRecommendation(1); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_configured'); + }); + + it('returns persist_failed error if update fails', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce(createMockChain({ count: 0 })) // count claims + .mockReturnValueOnce(createMockChain(null, { data: null, error: new Error('DB Error') })); + + const result = await claimRecommendation(1); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('persist_failed'); + }); + }); + + describe('skipRecommendation', () => { + it('sets status to reassigned and returns a replacement rec', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce( + createMockChain(null, { data: { id: 1, difficulty: 'E', issue_id: 10 }, error: null }), + ) // update rec + .mockReturnValueOnce(createMockChain({ data: [{ issue_id: 10 }] })) // select seen + .mockReturnValueOnce( + createMockChain({ + data: [ + { + id: 11, + difficulty: 'E', + xp_reward: 100, + repo_full_name: 'a/b', + github_issue_number: 2, + title: 'T', + url: 'http', + }, + ], + }), + ) // select pool + .mockReturnValueOnce(createMockChain(null, { data: { id: 2 }, error: null })); // insert replacement + + const result = await skipRecommendation(1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.id).toBe(1); + expect(result.data.replacement?.id).toBe(2); + } + expect(mocks.mockCacheDel).toHaveBeenCalledWith('recs:test-user-id'); + }); + + it('returns null replacement when pool is exhausted', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce( + createMockChain(null, { data: { id: 1, difficulty: 'E', issue_id: 10 }, error: null }), + ) // update rec + .mockReturnValueOnce(createMockChain({ data: [{ issue_id: 10 }] })) // select seen + .mockReturnValueOnce(createMockChain({ data: [] })) // select pool E + .mockReturnValueOnce(createMockChain({ data: [] })); // select pool Any + + const result = await skipRecommendation(1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.id).toBe(1); + expect(result.data.replacement).toBeNull(); + } + }); + + it('returns not_skippable if status is not open', async () => { + mocks.mockServiceFrom.mockReturnValueOnce(createMockChain(null, { data: null, error: null })); // update returns null row + + const result = await skipRecommendation(1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('not_skippable'); + } + }); + }); + + describe('linkPrToRec', () => { + it('updates linked_pr_url when URL is valid', async () => { + mocks.mockServiceFrom.mockReturnValueOnce( + createMockChain(null, { data: { id: 1 }, error: null }), + ); + + const result = await linkPrToRec(1, 'https://github.com/owner/repo/pull/123'); + + expect(result).toEqual({ ok: true, data: { id: 1 } }); + expect(mocks.mockCacheDel).toHaveBeenCalledWith('recs:test-user-id'); + }); + + it('returns invalid_url for non-GitHub URLs', async () => { + const result = await linkPrToRec(1, 'https://gitlab.com/owner/repo/pull/123'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('invalid_url'); + } + }); + + it('returns not_linkable when rec is not open/claimed', async () => { + mocks.mockServiceFrom.mockReturnValueOnce(createMockChain(null, { data: null, error: null })); + + const result = await linkPrToRec(1, 'https://github.com/owner/repo/pull/123'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('not_linkable'); + } + }); + }); + + describe('unlinkPrFromRec', () => { + it('clears linked_pr_url', async () => { + mocks.mockServiceFrom.mockReturnValueOnce( + createMockChain(null, { data: { id: 1 }, error: null }), + ); + + const result = await unlinkPrFromRec(1); + + expect(result).toEqual({ ok: true, data: { id: 1 } }); + expect(mocks.mockCacheDel).toHaveBeenCalledWith('recs:test-user-id'); + }); + + it('returns not_found if recommendation not found', async () => { + mocks.mockServiceFrom.mockReturnValueOnce(createMockChain(null, { data: null, error: null })); + + const result = await unlinkPrFromRec(1); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_found'); + }); + }); + + describe('unclaimRecommendation', () => { + it('resets status to open and clears claimed_at and linked_pr_url', async () => { + mocks.mockServiceFrom.mockReturnValueOnce( + createMockChain(null, { data: { id: 1 }, error: null }), + ); + + const result = await unclaimRecommendation(1); + + expect(result).toEqual({ ok: true, data: { id: 1 } }); + expect(mocks.mockCacheDel).toHaveBeenCalledWith('recs:test-user-id'); + }); + + it('returns not_claimable if rec is not in claimed state', async () => { + mocks.mockServiceFrom.mockReturnValueOnce(createMockChain(null, { data: null, error: null })); + + const result = await unclaimRecommendation(1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('not_claimable'); + } + }); + }); +}); diff --git a/src/inngest/functions/process-pr-event.test.ts b/src/inngest/functions/process-pr-event.test.ts index e5ca965..83edcc8 100644 --- a/src/inngest/functions/process-pr-event.test.ts +++ b/src/inngest/functions/process-pr-event.test.ts @@ -1,5 +1,50 @@ -import { describe, it, expect } from 'vitest'; -import { extractIssueNumbers } from './process-pr-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { extractIssueNumbers, processPrEvent } from './process-pr-event'; +import { insertXpEvent } from '@/lib/xp/events'; +import { sb, wire, step } from './test-helpers'; + +// Mock external dependencies. +vi.mock('@/lib/supabase/service', () => ({ getServiceSupabase: vi.fn() })); +vi.mock('@/lib/xp/events', () => ({ insertXpEvent: vi.fn() })); +vi.mock('@/lib/cache', () => ({ cacheDelByPrefix: vi.fn() })); +const mockSend = vi.fn().mockResolvedValue(undefined); +vi.mock('../client', () => ({ + inngest: { + createFunction: (_c: unknown, _t: unknown, h: Function) => h, + send: (...args: unknown[]) => mockSend(...args), + }, +})); + +// Handler reference. +const prRun = processPrEvent as unknown as (ctx: { + event: { data: Record }; + step: typeof step; +}) => Promise; + +// Factory for a pull_request closed & merged event. +const ev = (prUrl: string, repo: string, number: number) => ({ + data: { + payload: { + action: 'closed', + pull_request: { + id: 1234, + number, + html_url: prUrl, + title: 'Fix issue', + body: 'Closes #12', + state: 'closed', + draft: false, + merged: true, + merged_at: '2026-01-01T00:00:00Z', + closed_at: '2026-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + user: { login: 'contributor' }, + base: { repo: { full_name: repo } }, + }, + }, + }, +}); describe('extractIssueNumbers', () => { it('finds "closes #123"', () => { @@ -33,3 +78,195 @@ describe('extractIssueNumbers', () => { expect(extractIssueNumbers('Fixed #100')).toEqual([100]); }); }); + +describe('processPrEvent - awardRecommendedMerge XP capping', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const setupMock = (rec: { + id: number; + user_id: string; + difficulty: string; + xp_reward: number | null; + status: string; + }) => { + const recommendationsMock = sb({ + maybeSingle: vi.fn().mockResolvedValue({ data: rec }), + update: vi.fn().mockReturnThis(), + }); + const xpEventsMock = sb({ + maybeSingle: vi.fn().mockResolvedValue({ data: null }), // no existing xp event + }); + const activityLogMock = sb({ + insert: vi.fn().mockResolvedValue({ error: null }), + }); + const installationRepositoriesMock = sb({ + maybeSingle: vi.fn().mockResolvedValue({ data: { repo_full_name: 'owner/repo' } }), + }); + const profilesMock = sb({ + maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'contributor-id' } }), + }); + const pullRequestsMock = sb({ + upsert: vi.fn().mockResolvedValue({ error: null }), + }); + + wire({ + recommendations: recommendationsMock, + xp_events: xpEventsMock, + activity_log: activityLogMock, + installation_repositories: installationRepositoriesMock, + profiles: profilesMock, + pull_requests: pullRequestsMock, + }); + + vi.mocked(insertXpEvent).mockResolvedValue(true as never); + + return { recommendationsMock, activityLogMock }; + }; + + it('clamps inflated rec.xp_reward to difficulty ceiling (Easy)', async () => { + const { activityLogMock } = setupMock({ + id: 1, + user_id: 'user-1', + difficulty: 'E', + xp_reward: 9999, + status: 'claimed', + }); + + await prRun({ event: ev('https://github.com/owner/repo/pull/1', 'owner/repo', 1), step }); + + expect(insertXpEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + xpDelta: 50, // Capped to Easy ceiling (50) + }), + ); + + expect(activityLogMock.insert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-1', + kind: 'pr_merged', + detail: expect.objectContaining({ + xpAwarded: 50, // Clamped logged value + }), + }), + ); + }); + + it('clamps inflated rec.xp_reward to difficulty ceiling (Medium)', async () => { + const { activityLogMock } = setupMock({ + id: 2, + user_id: 'user-2', + difficulty: 'M', + xp_reward: 350, + status: 'claimed', + }); + + await prRun({ event: ev('https://github.com/owner/repo/pull/2', 'owner/repo', 2), step }); + + expect(insertXpEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-2', + xpDelta: 150, // Capped to Medium ceiling (150) + }), + ); + + expect(activityLogMock.insert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-2', + kind: 'pr_merged', + detail: expect.objectContaining({ + xpAwarded: 150, // Clamped logged value + }), + }), + ); + }); + + it('clamps inflated rec.xp_reward to difficulty ceiling (Hard)', async () => { + const { activityLogMock } = setupMock({ + id: 3, + user_id: 'user-3', + difficulty: 'H', + xp_reward: 1000, + status: 'claimed', + }); + + await prRun({ event: ev('https://github.com/owner/repo/pull/3', 'owner/repo', 3), step }); + + expect(insertXpEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-3', + xpDelta: 400, // Capped to Hard ceiling (400) + }), + ); + + expect(activityLogMock.insert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-3', + kind: 'pr_merged', + detail: expect.objectContaining({ + xpAwarded: 400, // Clamped logged value + }), + }), + ); + }); + + it('uses raw rec.xp_reward if it is within difficulty ceiling', async () => { + const { activityLogMock } = setupMock({ + id: 4, + user_id: 'user-4', + difficulty: 'E', + xp_reward: 30, + status: 'claimed', + }); + + await prRun({ event: ev('https://github.com/owner/repo/pull/4', 'owner/repo', 4), step }); + + expect(insertXpEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-4', + xpDelta: 30, // Within cap, so used as-is + }), + ); + + expect(activityLogMock.insert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-4', + kind: 'pr_merged', + detail: expect.objectContaining({ + xpAwarded: 30, + }), + }), + ); + }); + + it('falls back to default difficulty xp reward when xp_reward is null', async () => { + const { activityLogMock } = setupMock({ + id: 5, + user_id: 'user-5', + difficulty: 'E', + xp_reward: null, + status: 'claimed', + }); + + await prRun({ event: ev('https://github.com/owner/repo/pull/5', 'owner/repo', 5), step }); + + expect(insertXpEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-5', + xpDelta: 50, // Falls back to Easy ceiling (50) + }), + ); + + expect(activityLogMock.insert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-5', + kind: 'pr_merged', + detail: expect.objectContaining({ + xpAwarded: 50, + }), + }), + ); + }); +}); diff --git a/src/inngest/functions/process-pr-event.ts b/src/inngest/functions/process-pr-event.ts index e62a625..126c558 100644 --- a/src/inngest/functions/process-pr-event.ts +++ b/src/inngest/functions/process-pr-event.ts @@ -1,7 +1,7 @@ import { inngest } from '../client'; import { getServiceSupabase } from '@/lib/supabase/service'; import { insertXpEvent } from '@/lib/xp/events'; -import { XP_SOURCE, xpForMerge, refIds } from '@/lib/xp/sources'; +import { XP_SOURCE, xpForMerge, refIds, XP_REWARDS } from '@/lib/xp/sources'; import { cacheDelByPrefix } from '@/lib/cache'; import { buildPrRow, type IngestiblePr } from '@/lib/maintainer/pr-ingest'; @@ -259,6 +259,11 @@ async function awardRecommendedMerge( if (existing?.data) { return { xpAwarded: false, recId: rec.id }; } + const tierCap = + XP_REWARDS.RECOMMENDED_MERGE[difficulty as keyof typeof XP_REWARDS.RECOMMENDED_MERGE] ?? + xpForMerge(difficulty); + const xpDelta = Math.min(rec.xp_reward ?? tierCap, tierCap); + const inserted = await insertXpEvent({ userId: rec.user_id, source: XP_SOURCE.RECOMMENDED_MERGE, @@ -266,7 +271,7 @@ async function awardRecommendedMerge( refId: refIds.pr(repo, pr.number), repo, difficulty, - xpDelta: rec.xp_reward ?? xpForMerge(difficulty), + xpDelta, }); await sb @@ -282,7 +287,7 @@ async function awardRecommendedMerge( await sb.from('activity_log').insert({ user_id: rec.user_id, kind: 'pr_merged', - detail: { recId: rec.id, repo, prNumber: pr.number, xpAwarded: rec.xp_reward } as never, + detail: { recId: rec.id, repo, prNumber: pr.number, xpAwarded: xpDelta } as never, }); }