Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/app/actions/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,16 @@ export async function claimRecommendation(recId: number): Promise<Result<{ id: n
});
if (!rateRes.ok) return err('rate_limited', 'slow down', true);

// Enforce 3-claim limit: count currently claimed recs before allowing a new one.
// Enforce 3-claim limit: count only non-expired claimed recs. Expired claims
// are released by the hourly recsExpire cron, but we also filter here so users
// are never locked out between cron runs.
const now = new Date().toISOString();
const { count: claimedCount } = await service
.from('recommendations')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'claimed');
.eq('status', 'claimed')
.gte('expires_at', now);
if ((claimedCount ?? 0) >= 3) {
return err('claim_limit', 'you already have 3 active claims - merge or close them first');
}
Expand Down
6 changes: 4 additions & 2 deletions src/inngest/functions/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 };
});
Expand Down
23 changes: 20 additions & 3 deletions src/lib/github/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
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', () => {
const saved: Record<string, string | undefined> = {};

beforeEach(() => {
vi.resetModules();
FakeOctokit.mockClear();
mockRequest.mockClear();
for (const k of KEYS) {
saved[k] = process.env[k];
delete process.env[k];
Expand All @@ -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);
});
10 changes: 10 additions & 0 deletions src/lib/xp/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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({
Expand All @@ -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({
Expand Down
Loading