Skip to content
76 changes: 76 additions & 0 deletions app/api/admin/upgrade-self/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const authMock = vi.fn();
const getUserMock = vi.fn();
const updateUserMetadataMock = vi.fn();

vi.mock('@clerk/nextjs/server', () => ({
auth: () => authMock(),
clerkClient: async () => ({
users: {
getUser: getUserMock,
updateUserMetadata: updateUserMetadataMock,
},
}),
}));

import { GET } from './route';

beforeEach(() => {
authMock.mockReset();
getUserMock.mockReset();
updateUserMetadataMock.mockReset();
});

describe('GET /api/admin/upgrade-self', () => {
it('returns 401 when not signed in', async () => {
authMock.mockResolvedValue({ userId: null });

const res = await GET();

expect(res.status).toBe(401);
expect(getUserMock).not.toHaveBeenCalled();
});

it('returns 403 for a signed-in user who is not an admin', async () => {
authMock.mockResolvedValue({ userId: 'user_123' });
getUserMock.mockResolvedValue({ emailAddresses: [{ emailAddress: 'not-an-admin@example.com' }] });

const res = await GET();
const body = await res.json();

expect(res.status).toBe(403);
expect(body).toEqual({ error: 'Forbidden' });
expect(updateUserMetadataMock).not.toHaveBeenCalled();
});

it('upgrades an admin user to the full plan', async () => {
authMock.mockResolvedValue({ userId: 'user_admin' });
getUserMock.mockResolvedValue({ emailAddresses: [{ emailAddress: 'ritesh@gratiantechnologies.com' }] });
updateUserMetadataMock.mockResolvedValue(undefined);

const res = await GET();
const body = await res.json();

expect(res.status).toBe(200);
expect(body).toEqual({
ok: true,
userId: 'user_admin',
email: 'ritesh@gratiantechnologies.com',
plan: 'full',
});
expect(updateUserMetadataMock).toHaveBeenCalledWith('user_admin', {
publicMetadata: { plan: 'full' },
});
});

it('treats a missing email address as non-admin (403), not a crash', async () => {
authMock.mockResolvedValue({ userId: 'user_no_email' });
getUserMock.mockResolvedValue({ emailAddresses: [] });

const res = await GET();

expect(res.status).toBe(403);
expect(updateUserMetadataMock).not.toHaveBeenCalled();
});
});
173 changes: 173 additions & 0 deletions lib/ai/analyze.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const createMock = vi.fn();

vi.mock('./providers', () => ({
withFallback: async (fn: (provider: unknown) => Promise<unknown>) => {
const provider = {
name: 'test-provider',
model: 'test-model',
supportsVision: true,
client: { chat: { completions: { create: createMock } } },
};
const result = await fn(provider);
return { result, providerUsed: provider.name };
},
}));

vi.mock('../db', () => ({
getSetting: vi.fn(async () => null),
}));

import { runAudit } from './analyze';

function mockResponse(content: string) {
createMock.mockResolvedValueOnce({ choices: [{ message: { content } }] });
}

beforeEach(() => {
createMock.mockReset();
});

describe('runAudit', () => {
it('clamps an out-of-range score into 0-100', async () => {
mockResponse(JSON.stringify({
dimension: 'visual_design',
score: 150,
summary: 'summary',
findings: [],
}));

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['visual_design'],
});

expect(result.dimensions[0].score).toBe(100);
expect(result.overallScore).toBe(100);
});

it('clamps a negative score to 0', async () => {
mockResponse(JSON.stringify({
dimension: 'copywriting',
score: -20,
summary: 'summary',
findings: [],
}));

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['copywriting'],
});

expect(result.dimensions[0].score).toBe(0);
});

it('rounds a fractional score', async () => {
mockResponse(JSON.stringify({
dimension: 'cta',
score: 72.6,
summary: 'summary',
findings: [],
}));

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['cta'],
});

expect(result.dimensions[0].score).toBe(73);
});

it('strips markdown code fences before parsing JSON', async () => {
mockResponse('```json\n' + JSON.stringify({
dimension: 'seo',
score: 60,
summary: 'summary',
findings: [],
}) + '\n```');

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['seo'],
});

expect(result.dimensions[0].score).toBe(60);
});

it('falls back to a safe default result when the model returns invalid JSON', async () => {
mockResponse('this is not json at all');

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['accessibility'],
});

expect(result.dimensions[0]).toMatchObject({
dimension: 'accessibility',
score: 50,
});
expect(result.dimensions[0].findings).toHaveLength(1);
});

it('falls back to a safe default when the model returns empty content', async () => {
mockResponse('');

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['performance'],
});

expect(result.dimensions[0].score).toBe(50);
});

it('averages scores across multiple dimensions for the overall score', async () => {
mockResponse(JSON.stringify({ dimension: 'visual_design', score: 80, summary: '', findings: [] }));
mockResponse(JSON.stringify({ dimension: 'copywriting', score: 40, summary: '', findings: [] }));
mockResponse(JSON.stringify({ dimension: 'cta', score: 60, summary: '', findings: [] }));

const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['visual_design', 'copywriting', 'cta'],
});

expect(result.overallScore).toBe(60);
});

it('returns overallScore 0 when no dimensions are requested', async () => {
const result = await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: [],
});

expect(result.overallScore).toBe(0);
expect(result.dimensions).toEqual([]);
expect(createMock).not.toHaveBeenCalled();
});

