stampchain-client.test.ts•16.6 kB
import { vi, describe, it, expect, beforeEach } from 'vitest';
/**
 * Tests for StampchainClient API client
 */
import axios from 'axios';
import { StampchainClient } from '../../api/stampchain-client.js';
import {
  createMockAxiosResponse,
  createMockStamp,
  createMockCollection,
  createMockToken,
} from '../utils/test-helpers.js';
// Get the mocked axios instance
vi.mock('axios');
vi.mock('axios-retry');
describe('StampchainClient', () => {
  let client: StampchainClient;
  let mockAxiosInstance: {
    get: ReturnType<typeof vi.fn>;
    post: ReturnType<typeof vi.fn>;
    put: ReturnType<typeof vi.fn>;
    delete: ReturnType<typeof vi.fn>;
    request: ReturnType<typeof vi.fn>;
    interceptors: {
      request: { use: ReturnType<typeof vi.fn> };
      response: { use: ReturnType<typeof vi.fn> };
    };
    defaults: {
      headers: Record<string, string>;
      baseURL: string;
      timeout: number;
    };
  };
  beforeEach(() => {
    // Get the mocked axios create return value
    mockAxiosInstance = {
      get: vi.fn(),
      post: vi.fn(),
      put: vi.fn(),
      delete: vi.fn(),
      request: vi.fn(),
      interceptors: {
        request: { use: vi.fn() },
        response: { use: vi.fn() },
      },
      defaults: {
        headers: {},
        baseURL: 'https://test.stampchain.io/api',
        timeout: 5000,
      },
    };
    // Make axios.create return our mock instance
    vi.mocked(axios.create).mockReturnValue(mockAxiosInstance as never);
    client = new StampchainClient({
      baseURL: 'https://test.stampchain.io/api',
      timeout: 5000,
      retries: 1,
      retryDelay: 100,
    });
    // Reset all mocks
    vi.clearAllMocks();
  });
  describe('constructor', () => {
    it('should create client with default configuration', () => {
      const defaultClient = new StampchainClient();
      expect(defaultClient).toBeInstanceOf(StampchainClient);
      expect(defaultClient.getApiVersion()).toBe('2.3');
    });
    it('should create client with custom configuration', () => {
      const customClient = new StampchainClient({
        baseURL: 'https://custom.api.com',
        timeout: 10000,
        retries: 5,
        retryDelay: 500,
        apiVersion: '2.2',
      });
      expect(customClient).toBeInstanceOf(StampchainClient);
      expect(customClient.getApiVersion()).toBe('2.2');
    });
  });
  describe('getStamp', () => {
    it('should fetch stamp data successfully', async () => {
      const mockStamp = createMockStamp();
      mockAxiosInstance.get.mockResolvedValueOnce(
        createMockAxiosResponse({
          data: { stamp: mockStamp },
        })
      );
      const result = await client.getStamp(12345);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps/12345');
      expect(result).toEqual(mockStamp);
    });
    it('should handle API errors gracefully', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network error'));
      await expect(client.getStamp(12345)).rejects.toThrow('Network error');
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps/12345');
    });
    it('should handle 404 errors for non-existent stamps', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce({
        response: { status: 404, data: { error: 'Stamp not found' } },
      });
      await expect(client.getStamp(99999)).rejects.toMatchObject({
        response: { status: 404 },
      });
    });
  });
  describe('searchStamps', () => {
    it('should search stamps with default parameters', async () => {
      const mockStamps = [createMockStamp(), createMockStamp({ stamp: 12346 })];
      mockAxiosInstance.get.mockResolvedValueOnce(
        createMockAxiosResponse({
          data: mockStamps,
        })
      );
      const result = await client.searchStamps();
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps', {
        params: {},
      });
      expect(result).toEqual(mockStamps);
    });
    it('should search stamps with custom filters', async () => {
      const mockStamps = [createMockStamp()];
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse({ data: mockStamps }));
      const params = {
        creator: 'bc1qtest123456789012345678901234567890',
        collection_id: 'test-collection',
        limit: 20,
        sort_order: 'ASC' as const,
      };
      const result = await client.searchStamps(params);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps', { params });
      expect(result).toEqual(mockStamps);
    });
    it('should handle empty search results', async () => {
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse({ data: [] }));
      const result = await client.searchStamps({ creator: 'nonexistent' });
      expect(result).toEqual([]);
    });
  });
  describe('getRecentStamps', () => {
    it('should fetch recent stamps with default limit', async () => {
      const mockStamps = [createMockStamp(), createMockStamp({ stamp: 12346 })];
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse({ data: mockStamps }));
      const result = await client.getRecentStamps();
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps', {
        params: { limit: 20 },
      });
      expect(result).toEqual(mockStamps);
    });
    it('should fetch recent stamps with custom limit', async () => {
      const mockStamps = Array.from({ length: 25 }, (_, i) =>
        createMockStamp({ stamp: 12345 + i })
      );
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse({ data: mockStamps }));
      const result = await client.getRecentStamps(25);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps', {
        params: { limit: 25 },
      });
      expect(result).toEqual(mockStamps);
    });
  });
  describe('getCollection', () => {
    it('should fetch collection data successfully', async () => {
      const mockCollection = createMockCollection();
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockCollection));
      const result = await client.getCollection('test-collection');
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/collections/test-collection');
      expect(result).toEqual(mockCollection);
    });
    it('should handle non-existent collections', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce({
        response: { status: 404, data: { error: 'Collection not found' } },
      });
      await expect(client.getCollection('nonexistent')).rejects.toMatchObject({
        response: { status: 404 },
      });
    });
  });
  describe('searchCollections', () => {
    it('should search collections with default parameters', async () => {
      const mockCollections = [
        createMockCollection(),
        createMockCollection({ collection_id: 'test-2' }),
      ];
      const mockResponse = {
        data: mockCollections,
        last_block: 844755,
        page: 1,
        limit: 10,
        totalPages: 1,
        total: 2,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockResponse));
      const result = await client.searchCollections();
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/collections', {
        params: {},
      });
      expect(result).toEqual(mockCollections);
    });
    it('should search collections with custom filters', async () => {
      const mockCollections = [createMockCollection()];
      const mockResponse = {
        data: mockCollections,
        last_block: 844755,
        page: 1,
        limit: 5,
        totalPages: 1,
        total: 1,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockResponse));
      const params = {
        creator: 'bc1qtest123456789012345678901234567890',
        query: 'Test',
        page_size: 5,
      };
      const result = await client.searchCollections(params);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/collections', { params });
      expect(result).toEqual(mockCollections);
    });
  });
  describe('getToken', () => {
    it('should fetch token info successfully', async () => {
      const mockToken = createMockToken();
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockToken));
      const result = await client.getToken('TEST');
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/src20/TEST');
      expect(result).toEqual(mockToken);
    });
    it('should handle case-insensitive token tickers', async () => {
      const mockToken = createMockToken({ tick: 'TEST' });
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockToken));
      const result = await client.getToken('test');
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/src20/test');
      expect(result).toEqual(mockToken);
    });
  });
  describe('searchTokens', () => {
    it('should search tokens with default parameters', async () => {
      const mockTokens = [createMockToken(), createMockToken({ tick: 'OTHER' })];
      const mockResponse = {
        data: mockTokens,
        last_block: 844755,
        page: 1,
        limit: 10,
        totalPages: 1,
        total: 2,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockResponse));
      const result = await client.searchTokens();
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/src20', {
        params: {},
      });
      expect(result).toEqual(mockTokens);
    });
    it('should search tokens with filters', async () => {
      const mockTokens = [createMockToken()];
      const mockResponse = {
        data: mockTokens,
        last_block: 844755,
        page: 1,
        limit: 20,
        totalPages: 1,
        total: 1,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockResponse));
      const params = {
        deployer: 'bc1qtest123456789012345678901234567890',
        page_size: 20,
      };
      const result = await client.searchTokens(params);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/src20', { params });
      expect(result).toEqual(mockTokens);
    });
  });
  describe('error handling', () => {
    it('should handle network timeouts', async () => {
      const timeoutError = new Error('timeout of 5000ms exceeded');
      timeoutError.name = 'AxiosError';
      mockAxiosInstance.get.mockRejectedValueOnce(timeoutError);
      await expect(client.getStamp(12345)).rejects.toThrow('timeout of 5000ms exceeded');
    });
    it('should handle server errors', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce({
        response: {
          status: 500,
          statusText: 'Internal Server Error',
          data: { error: 'Database connection failed' },
        },
      });
      await expect(client.getStamp(12345)).rejects.toMatchObject({
        response: { status: 500 },
      });
    });
    it('should handle rate limiting', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce({
        response: {
          status: 429,
          statusText: 'Too Many Requests',
          data: { error: 'Rate limit exceeded' },
        },
      });
      await expect(client.getStamp(12345)).rejects.toMatchObject({
        response: { status: 429 },
      });
    });
  });
  describe('API version management', () => {
    it('should get current API version', () => {
      expect(client.getApiVersion()).toBe('2.3');
    });
    it('should set API version', () => {
      client.setApiVersion('2.2');
      expect(client.getApiVersion()).toBe('2.2');
    });
    it('should get available versions', async () => {
      const mockVersions = {
        current: '2.3',
        requestedVersion: '2.3',
        versions: [
          { version: '2.3', status: 'current', releaseDate: '2025-01-15' },
          { version: '2.2', status: 'supported', releaseDate: '2024-06-01' },
        ],
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockVersions));
      const result = await client.getAvailableVersions();
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/versions');
      expect(result).toEqual(mockVersions);
    });
    it('should test version compatibility', async () => {
      // Mock axios.create for the temporary client in testVersionCompatibility
      const tempMockInstance = {
        get: vi.fn().mockResolvedValueOnce(createMockAxiosResponse({ status: 'OK' })),
      };
      vi.mocked(axios.create).mockReturnValueOnce(tempMockInstance as never);
      const result = await client.testVersionCompatibility('2.2');
      expect(result).toBe(true);
    });
    it('should handle version compatibility failure', async () => {
      mockAxiosInstance.get.mockRejectedValueOnce(new Error('Version not supported'));
      const result = await client.testVersionCompatibility('2.1');
      expect(result).toBe(false);
    });
    it('should get feature availability', () => {
      const features = client.getFeatureAvailability();
      expect(features).toEqual({
        marketData: true,
        recentSales: true,
        enhancedFiltering: true,
        dispenserInfo: true,
        cacheStatus: true,
      });
    });
    it('should get feature availability for older version', () => {
      client.setApiVersion('2.2');
      const features = client.getFeatureAvailability();
      expect(features).toEqual({
        marketData: false,
        recentSales: false,
        enhancedFiltering: false,
        dispenserInfo: false,
        cacheStatus: false,
      });
    });
  });
  describe('recent sales (v2.3)', () => {
    it('should get recent sales data', async () => {
      const mockSalesData = {
        data: [
          {
            tx_hash: 'abcd1234',
            block_index: 844755,
            stamp_id: 12345,
            price_btc: 0.001,
            price_usd: 50.0,
            timestamp: 1704067200,
            buyer_address: 'bc1qtest123',
            time_ago: '2h ago',
          },
        ],
        metadata: {
          dayRange: 30,
          lastUpdated: 1704067200000,
          total: 1,
        },
        last_block: 844755,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockSalesData));
      const result = await client.getRecentSales({ dayRange: 30, fullDetails: true });
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps/recentSales', {
        params: { dayRange: 30, fullDetails: true },
      });
      expect(result).toEqual(mockSalesData);
    });
    it('should handle recent sales with fallback for older API versions', async () => {
      client.setApiVersion('2.2');
      const mockStamps = [createMockStamp()];
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse({ data: mockStamps }));
      const result = await client.getRecentSales({ dayRange: 7 });
      expect(result.data).toHaveLength(1);
      expect(result.metadata.dayRange).toBe(7);
    });
  });
  describe('market data (v2.3)', () => {
    it('should get market data', async () => {
      const mockMarketData = {
        data: [
          {
            floorPrice: 0.001,
            floorPriceUSD: 50.0,
            marketCapUSD: 1000000,
            activityLevel: 'HOT',
            lastActivityTime: 1704067200,
            volume24h: 0.5,
          },
        ],
        last_block: 844755,
        page: 1,
        limit: 20,
        total: 1,
      };
      mockAxiosInstance.get.mockResolvedValueOnce(createMockAxiosResponse(mockMarketData));
      const result = await client.getMarketData({ activity_level: 'HOT' });
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps/marketData', {
        params: { activity_level: 'HOT' },
      });
      expect(result).toEqual(mockMarketData);
    });
    it('should get stamp market data', async () => {
      const mockStampMarketData = {
        floorPrice: 0.001,
        floorPriceUSD: 50.0,
        marketCapUSD: 100000,
        activityLevel: 'WARM',
        lastActivityTime: 1704067200,
        volume24h: 0.1,
        lastSaleTxHash: 'abcd1234',
        lastSaleBuyerAddress: 'bc1qtest123',
      };
      mockAxiosInstance.get.mockResolvedValueOnce(
        createMockAxiosResponse({
          data: mockStampMarketData,
        })
      );
      const result = await client.getStampMarketData(12345);
      expect(mockAxiosInstance.get).toHaveBeenCalledWith('/stamps/12345/marketData');
      expect(result).toEqual(mockStampMarketData);
    });
  });
});