diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index cd8253f..6813ab2 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -112,12 +112,16 @@ export async function claimRecommendation(recId: number): Promise= 3) { return err('claim_limit', 'you already have 3 active claims - merge or close them first'); } diff --git a/src/inngest/functions/maintenance.ts b/src/inngest/functions/maintenance.ts index 079712e..3cfd454 100644 --- a/src/inngest/functions/maintenance.ts +++ b/src/inngest/functions/maintenance.ts @@ -46,7 +46,9 @@ export const streakDetect = inngest.createFunction( /** * Expire stale recommendations. - * recommendations.expires_at < now AND status='open' → 'expired'. + * recommendations.expires_at < now AND status IN ('open','claimed') → 'expired'. + * Including 'claimed' ensures abandoned claims are freed so users are not + * permanently locked out of the 3-claim limit. */ export const recsExpire = inngest.createFunction( { id: 'recs-expire' }, @@ -60,7 +62,7 @@ export const recsExpire = inngest.createFunction( .from('recommendations') .update({ status: 'expired' }) .lt('expires_at', now) - .eq('status', 'open') + .in('status', ['open', 'claimed']) .select('id'); return { expired: data?.length ?? 0 }; }); diff --git a/src/lib/github/app.test.ts b/src/lib/github/app.test.ts index fd176e4..8130b69 100644 --- a/src/lib/github/app.test.ts +++ b/src/lib/github/app.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +/* ------------------------------------------------------------------ */ +/* Mock heavy Octokit packages so dynamic imports stay fast. */ +/* ------------------------------------------------------------------ */ +const mockRequest = vi.fn(); +const FakeOctokit = vi.fn().mockImplementation(() => ({ + request: mockRequest, +})); + +vi.mock('@octokit/rest', () => ({ Octokit: FakeOctokit })); +vi.mock('@octokit/auth-app', () => ({ createAppAuth: vi.fn() })); +vi.mock('../cache', () => ({ + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn().mockResolvedValue(undefined), +})); + const KEYS = ['GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY'] as const; describe('github app factories', () => { @@ -7,6 +22,8 @@ describe('github app factories', () => { beforeEach(() => { vi.resetModules(); + FakeOctokit.mockClear(); + mockRequest.mockClear(); for (const k of KEYS) { saved[k] = process.env[k]; delete process.env[k]; @@ -23,17 +40,17 @@ describe('github app factories', () => { it('getAppOctokit throws when env missing', async () => { const { getAppOctokit } = await import('./app'); expect(() => getAppOctokit()).toThrow(/GITHUB_APP_ID/); - }); + }, 15_000); it('getInstallationToken throws when env missing', async () => { const { getInstallationToken } = await import('./app'); await expect(getInstallationToken(123)).rejects.toThrow(/GITHUB_APP_ID/); - }); + }, 15_000); it('getUserOctokit returns a client given a token', async () => { const { getUserOctokit } = await import('./app'); const oc = getUserOctokit('ghp_fake'); expect(oc).toBeTruthy(); expect(typeof oc.request).toBe('function'); - }); + }, 15_000); }); diff --git a/src/lib/xp/events.test.ts b/src/lib/xp/events.test.ts index 2fb6499..12954b4 100644 --- a/src/lib/xp/events.test.ts +++ b/src/lib/xp/events.test.ts @@ -11,6 +11,14 @@ vi.mock('../db/client', () => ({ schema: { xpEvents: { userId: 'u', source: 's', refId: 'r' } }, })); +/* Mock drizzle-orm's sql tag so vitest doesn't load the full package + (which takes 4+ seconds on some machines and causes timeouts). */ +vi.mock('drizzle-orm', () => ({ + sql: Object.assign((strings: TemplateStringsArray, ..._values: unknown[]) => ({ strings }), { + raw: (s: string) => s, + }), +})); + beforeEach(() => { mockReturning.mockReset(); mockExecute.mockReset(); @@ -21,6 +29,7 @@ beforeEach(() => { describe('insertXpEvent', () => { it('returns true when a row is inserted', async () => { + mockExecute.mockResolvedValueOnce([{ sum: 0 }]); // sumXpToday mockReturning.mockResolvedValueOnce([{ id: 1 }]); const { insertXpEvent } = await import('./events'); const inserted = await insertXpEvent({ @@ -38,6 +47,7 @@ describe('insertXpEvent', () => { }); it('returns false on idempotent duplicate (no row returned)', async () => { + mockExecute.mockResolvedValueOnce([{ sum: 0 }]); // sumXpToday mockReturning.mockResolvedValueOnce([]); const { insertXpEvent } = await import('./events'); const inserted = await insertXpEvent({