Skip to content
Closed
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
100 changes: 100 additions & 0 deletions src/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Tests for the registry API client.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { submitSkillsToRegistry, searchSkillsInRegistry } from '../lib/registry.js';

// Helper to build a mock Response
function mockResponse(
status: number,
body: string,
headers: Record<string, string> = {},
): Response {
const headersObj = new Headers({ 'content-type': 'text/plain', ...headers });
return {
ok: status >= 200 && status < 300,
status,
headers: headersObj,
text: () => Promise.resolve(body),
json: () => Promise.resolve(JSON.parse(body)),
} as unknown as Response;
}

describe('submitSkillsToRegistry', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

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

it('returns a clear rate-limit error on 429 without retry-after', async () => {
vi.mocked(fetch).mockResolvedValue(mockResponse(429, 'Too Many Requests'));

const result = await submitSkillsToRegistry([
{
url: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
skill: 'repo',
path: '',
},
]);

expect(result.success).toBe(false);
expect(result.submitted[0].error).toMatch(/rate limit exceeded/i);
expect(result.submitted[0].error).toContain('429');
expect(result.submitted[0].error).not.toContain('non-JSON');
});

it('includes retry-after hint when header is present on 429', async () => {
vi.mocked(fetch).mockResolvedValue(
mockResponse(429, 'Too Many Requests', { 'retry-after': '60' }),
);

const result = await submitSkillsToRegistry([
{
url: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
skill: 'repo',
path: '',
},
]);

expect(result.submitted[0].error).toContain('60 seconds');
});
});

describe('searchSkillsInRegistry', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

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

it('returns a clear rate-limit error on 429 without retry-after', async () => {
vi.mocked(fetch).mockResolvedValue(mockResponse(429, 'Too Many Requests'));

const result = await searchSkillsInRegistry('some-query');

expect(result.success).toBe(false);
expect(result.error).toMatch(/rate limit exceeded/i);
expect(result.error).toContain('429');
expect(result.error).not.toContain('non-JSON');
});

it('includes retry-after hint when header is present on 429', async () => {
vi.mocked(fetch).mockResolvedValue(
mockResponse(429, 'Too Many Requests', { 'retry-after': '30' }),
);

const result = await searchSkillsInRegistry('some-query');

expect(result.error).toContain('30 seconds');
});
});
21 changes: 21 additions & 0 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export async function submitSkillsToRegistry(
MAX_RETRIES,
);

// Handle rate limiting before content-type check (429 body is often non-JSON)
if (res.status === 429) {
const retryAfter = res.headers.get('retry-after');
const hint = retryAfter
? ` Try again in ${retryAfter} seconds.`
: ' Please try again later.';
throw new Error(`Registry rate limit exceeded (HTTP 429).${hint}`);
}

// Validate Content-Type before parsing JSON
const contentType = res.headers.get('content-type');
if (!contentType?.includes('application/json')) {
Expand Down Expand Up @@ -217,6 +226,18 @@ export async function searchSkillsInRegistry(
MAX_RETRIES,
);

// Handle rate limiting before content-type check (429 body is often non-JSON)
if (res.status === 429) {
const retryAfter = res.headers.get('retry-after');
const hint = retryAfter ? ` Try again in ${retryAfter} seconds.` : ' Please try again later.';
return {
success: false,
results: [],
totalCount: 0,
error: `Registry rate limit exceeded (HTTP 429).${hint}`,
};
}

// Validate Content-Type before parsing JSON
const contentType = res.headers.get('content-type');
const isJson = contentType?.includes('application/json');
Expand Down