Open-source marketplace for AI Agent skills. Features: - Next.js 15 web app with i18n (en/fa) - CLI tool for skill installation (npx skillhub) - GitHub crawler/indexer with multi-strategy discovery - Security scanning for all indexed skills - Self-hostable with Docker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
isMeilisearchConfigured,
|
|
getMeilisearchClient,
|
|
isMeilisearchHealthy,
|
|
initializeSkillsIndex,
|
|
addSkillDocument,
|
|
addSkillDocuments,
|
|
deleteSkillDocument,
|
|
searchSkills,
|
|
getAllSkillDocuments,
|
|
clearSkillsIndex,
|
|
type MeiliSkillDocument,
|
|
} from './meilisearch.js';
|
|
|
|
// Store original env
|
|
const originalEnv = { ...process.env };
|
|
|
|
// Mock MeiliSearch class
|
|
vi.mock('meilisearch', () => {
|
|
const mockIndex = {
|
|
search: vi.fn(),
|
|
addDocuments: vi.fn().mockResolvedValue({ taskUid: 1 }),
|
|
deleteDocument: vi.fn().mockResolvedValue({ taskUid: 2 }),
|
|
deleteAllDocuments: vi.fn().mockResolvedValue({ taskUid: 3 }),
|
|
getDocuments: vi.fn().mockResolvedValue({ results: [] }),
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
updateSearchableAttributes: vi.fn().mockResolvedValue({ taskUid: 4 }),
|
|
updateFilterableAttributes: vi.fn().mockResolvedValue({ taskUid: 5 }),
|
|
updateSortableAttributes: vi.fn().mockResolvedValue({ taskUid: 6 }),
|
|
updateRankingRules: vi.fn().mockResolvedValue({ taskUid: 7 }),
|
|
};
|
|
|
|
return {
|
|
MeiliSearch: vi.fn().mockImplementation(() => ({
|
|
health: vi.fn().mockResolvedValue({ status: 'available' }),
|
|
index: vi.fn().mockReturnValue(mockIndex),
|
|
createIndex: vi.fn().mockResolvedValue({ taskUid: 0 }),
|
|
})),
|
|
Index: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe('Meilisearch Configuration', () => {
|
|
beforeEach(() => {
|
|
// Reset environment
|
|
delete process.env.MEILI_URL;
|
|
delete process.env.MEILI_MASTER_KEY;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original env
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
describe('isMeilisearchConfigured', () => {
|
|
it('should return true when MEILI_URL is set', () => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
|
|
expect(isMeilisearchConfigured()).toBe(true);
|
|
});
|
|
|
|
it('should return false when MEILI_URL is not set', () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
expect(isMeilisearchConfigured()).toBe(false);
|
|
});
|
|
|
|
it('should return false when MEILI_URL is empty', () => {
|
|
process.env.MEILI_URL = '';
|
|
|
|
expect(isMeilisearchConfigured()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getMeilisearchClient', () => {
|
|
it('should return null when not configured', () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const client = getMeilisearchClient();
|
|
|
|
expect(client).toBeNull();
|
|
});
|
|
|
|
it('should return client instance when configured', () => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
|
|
|
const client = getMeilisearchClient();
|
|
|
|
expect(client).not.toBeNull();
|
|
});
|
|
|
|
it('should return same instance (singleton)', () => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
|
|
const client1 = getMeilisearchClient();
|
|
const client2 = getMeilisearchClient();
|
|
|
|
expect(client1).toBe(client2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Meilisearch Health', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('isMeilisearchHealthy', () => {
|
|
it('should return false when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await isMeilisearchHealthy();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return true when Meilisearch is healthy', async () => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
|
|
const result = await isMeilisearchHealthy();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Meilisearch Index Operations', () => {
|
|
beforeEach(() => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.MEILI_URL;
|
|
delete process.env.MEILI_MASTER_KEY;
|
|
});
|
|
|
|
describe('initializeSkillsIndex', () => {
|
|
it('should create index with correct settings', async () => {
|
|
await initializeSkillsIndex();
|
|
|
|
// The function should complete without errors
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Meilisearch Document Operations', () => {
|
|
const testSkillDocument: MeiliSkillDocument = {
|
|
id: 'test-owner/test-repo/test-skill',
|
|
name: 'test-skill',
|
|
description: 'A test skill',
|
|
githubOwner: 'test-owner',
|
|
githubRepo: 'test-repo',
|
|
platforms: ['claude', 'codex'],
|
|
githubStars: 100,
|
|
downloadCount: 50,
|
|
rating: 4,
|
|
securityScore: 85,
|
|
isFeatured: false,
|
|
isVerified: true,
|
|
indexedAt: new Date().toISOString(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.MEILI_URL;
|
|
delete process.env.MEILI_MASTER_KEY;
|
|
});
|
|
|
|
describe('addSkillDocument', () => {
|
|
it('should add document to index', async () => {
|
|
const result = await addSkillDocument(testSkillDocument);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await addSkillDocument(testSkillDocument);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('addSkillDocuments', () => {
|
|
it('should add multiple documents', async () => {
|
|
const skills = [
|
|
testSkillDocument,
|
|
{ ...testSkillDocument, id: 'another/skill/test' },
|
|
];
|
|
|
|
const result = await addSkillDocuments(skills);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return true for empty array', async () => {
|
|
const result = await addSkillDocuments([]);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await addSkillDocuments([testSkillDocument]);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deleteSkillDocument', () => {
|
|
it('should delete document from index', async () => {
|
|
const result = await deleteSkillDocument('test-owner/test-repo/test-skill');
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await deleteSkillDocument('test-owner/test-repo/test-skill');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getAllSkillDocuments', () => {
|
|
it('should return all documents', async () => {
|
|
const result = await getAllSkillDocuments();
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
});
|
|
|
|
it('should return empty array when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await getAllSkillDocuments();
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('clearSkillsIndex', () => {
|
|
it('should clear all documents', async () => {
|
|
const result = await clearSkillsIndex();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await clearSkillsIndex();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Meilisearch Search', () => {
|
|
beforeEach(() => {
|
|
process.env.MEILI_URL = 'http://localhost:7700';
|
|
process.env.MEILI_MASTER_KEY = 'test-key';
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.MEILI_URL;
|
|
delete process.env.MEILI_MASTER_KEY;
|
|
});
|
|
|
|
describe('searchSkills', () => {
|
|
it('should search with query', async () => {
|
|
// Mock search response
|
|
const { MeiliSearch } = await import('meilisearch');
|
|
const mockClient = new MeiliSearch({ host: 'http://localhost:7700' });
|
|
const mockIndex = mockClient.index('skills');
|
|
vi.mocked(mockIndex.search).mockResolvedValue({
|
|
hits: [],
|
|
estimatedTotalHits: 0,
|
|
processingTimeMs: 1,
|
|
query: 'test',
|
|
limit: 20,
|
|
offset: 0,
|
|
});
|
|
|
|
const result = await searchSkills({ query: 'test' });
|
|
|
|
// Result could be null if index doesn't exist
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should return null when not configured', async () => {
|
|
delete process.env.MEILI_URL;
|
|
|
|
const result = await searchSkills({ query: 'test' });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should apply platform filter', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
filters: { platforms: ['claude'] },
|
|
});
|
|
|
|
// Just verify it doesn't throw
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should apply star filter', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
filters: { minStars: 100 },
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should apply verified filter', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
filters: { verified: true },
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should apply featured filter', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
filters: { featured: true },
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should apply security score filter', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
filters: { minSecurity: 80 },
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should sort by stars', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
sort: 'stars',
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should sort by downloads', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
sort: 'downloads',
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should sort by rating', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
sort: 'rating',
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should sort by recent', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
sort: 'recent',
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
|
|
it('should respect pagination', async () => {
|
|
const result = await searchSkills({
|
|
query: 'test',
|
|
limit: 10,
|
|
offset: 20,
|
|
});
|
|
|
|
expect(result === null || typeof result === 'object').toBe(true);
|
|
});
|
|
});
|
|
});
|