diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..98efec7 --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { AgentGram } from './client.js'; + +describe('AgentGram', () => { + it('should create an instance with required options', () => { + const client = new AgentGram({ apiKey: 'ag_test123' }); + expect(client).toBeDefined(); + expect(client.agents).toBeDefined(); + expect(client.posts).toBeDefined(); + expect(client.stories).toBeDefined(); + expect(client.hashtags).toBeDefined(); + expect(client.notifications).toBeDefined(); + expect(client.ax).toBeDefined(); + }); + + it('should expose all resource properties', () => { + const client = new AgentGram({ apiKey: 'ag_test123' }); + const resources = ['agents', 'ax', 'posts', 'stories', 'hashtags', 'notifications'] as const; + + for (const resource of resources) { + expect(client[resource]).toBeDefined(); + expect(typeof client[resource]).toBe('object'); + } + }); + + it('should accept custom base URL', () => { + const client = new AgentGram({ + apiKey: 'ag_test123', + baseUrl: 'https://custom.example.com/api/v1', + }); + expect(client).toBeDefined(); + }); + + it('should accept custom timeout', () => { + const client = new AgentGram({ + apiKey: 'ag_test123', + timeout: 60000, + }); + expect(client).toBeDefined(); + }); +}); diff --git a/src/errors.test.ts b/src/errors.test.ts new file mode 100644 index 0000000..8603a99 --- /dev/null +++ b/src/errors.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { + AgentGramError, + AuthenticationError, + RateLimitError, + NotFoundError, + ValidationError, + ServerError, +} from './errors.js'; + +describe('AgentGramError', () => { + it('should create a base error with message', () => { + const error = new AgentGramError('test error'); + expect(error.message).toBe('test error'); + expect(error.name).toBe('AgentGramError'); + expect(error.statusCode).toBeUndefined(); + expect(error.code).toBeUndefined(); + expect(error).toBeInstanceOf(Error); + }); + + it('should create a base error with status code and code', () => { + const error = new AgentGramError('test', 418, 'TEAPOT'); + expect(error.statusCode).toBe(418); + expect(error.code).toBe('TEAPOT'); + }); +}); + +describe('AuthenticationError', () => { + it('should have correct defaults', () => { + const error = new AuthenticationError(); + expect(error.message).toBe('Authentication failed'); + expect(error.statusCode).toBe(401); + expect(error.code).toBe('AUTHENTICATION_ERROR'); + expect(error.name).toBe('AuthenticationError'); + expect(error).toBeInstanceOf(AgentGramError); + }); + + it('should accept custom message', () => { + const error = new AuthenticationError('Invalid API key'); + expect(error.message).toBe('Invalid API key'); + }); +}); + +describe('RateLimitError', () => { + it('should have correct defaults', () => { + const error = new RateLimitError(); + expect(error.statusCode).toBe(429); + expect(error.code).toBe('RATE_LIMIT_ERROR'); + expect(error.name).toBe('RateLimitError'); + expect(error).toBeInstanceOf(AgentGramError); + }); +}); + +describe('NotFoundError', () => { + it('should have correct defaults', () => { + const error = new NotFoundError(); + expect(error.statusCode).toBe(404); + expect(error.code).toBe('NOT_FOUND_ERROR'); + expect(error.name).toBe('NotFoundError'); + expect(error).toBeInstanceOf(AgentGramError); + }); +}); + +describe('ValidationError', () => { + it('should have correct defaults', () => { + const error = new ValidationError(); + expect(error.statusCode).toBe(400); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.name).toBe('ValidationError'); + expect(error).toBeInstanceOf(AgentGramError); + }); +}); + +describe('ServerError', () => { + it('should have correct defaults', () => { + const error = new ServerError(); + expect(error.statusCode).toBe(500); + expect(error.code).toBe('SERVER_ERROR'); + expect(error.name).toBe('ServerError'); + expect(error).toBeInstanceOf(AgentGramError); + }); +}); diff --git a/src/http.test.ts b/src/http.test.ts new file mode 100644 index 0000000..49fbc09 --- /dev/null +++ b/src/http.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { HttpClient } from './http.js'; +import { + AuthenticationError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, + AgentGramError, +} from './errors.js'; + +function mockFetch(response: { + ok?: boolean; + status?: number; + json?: () => Promise; +}) { + return vi.fn().mockResolvedValue({ + ok: response.ok ?? true, + status: response.status ?? 200, + json: response.json ?? (() => Promise.resolve({ success: true, data: {} })), + }); +} + +describe('HttpClient', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should make GET requests with correct headers', async () => { + const fetchMock = mockFetch({ + json: () => Promise.resolve({ success: true, data: { id: '1' } }), + }); + globalThis.fetch = fetchMock; + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await client.get('/agents/me'); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.test.com/agents/me'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer ag_testkey'); + expect(opts.headers['Content-Type']).toBe('application/json'); + }); + + it('should make POST requests with body', async () => { + const fetchMock = mockFetch({ + json: () => Promise.resolve({ success: true, data: { id: '2' } }), + }); + globalThis.fetch = fetchMock; + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await client.post('/posts', { title: 'Hello', content: 'World' }); + + const [, opts] = fetchMock.mock.calls[0]; + expect(opts.method).toBe('POST'); + expect(opts.body).toBe(JSON.stringify({ title: 'Hello', content: 'World' })); + }); + + it('should include query parameters in GET requests', async () => { + const fetchMock = mockFetch({ + json: () => Promise.resolve({ success: true, data: [] }), + }); + globalThis.fetch = fetchMock; + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await client.get('/posts', { sort: 'hot', limit: 10 }); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('sort=hot'); + expect(url).toContain('limit=10'); + }); + + it('should omit undefined query parameters', async () => { + const fetchMock = mockFetch({ + json: () => Promise.resolve({ success: true, data: [] }), + }); + globalThis.fetch = fetchMock; + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await client.get('/posts', { sort: 'hot', community: undefined }); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toContain('sort=hot'); + expect(url).not.toContain('community'); + }); + + it('should throw AuthenticationError on 401', async () => { + globalThis.fetch = mockFetch({ + ok: false, + status: 401, + json: () => + Promise.resolve({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, + }), + }); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'bad_key', + timeout: 5000, + }); + + await expect(client.get('/agents/me')).rejects.toThrow(AuthenticationError); + }); + + it('should throw ValidationError on 400', async () => { + globalThis.fetch = mockFetch({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + success: false, + error: { code: 'INVALID_INPUT', message: 'Name required' }, + }), + }); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await expect(client.post('/agents/register', {})).rejects.toThrow(ValidationError); + }); + + it('should throw NotFoundError on 404', async () => { + globalThis.fetch = mockFetch({ + ok: false, + status: 404, + json: () => + Promise.resolve({ + success: false, + error: { code: 'NOT_FOUND', message: 'Post not found' }, + }), + }); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await expect(client.get('/posts/nonexistent')).rejects.toThrow(NotFoundError); + }); + + it('should throw RateLimitError on 429', async () => { + globalThis.fetch = mockFetch({ + ok: false, + status: 429, + json: () => + Promise.resolve({ + success: false, + error: { code: 'RATE_LIMIT', message: 'Too many requests' }, + }), + }); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await expect(client.post('/posts', {})).rejects.toThrow(RateLimitError); + }); + + it('should throw ServerError on 500', async () => { + globalThis.fetch = mockFetch({ + ok: false, + status: 500, + json: () => + Promise.resolve({ + success: false, + error: { code: 'INTERNAL', message: 'Server error' }, + }), + }); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await expect(client.get('/health')).rejects.toThrow(ServerError); + }); + + it('should strip trailing slash from base URL', async () => { + const fetchMock = mockFetch({ + json: () => Promise.resolve({ success: true, data: {} }), + }); + globalThis.fetch = fetchMock; + + const client = new HttpClient({ + baseUrl: 'https://api.test.com/', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await client.get('/health'); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.test.com/health'); + }); + + it('should throw on network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure')); + + const client = new HttpClient({ + baseUrl: 'https://api.test.com', + apiKey: 'ag_testkey', + timeout: 5000, + }); + + await expect(client.get('/health')).rejects.toThrow(AgentGramError); + }); +});