diff --git a/tests/api/marketplace-featured.test.ts b/tests/api/marketplace-featured.test.ts new file mode 100644 index 0000000..c89520d --- /dev/null +++ b/tests/api/marketplace-featured.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMockRequest, parseResponse } from './helpers'; + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@/lib/backend/services/marketplace', () => ({ + FEATURED_MARKETPLACE_CACHE_CONTROL: + 'public, max-age=300, s-maxage=300, stale-while-revalidate=600', + marketplaceService: { + getFeaturedListings: vi.fn(), + }, +})); + +import { GET } from '@/app/api/marketplace/featured/route'; +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { + FEATURED_MARKETPLACE_CACHE_CONTROL, + marketplaceService, +} from '@/lib/backend/services/marketplace'; + +const featuredListing = { + listingId: 'LST-001', + commitmentId: 'CMT-001', + type: 'Safe', + amount: 50000, + remainingDays: 25, + maxLoss: 2, + currentYield: 5.2, + complianceScore: 95, + price: 52000, +}; + +function makeRequest(ip = '203.0.113.10') { + return createMockRequest('http://localhost:3000/api/marketplace/featured', { + headers: { + 'x-forwarded-for': ip, + }, + }); +} + +describe('GET /api/marketplace/featured', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(marketplaceService.getFeaturedListings).mockResolvedValue([ + featuredListing as any, + ]); + }); + + it('returns featured listings with total count and cache/security headers', async () => { + const res = await GET(makeRequest(), { params: {} }, 'corr-featured'); + const { status, data, headers } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual({ + listings: [featuredListing], + total: 1, + }); + expect(headers.get('Cache-Control')).toBe(FEATURED_MARKETPLACE_CACHE_CONTROL); + expect(headers.get('X-Content-Type-Options')).toBe('nosniff'); + expect(headers.get('X-Frame-Options')).toBe('DENY'); + expect(checkRateLimit).toHaveBeenCalledWith( + '203.0.113.10', + 'api/marketplace/featured', + ); + }); + + it('returns an empty featured set with a zero total', async () => { + vi.mocked(marketplaceService.getFeaturedListings).mockResolvedValue([]); + + const res = await GET(makeRequest(), { params: {} }, 'corr-featured-empty'); + const { status, data } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.data).toEqual({ + listings: [], + total: 0, + }); + }); + + it('returns 429 when the route is rate limited', async () => { + vi.mocked(checkRateLimit).mockResolvedValue(false); + + const res = await GET(makeRequest(), { params: {} }, 'corr-featured-limit'); + const { status, data, headers } = await parseResponse(res); + + expect(status).toBe(429); + expect(data.success).toBe(false); + expect(data.error.code).toBe('TOO_MANY_REQUESTS'); + expect(headers.get('Retry-After')).toBe('60'); + expect(marketplaceService.getFeaturedListings).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/marketplace-preflight.test.ts b/tests/api/marketplace-preflight.test.ts new file mode 100644 index 0000000..2f80f88 --- /dev/null +++ b/tests/api/marketplace-preflight.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMockRequest, createMockRouteContext, parseResponse } from './helpers'; + +vi.mock('@/lib/backend/services/marketplace', () => ({ + marketplaceService: { + getPurchasePreflight: vi.fn(), + }, +})); + +vi.mock('@/lib/backend/validation', () => { + class ValidationError extends Error { + constructor( + message: string, + public field?: string, + ) { + super(message); + this.name = 'ValidationError'; + } + } + + return { + ValidationError, + validateAddress: vi.fn((address: string) => { + if (address === 'not-a-stellar-address') { + throw new ValidationError('Invalid Stellar address format', 'address'); + } + + return address; + }), + }; +}); + +import { POST } from '@/app/api/marketplace/listings/[id]/preflight/route'; +import { marketplaceService } from '@/lib/backend/services/marketplace'; +import { validateAddress } from '@/lib/backend/validation'; + +const BUYER_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +function makeRequest(body: Record) { + return createMockRequest( + 'http://localhost:3000/api/marketplace/listings/listing_1/preflight', + { + method: 'POST', + body, + }, + ); +} + +function makeContext(id = 'listing_1') { + return createMockRouteContext({ id }); +} + +describe('POST /api/marketplace/listings/[id]/preflight', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(marketplaceService.getPurchasePreflight).mockResolvedValue({ + eligible: true, + reasons: [], + }); + }); + + it('returns an eligible preflight result for a valid buyer', async () => { + const res = await POST( + makeRequest({ buyerAddress: BUYER_ADDRESS }), + makeContext(), + 'corr-preflight-eligible', + ); + const { status, data } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual({ eligible: true, reasons: [] }); + expect(validateAddress).toHaveBeenCalledWith(BUYER_ADDRESS); + expect(marketplaceService.getPurchasePreflight).toHaveBeenCalledWith( + 'listing_1', + BUYER_ADDRESS, + ); + }); + + it('returns an ineligible result for a sold-out or inactive listing', async () => { + vi.mocked(marketplaceService.getPurchasePreflight).mockResolvedValue({ + eligible: false, + reasons: ['listing_inactive'], + }); + + const res = await POST( + makeRequest({ buyerAddress: BUYER_ADDRESS }), + makeContext('sold_out_listing'), + 'corr-preflight-inactive', + ); + const { status, data } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual({ + eligible: false, + reasons: ['listing_inactive'], + }); + expect(marketplaceService.getPurchasePreflight).toHaveBeenCalledWith( + 'sold_out_listing', + BUYER_ADDRESS, + ); + }); + + it('returns an ineligible result when the buyer is the seller', async () => { + vi.mocked(marketplaceService.getPurchasePreflight).mockResolvedValue({ + eligible: false, + reasons: ['buyer_is_seller'], + }); + + const res = await POST( + makeRequest({ buyerAddress: BUYER_ADDRESS }), + makeContext(), + 'corr-preflight-seller', + ); + const { status, data } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.data.eligible).toBe(false); + expect(data.data.reasons).toContain('buyer_is_seller'); + }); + + it('returns 400 when buyerAddress is missing', async () => { + const res = await POST(makeRequest({}), makeContext(), 'corr-preflight-missing'); + const { status, data } = await parseResponse(res); + + expect(status).toBe(400); + expect(data.success).toBe(false); + expect(data.error.code).toBe('BAD_REQUEST'); + expect(data.error.message).toBe('Missing buyerAddress'); + expect(marketplaceService.getPurchasePreflight).not.toHaveBeenCalled(); + }); + + it('returns 400 when buyerAddress is malformed', async () => { + const res = await POST( + makeRequest({ buyerAddress: 'not-a-stellar-address' }), + makeContext(), + 'corr-preflight-invalid', + ); + const { status, data } = await parseResponse(res); + + expect(status).toBe(400); + expect(data.success).toBe(false); + expect(data.error.code).toBe('BAD_REQUEST'); + expect(data.error.message).toContain('Invalid buyerAddress format'); + expect(marketplaceService.getPurchasePreflight).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/marketplace-stats.test.ts b/tests/api/marketplace-stats.test.ts new file mode 100644 index 0000000..9b0ee5f --- /dev/null +++ b/tests/api/marketplace-stats.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMockRequest, parseResponse } from './helpers'; + +const { mockCache } = vi.hoisted(() => ({ + mockCache: { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + invalidate: vi.fn(), + }, +})); + +vi.mock('@/lib/backend/cache/factory', () => ({ + cache: mockCache, +})); + +vi.mock('@/lib/backend/rateLimit', () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock('@/lib/backend/services/marketplace', () => ({ + marketplaceService: { + getMarketplaceStats: vi.fn(), + }, +})); + +import { GET } from '@/app/api/marketplace/stats/route'; +import { CacheTTL } from '@/lib/backend/cache/index'; +import { checkRateLimit } from '@/lib/backend/rateLimit'; +import { marketplaceService } from '@/lib/backend/services/marketplace'; + +const MARKETPLACE_STATS_KEY = 'commitlabs:marketplace:stats'; + +const marketplaceStats = { + activeListings: 6, + averageYield: 12.43, + medianPrice: 130000, + typeBreakdown: { + Safe: 2, + Balanced: 2, + Aggressive: 2, + }, +}; + +function makeRequest(ip = '203.0.113.20') { + return createMockRequest('http://localhost:3000/api/marketplace/stats', { + headers: { + 'x-forwarded-for': ip, + }, + }); +} + +describe('GET /api/marketplace/stats', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue(true); + mockCache.get.mockResolvedValue(null); + mockCache.set.mockResolvedValue(undefined); + vi.mocked(marketplaceService.getMarketplaceStats).mockResolvedValue( + marketplaceStats as any, + ); + }); + + it('returns cached stats and marks the response as a cache hit', async () => { + mockCache.get.mockResolvedValue(marketplaceStats); + + const res = await GET(makeRequest(), { params: {} }, 'corr-stats-hit'); + const { status, data, headers } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toEqual(marketplaceStats); + expect(headers.get('X-Cache')).toBe('HIT'); + expect(headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, stale-while-revalidate=30', + ); + expect(mockCache.get).toHaveBeenCalledWith(MARKETPLACE_STATS_KEY); + expect(marketplaceService.getMarketplaceStats).not.toHaveBeenCalled(); + expect(mockCache.set).not.toHaveBeenCalled(); + }); + + it('fetches, caches, and returns stats on a cache miss', async () => { + const res = await GET(makeRequest(), { params: {} }, 'corr-stats-miss'); + const { status, data, headers } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.data).toEqual(marketplaceStats); + expect(headers.get('X-Cache')).toBe('MISS'); + expect(marketplaceService.getMarketplaceStats).toHaveBeenCalledTimes(1); + expect(mockCache.set).toHaveBeenCalledWith( + MARKETPLACE_STATS_KEY, + marketplaceStats, + CacheTTL.MARKETPLACE_STATS, + ); + }); + + it('returns zero-listing stats with the same response shape', async () => { + const zeroStats = { + activeListings: 0, + averageYield: 0, + medianPrice: 0, + typeBreakdown: { + Safe: 0, + Balanced: 0, + Aggressive: 0, + }, + }; + vi.mocked(marketplaceService.getMarketplaceStats).mockResolvedValue( + zeroStats as any, + ); + + const res = await GET(makeRequest(), { params: {} }, 'corr-stats-zero'); + const { status, data, headers } = await parseResponse(res); + + expect(status).toBe(200); + expect(data.data).toEqual(zeroStats); + expect(headers.get('X-Cache')).toBe('MISS'); + expect(mockCache.set).toHaveBeenCalledWith( + MARKETPLACE_STATS_KEY, + zeroStats, + CacheTTL.MARKETPLACE_STATS, + ); + }); + + it('returns 429 without reading cache when rate limited', async () => { + vi.mocked(checkRateLimit).mockResolvedValue(false); + + const res = await GET(makeRequest(), { params: {} }, 'corr-stats-limit'); + const { status, data } = await parseResponse(res); + + expect(status).toBe(429); + expect(data.success).toBe(false); + expect(data.error.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(mockCache.get).not.toHaveBeenCalled(); + expect(marketplaceService.getMarketplaceStats).not.toHaveBeenCalled(); + }); +});