diff --git a/src/app/actions/help.test.ts b/src/app/actions/help.test.ts new file mode 100644 index 00000000..2f305550 --- /dev/null +++ b/src/app/actions/help.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { sendHelpRequest } from './help'; + +const mocks = vi.hoisted(() => ({ + mockGetServerSupabase: vi.fn(), + mockGetUser: vi.fn(), + mockGetServiceSupabase: vi.fn(), + mockServiceFrom: vi.fn(), + mockInngestSend: vi.fn(), + mockRateLimit: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + getServerSupabase: mocks.mockGetServerSupabase, +})); + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: mocks.mockGetServiceSupabase, +})); + +vi.mock('@/inngest/client', () => ({ + inngest: { send: mocks.mockInngestSend }, +})); + +vi.mock('@/lib/rate-limit', () => ({ + rateLimit: mocks.mockRateLimit, + RATE_LIMIT_TIERS: { HOURLY: { limit: 5, windowSec: 3600 } }, +})); + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })); + +/** Build a chainable Supabase query mock with optional terminal result for .single(). */ +function makeChain(resolveValue?: unknown) { + const result = resolveValue ?? { data: null, error: null }; + return { + select: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue(result), + }; +} + +describe('sendHelpRequest', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.mockGetServerSupabase.mockReturnValue({ + auth: { getUser: mocks.mockGetUser }, + }); + mocks.mockGetServiceSupabase.mockReturnValue({ + from: mocks.mockServiceFrom, + }); + }); + + it('rejects when server supabase not configured', async () => { + mocks.mockGetServerSupabase.mockReturnValue(null); + + const result = await sendHelpRequest({ recId: 1, prUrl: 'help' }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_configured'); + }); + + it('rejects when user not authenticated', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + + const result = await sendHelpRequest({ recId: 1, prUrl: 'help' }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('not_authenticated'); + }); + + it('rejects when rate limited', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ + ok: false, + resetAt: new Date('2026-07-10T12:00:00Z'), + }); + + const result = await sendHelpRequest({ recId: 1, prUrl: 'help' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('rate_limited'); + expect(result.error.resetAt).toBeDefined(); + } + }); + + it('enforces cooldown for same PR URL within 4 hours', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + + // Cooldown query: .limit() returns existing help request → cooldown hit + const cooldownChain = makeChain(); + cooldownChain.limit = vi.fn().mockResolvedValue({ data: [{ id: 99 }], error: null }); + mocks.mockServiceFrom.mockReturnValueOnce(cooldownChain); + + const result = await sendHelpRequest({ + recId: 1, + prUrl: 'https://github.com/user/repo/pull/42', + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('cooldown'); + }); + + it('accepts plain text message and sends help request', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockInngestSend.mockResolvedValue(undefined); + + // Cooldown query: standard chain → .limit() returns chain → data is + // undefined → cooldown passes. + const cooldownChain = makeChain(); + mocks.mockServiceFrom.mockReturnValueOnce(cooldownChain); + + // Insert query: .single() resolves with the new row. + const insertChain = makeChain({ data: { id: 123 }, error: null }); + mocks.mockServiceFrom.mockReturnValueOnce(insertChain); + + const result = await sendHelpRequest({ recId: 1, prUrl: 'I need help' }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.helpRequestId).toBe(123); + + expect(mocks.mockInngestSend).toHaveBeenCalledWith( + expect.objectContaining({ name: 'help/dispatch' }), + ); + }); + + it('accepts valid GitHub PR URL', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockInngestSend.mockResolvedValue(undefined); + + const cooldownChain = makeChain(); + mocks.mockServiceFrom.mockReturnValueOnce(cooldownChain); + + const insertChain = makeChain({ data: { id: 456 }, error: null }); + mocks.mockServiceFrom.mockReturnValueOnce(insertChain); + + const prUrl = 'https://github.com/some-org/some-repo/pull/789'; + const result = await sendHelpRequest({ recId: 1, prUrl }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.helpRequestId).toBe(456); + + // Inngest payload must include the prUrl for valid GitHub URLs. + expect(mocks.mockInngestSend).toHaveBeenCalledWith({ + name: 'help/dispatch', + data: { helpRequestId: 456, userId: 'user-1', prUrl }, + }); + }); +}); diff --git a/src/app/actions/streak.test.ts b/src/app/actions/streak.test.ts new file mode 100644 index 00000000..6b6b594f --- /dev/null +++ b/src/app/actions/streak.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getCurrentStreak, getPublicStreak } from './streak'; + +const mocks = vi.hoisted(() => ({ + mockGetServerSupabase: vi.fn(), + mockGetUser: vi.fn(), + mockGetServiceSupabase: vi.fn(), + mockServiceFrom: vi.fn(), + mockRateLimit: vi.fn(), + mockComputeStreak: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + getServerSupabase: mocks.mockGetServerSupabase, +})); + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: mocks.mockGetServiceSupabase, +})); + +vi.mock('@/lib/rate-limit', () => ({ + rateLimit: mocks.mockRateLimit, +})); + +vi.mock('@/lib/xp/streak', () => ({ + computeCurrentStreak: mocks.mockComputeStreak, +})); + +/** Build a chainable Supabase query mock with optional terminal result for .single(). */ +function makeChain(resolveValue?: unknown) { + const result = resolveValue ?? { data: null, error: null }; + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue(result), + }; +} + +describe('getCurrentStreak', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.mockGetServerSupabase.mockReturnValue({ + auth: { getUser: mocks.mockGetUser }, + }); + mocks.mockGetServiceSupabase.mockReturnValue({ + from: mocks.mockServiceFrom, + }); + }); + + it('returns current streak for authenticated user', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockComputeStreak.mockReturnValue(5); + + // xp_events query ends with .order() — make it resolve with data. + const chain = makeChain(); + chain.order = vi.fn().mockResolvedValue({ + data: [{ created_at: '2026-07-09T10:00:00Z' }], + error: null, + }); + mocks.mockServiceFrom.mockReturnValue(chain); + + const result = await getCurrentStreak(); + + expect(result.days).toBe(5); + expect(mocks.mockComputeStreak).toHaveBeenCalledWith( + [{ created_at: '2026-07-09T10:00:00Z' }], + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + ); + }); + + it('returns 0 when user has no activity', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockComputeStreak.mockReturnValue(0); + + const chain = makeChain(); + chain.order = vi.fn().mockResolvedValue({ data: [], error: null }); + mocks.mockServiceFrom.mockReturnValue(chain); + + const result = await getCurrentStreak(); + + expect(result.days).toBe(0); + expect(mocks.mockComputeStreak).toHaveBeenCalledWith([], expect.any(String)); + }); + + it('returns 0 when not authenticated', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + + const result = await getCurrentStreak(); + + expect(result.days).toBe(0); + expect(mocks.mockRateLimit).not.toHaveBeenCalled(); + }); + + it('returns 0 when rate limited', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: false }); + + const result = await getCurrentStreak(); + + expect(result.days).toBe(0); + expect(mocks.mockComputeStreak).not.toHaveBeenCalled(); + }); +}); + +describe('getPublicStreak', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.mockGetServerSupabase.mockReturnValue({ + auth: { getUser: mocks.mockGetUser }, + }); + mocks.mockGetServiceSupabase.mockReturnValue({ + from: mocks.mockServiceFrom, + }); + }); + + it('returns streak for any userId (anonymous)', async () => { + // Server supabase exists but user not authenticated → key = 'anon:' suffix. + mocks.mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockComputeStreak.mockReturnValue(3); + + const chain = makeChain(); + chain.order = vi.fn().mockResolvedValue({ + data: [{ created_at: '2026-07-08T10:00:00Z' }], + error: null, + }); + mocks.mockServiceFrom.mockReturnValue(chain); + + const result = await getPublicStreak('other-user-id'); + + expect(result.days).toBe(3); + expect(mocks.mockRateLimit).toHaveBeenCalledWith( + expect.objectContaining({ key: 'anon:other-user-id' }), + ); + }); + + it('uses authenticated user key when available', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'auth-user-id' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + mocks.mockComputeStreak.mockReturnValue(7); + + const chain = makeChain(); + chain.order = vi.fn().mockResolvedValue({ + data: [{ created_at: '2026-07-07T10:00:00Z' }], + error: null, + }); + mocks.mockServiceFrom.mockReturnValue(chain); + + const result = await getPublicStreak('other-user-id'); + + expect(result.days).toBe(7); + // When user is authenticated, rate limit key uses user.id, not the anon prefix. + expect(mocks.mockRateLimit).toHaveBeenCalledWith( + expect.objectContaining({ key: 'auth-user-id' }), + ); + }); +}); diff --git a/src/app/actions/usage.test.ts b/src/app/actions/usage.test.ts new file mode 100644 index 00000000..55a4c704 --- /dev/null +++ b/src/app/actions/usage.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getUsage } from './usage'; + +const mocks = vi.hoisted(() => ({ + mockGetServerSupabase: vi.fn(), + mockGetUser: vi.fn(), + mockGetServiceSupabase: vi.fn(), + mockServiceFrom: vi.fn(), + mockRateLimit: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + getServerSupabase: mocks.mockGetServerSupabase, +})); + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: mocks.mockGetServiceSupabase, +})); + +vi.mock('@/lib/rate-limit', () => ({ + rateLimit: mocks.mockRateLimit, +})); + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })); + +/** Build a chainable Supabase query mock with optional terminal result for .single(). */ +function makeChain(resolveValue?: unknown) { + const result = resolveValue ?? { data: null, error: null }; + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue(result), + }; +} + +describe('getUsage', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.mockGetServerSupabase.mockReturnValue({ + auth: { getUser: mocks.mockGetUser }, + }); + mocks.mockGetServiceSupabase.mockReturnValue({ + from: mocks.mockServiceFrom, + }); + }); + + it('returns usage data for authenticated user', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + + // Three parallel queries: activity_log, xp_events (today), xp_events (week). + const logChain = makeChain(); + logChain.limit = vi.fn().mockResolvedValue({ + data: [ + { id: 1, kind: 'review', detail: { pr: 42 }, created_at: '2026-07-10T10:00:00Z' }, + { id: 2, kind: 'comment', detail: null, created_at: '2026-07-09T10:00:00Z' }, + ], + error: null, + }); + + const todayChain = makeChain(); + todayChain.gte = vi.fn().mockResolvedValue({ + data: [{ xp_delta: 50 }, { xp_delta: 30 }], + error: null, + }); + + const weekChain = makeChain(); + weekChain.gte = vi.fn().mockResolvedValue({ + data: [{ xp_delta: 50 }, { xp_delta: 30 }, { xp_delta: 20 }], + error: null, + }); + + mocks.mockServiceFrom + .mockReturnValueOnce(logChain) // activity_log + .mockReturnValueOnce(todayChain) // xp_events today + .mockReturnValueOnce(weekChain); // xp_events week + + const result = await getUsage(); + + expect(result.todayXp).toBe(80); // 50 + 30 + expect(result.weekXp).toBe(100); // 50 + 30 + 20 + expect(result.entries).toHaveLength(2); + expect(result.entries[0]).toEqual({ + id: 1, + kind: 'review', + createdAt: '2026-07-10T10:00:00Z', + detail: { pr: 42 }, + }); + }); + + it('respects limit parameter', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + + const logChain = makeChain(); + const limitSpy = vi.fn().mockResolvedValue({ + data: [{ id: 1, kind: 'review', detail: null, created_at: '2026-07-10T10:00:00Z' }], + error: null, + }); + logChain.limit = limitSpy; + + const todayChain = makeChain(); + todayChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + const weekChain = makeChain(); + weekChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + mocks.mockServiceFrom + .mockReturnValueOnce(logChain) + .mockReturnValueOnce(todayChain) + .mockReturnValueOnce(weekChain); + + await getUsage(5); + + expect(limitSpy).toHaveBeenCalledWith(5); + }); + + it('returns empty entries when no activity', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + + const logChain = makeChain(); + logChain.limit = vi.fn().mockResolvedValue({ data: [], error: null }); + + const todayChain = makeChain(); + todayChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + const weekChain = makeChain(); + weekChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + mocks.mockServiceFrom + .mockReturnValueOnce(logChain) + .mockReturnValueOnce(todayChain) + .mockReturnValueOnce(weekChain); + + const result = await getUsage(); + + expect(result.todayXp).toBe(0); + expect(result.weekXp).toBe(0); + expect(result.entries).toEqual([]); + }); + + it('returns empty summary when not authenticated', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); + + const result = await getUsage(); + + expect(result).toEqual({ todayXp: 0, weekXp: 0, entries: [] }); + expect(mocks.mockRateLimit).not.toHaveBeenCalled(); + }); + + it('returns empty when rate limited', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: false }); + + const result = await getUsage(); + + expect(result).toEqual({ todayXp: 0, weekXp: 0, entries: [] }); + expect(mocks.mockServiceFrom).not.toHaveBeenCalled(); + }); + + it('handles null detail in activity_log entries', async () => { + mocks.mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }); + mocks.mockRateLimit.mockResolvedValue({ ok: true }); + + const logChain = makeChain(); + logChain.limit = vi.fn().mockResolvedValue({ + data: [{ id: 1, kind: 'comment', detail: null, created_at: '2026-07-10T10:00:00Z' }], + error: null, + }); + + const todayChain = makeChain(); + todayChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + const weekChain = makeChain(); + weekChain.gte = vi.fn().mockResolvedValue({ data: [], error: null }); + + mocks.mockServiceFrom + .mockReturnValueOnce(logChain) + .mockReturnValueOnce(todayChain) + .mockReturnValueOnce(weekChain); + + const result = await getUsage(); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].detail).toBeNull(); + }); +});