it('invokes onDimensionComplete with each dimension result and provider name', async () => {
mockResponse(JSON.stringify({ dimension: 'mobile_experience', score: 55, summary: '', findings: [] }));
const onDimensionComplete = vi.fn();

await runAudit({
imageBase64: 'abc',
mimeType: 'image/png',
dimensions: ['mobile_experience'],
onDimensionComplete,
});

expect(onDimensionComplete).toHaveBeenCalledTimes(1);
expect(onDimensionComplete).toHaveBeenCalledWith(
expect.objectContaining({ dimension: 'mobile_experience', score: 55 }),
'test-provider',
);
});
});
133 changes: 133 additions & 0 deletions lib/ai/providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import OpenAI from 'openai';
import { isRetryableError, withFallback, getProviders, type Provider } from './providers';

function apiError(status: number): InstanceType<typeof OpenAI.APIError> {
return Object.assign(Object.create(OpenAI.APIError.prototype), { status });
}

const ALL_KEY_VARS = ['GROQ_KEY_1', 'GROQ_KEY_2', 'OPENROUTER_KEY_1', 'OPENROUTER_KEY_2'];

beforeEach(() => {
for (const key of ALL_KEY_VARS) vi.stubEnv(key, '');
});

afterEach(() => {
vi.unstubAllEnvs();
});

describe('isRetryableError', () => {
it('retries on 429 (rate limit)', () => {
expect(isRetryableError(apiError(429))).toBe(true);
});

it('retries on 403 (quota exhausted)', () => {
expect(isRetryableError(apiError(403))).toBe(true);
});

it('retries on 404 (model not found)', () => {
expect(isRetryableError(apiError(404))).toBe(true);
});

it('retries on 5xx server errors', () => {
expect(isRetryableError(apiError(500))).toBe(true);
expect(isRetryableError(apiError(503))).toBe(true);
});

it('does not retry on 400 (bad request)', () => {
expect(isRetryableError(apiError(400))).toBe(false);
});

it('does not retry on 401 (auth error)', () => {
expect(isRetryableError(apiError(401))).toBe(false);
});

it('retries on non-APIError (network/timeout/DNS failures)', () => {
expect(isRetryableError(new Error('ECONNRESET'))).toBe(true);
expect(isRetryableError('some string')).toBe(true);
expect(isRetryableError(undefined)).toBe(true);
});
});

describe('getProviders', () => {
it('never throws when every API key is missing (regression: empty apiKey used to crash the OpenAI client constructor)', () => {
expect(() => getProviders()).not.toThrow();
expect(getProviders()).toEqual([]);
});

it('skips providers with a short/placeholder key but keeps ones with a real key', () => {
vi.stubEnv('GROQ_KEY_1', 'short');
vi.stubEnv('GROQ_KEY_2', 'a-real-looking-groq-key-1234567890');

const providers = getProviders();

expect(providers.map((p) => p.name)).toEqual(['groq-2']);
});

it('returns providers in priority order (groq before openrouter)', () => {
vi.stubEnv('GROQ_KEY_1', 'a-real-looking-groq-key-1234567890');
vi.stubEnv('OPENROUTER_KEY_1', 'a-real-looking-openrouter-key-1234567890');

const providers = getProviders();

expect(providers.map((p) => p.name)).toEqual(['groq-1', 'openrouter-gemma', 'openrouter-auto']);
});
});

describe('withFallback', () => {
beforeEach(() => {
vi.stubEnv('GROQ_KEY_1', 'a-real-looking-groq-key-1234567890');
vi.stubEnv('GROQ_KEY_2', 'another-real-looking-groq-key-1234567890');
});

it('returns the result and provider name on first-provider success', async () => {
const fn = vi.fn(async (p: Provider) => `ok-from-${p.name}`);

const { result, providerUsed } = await withFallback(fn);

expect(result).toBe('ok-from-groq-1');
expect(providerUsed).toBe('groq-1');
expect(fn).toHaveBeenCalledTimes(1);
});

it('falls back to the next provider on a retryable error', async () => {
const fn = vi.fn(async (p: Provider) => {
if (p.name === 'groq-1') throw apiError(429);
return `ok-from-${p.name}`;
});

const { result, providerUsed } = await withFallback(fn);

expect(result).toBe('ok-from-groq-2');
expect(providerUsed).toBe('groq-2');
expect(fn).toHaveBeenCalledTimes(2);
});

it('fails fast on a non-retryable error without trying later providers', async () => {
const fn = vi.fn(async (p: Provider) => {
if (p.name === 'groq-1') throw apiError(401);
return `ok-from-${p.name}`;
});

await expect(withFallback(fn)).rejects.toMatchObject({ status: 401 });
expect(fn).toHaveBeenCalledTimes(1);
});

it('throws the last error when every provider fails', async () => {
const fn = vi.fn(async (p: Provider) => {
throw apiError(p.name === 'groq-1' ? 429 : 500);
});

await expect(withFallback(fn)).rejects.toMatchObject({ status: 500 });
expect(fn).toHaveBeenCalledTimes(2);
});

it('throws undefined when there are no providers available at all', async () => {
vi.stubEnv('GROQ_KEY_1', '');
vi.stubEnv('GROQ_KEY_2', '');
const fn = vi.fn(async () => 'unreachable');

await expect(withFallback(fn)).rejects.toBeUndefined();
expect(fn).not.toHaveBeenCalled();
});
});
Loading
Loading