From ece44a54a38fb3ffe7e7a12becf5ac21464a96ff Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 19 Sep 2025 23:06:46 -0700 Subject: [PATCH] feat: add search adapter for entity searches - Implement SearchAdapter extending BaseAdapter - Support searching weapons, characters, and summons - Add filtering by element, rarity, proficiency, etc. - Include pagination support - Add comprehensive test coverage --- .../adapters/__tests__/search.adapter.test.ts | 427 ++++++++++++++++++ src/lib/api/adapters/index.ts | 22 + src/lib/api/adapters/search.adapter.ts | 296 ++++++++++++ 3 files changed, 745 insertions(+) create mode 100644 src/lib/api/adapters/__tests__/search.adapter.test.ts create mode 100644 src/lib/api/adapters/index.ts create mode 100644 src/lib/api/adapters/search.adapter.ts diff --git a/src/lib/api/adapters/__tests__/search.adapter.test.ts b/src/lib/api/adapters/__tests__/search.adapter.test.ts new file mode 100644 index 00000000..4802f9ec --- /dev/null +++ b/src/lib/api/adapters/__tests__/search.adapter.test.ts @@ -0,0 +1,427 @@ +/** + * Tests for SearchAdapter + * + * These tests verify search functionality including filtering, + * pagination, and proper request/response handling. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { SearchAdapter } from '../search.adapter' +import type { SearchParams, SearchResponse } from '../search.adapter' + +describe('SearchAdapter', () => { + let adapter: SearchAdapter + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + adapter = new SearchAdapter({ + baseURL: 'https://api.example.com' + }) + }) + + afterEach(() => { + global.fetch = originalFetch + adapter.cancelAll() + }) + + describe('searchAll', () => { + it('should search across all entity types', async () => { + const mockResponse: SearchResponse = { + results: [ + { + id: '1', + granblueId: 'weapon_1', + name: { en: 'Bahamut Sword', ja: 'バハムートソード' }, + element: 1, + rarity: 5, + searchableType: 'Weapon', + imageUrl: 'https://example.com/weapon1.jpg' + }, + { + id: '2', + granblueId: 'character_1', + name: { en: 'Bahamut', ja: 'バハムート' }, + element: 6, + rarity: 5, + searchableType: 'Character', + imageUrl: 'https://example.com/character1.jpg' + } + ], + total: 2, + page: 1, + totalPages: 1, + meta: { + count: 2, + page: 1, + perPage: 20, + totalPages: 1 + } + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + id: '1', + granblue_id: 'weapon_1', + name: { en: 'Bahamut Sword', ja: 'バハムートソード' }, + element: 1, + rarity: 5, + searchable_type: 'Weapon', + image_url: 'https://example.com/weapon1.jpg' + }, + { + id: '2', + granblue_id: 'character_1', + name: { en: 'Bahamut', ja: 'バハムート' }, + element: 6, + rarity: 5, + searchable_type: 'Character', + image_url: 'https://example.com/character1.jpg' + } + ], + total: 2, + page: 1, + total_pages: 1, + meta: { + count: 2, + page: 1, + per_page: 20, + total_pages: 1 + } + }) + }) + + const params: SearchParams = { + query: 'bahamut', + locale: 'en', + page: 1 + } + + const result = await adapter.searchAll(params) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/search/all', + expect.objectContaining({ + method: 'POST', + credentials: 'omit', + body: JSON.stringify({ + locale: 'en', + page: 1, + query: 'bahamut' + }) + }) + ) + + expect(result).toEqual(mockResponse) + }) + + it('should include filters when provided', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'sword', + filters: { + element: [1, 2], + rarity: [5], + extra: true + } + } + + await adapter.searchAll(params) + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', + page: 1, + query: 'sword', + filters: { + element: [1, 2], + rarity: [5], + extra: true + } + }) + }) + ) + }) + + it('should handle exclude parameter', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'test', + exclude: ['1', '2', '3'] + } + + await adapter.searchAll(params) + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', + page: 1, + query: 'test', + exclude: ['1', '2', '3'] + }) + }) + ) + }) + + it('should cache results when query is provided', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [{ id: '1' }] }) + }) + + // First call + await adapter.searchAll({ query: 'test' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Second call should use cache + await adapter.searchAll({ query: 'test' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it('should not cache results when query is empty', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + // First call + await adapter.searchAll({}) + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Second call should not use cache + await adapter.searchAll({}) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + }) + + describe('searchWeapons', () => { + it('should search for weapons with appropriate filters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'sword', + filters: { + element: [1], + proficiency1: [2], + extra: false + }, + per: 50 + } + + await adapter.searchWeapons(params) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/search/weapons', + expect.objectContaining({ + method: 'POST', + credentials: 'omit', + body: JSON.stringify({ + locale: 'en', + page: 1, + per: 50, + query: 'sword', + filters: { + element: [1], + proficiency1: [2], + extra: false + } + }) + }) + ) + }) + + it('should not include character-specific filters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'test', + filters: { + proficiency2: [1], // Character-only filter + subaura: true // Summon-only filter + } + } + + await adapter.searchWeapons(params) + + const calledBody = JSON.parse((global.fetch as any).mock.calls[0][1].body) + expect(calledBody.filters).toBeUndefined() + }) + }) + + describe('searchCharacters', () => { + it('should search for characters with appropriate filters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'katalina', + filters: { + element: [2], + proficiency1: [1], + proficiency2: [3] + } + } + + await adapter.searchCharacters(params) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/search/characters', + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', + page: 1, + query: 'katalina', + filters: { + element: [2], + proficiency1: [1], + proficiency2: [3] + } + }) + }) + ) + }) + }) + + describe('searchSummons', () => { + it('should search for summons with appropriate filters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + const params: SearchParams = { + query: 'bahamut', + filters: { + element: [6], + rarity: [5], + subaura: true + } + } + + await adapter.searchSummons(params) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/search/summons', + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', + page: 1, + query: 'bahamut', + filters: { + element: [6], + rarity: [5], + subaura: true + } + }) + }) + ) + }) + }) + + describe('clearSearchCache', () => { + it('should clear cached search results', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [{ id: '1' }] }) + }) + + // Cache a search + await adapter.searchAll({ query: 'test' }) + expect(global.fetch).toHaveBeenCalledTimes(1) + + // Clear cache + adapter.clearSearchCache() + + // Should fetch again + await adapter.searchAll({ query: 'test' }) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + }) + + describe('error handling', () => { + it('should handle server errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'Server error' }) + }) + + await expect(adapter.searchAll({ query: 'test' })).rejects.toMatchObject({ + code: 'SERVER_ERROR', + status: 500 + }) + }) + + it('should handle network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')) + + await expect(adapter.searchAll({ query: 'test' })).rejects.toMatchObject({ + code: 'UNKNOWN_ERROR' + }) + }) + }) + + describe('pagination', () => { + it('should handle pagination parameters', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [], + page: 2, + total_pages: 5, + meta: { + count: 0, + page: 2, + per_page: 20, + total_pages: 5 + } + }) + }) + + const result = await adapter.searchAll({ + query: 'test', + page: 2, + per: 20 + }) + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', + page: 2, + per: 20, + query: 'test' + }) + }) + ) + + expect(result.page).toBe(2) + expect(result.totalPages).toBe(5) + }) + }) +}) \ No newline at end of file diff --git a/src/lib/api/adapters/index.ts b/src/lib/api/adapters/index.ts new file mode 100644 index 00000000..2aa0c978 --- /dev/null +++ b/src/lib/api/adapters/index.ts @@ -0,0 +1,22 @@ +/** + * Main export file for the adapter system + * + * This module re-exports all public APIs from the adapter system, + * providing a single entry point for consumers. + * + * @module adapters + */ + +// Core exports +export { BaseAdapter } from './base.adapter' +export * from './types' +export * from './errors' + +// Resource-specific adapters +export { SearchAdapter, searchAdapter } from './search.adapter' +export type { SearchParams, SearchResult, SearchResponse } from './search.adapter' +// export { PartyAdapter } from './party.adapter' +// export { GridAdapter } from './grid.adapter' + +// Reactive resources using Svelte 5 runes +export * from './resources' \ No newline at end of file diff --git a/src/lib/api/adapters/search.adapter.ts b/src/lib/api/adapters/search.adapter.ts new file mode 100644 index 00000000..5ca8ac11 --- /dev/null +++ b/src/lib/api/adapters/search.adapter.ts @@ -0,0 +1,296 @@ +/** + * Search Adapter for Entity Search Operations + * + * Handles all search-related API calls for weapons, characters, and summons. + * Provides unified interface with automatic transformation and error handling. + * + * @module adapters/search + */ + +import { BaseAdapter } from './base.adapter' +import type { AdapterOptions, SearchFilters } from './types' + +/** + * Search parameters for entity queries + * Used across all search methods + */ +export interface SearchParams { + /** Search query string */ + query?: string + /** Locale for search results */ + locale?: 'en' | 'ja' + /** Entity IDs to exclude from results */ + exclude?: string[] + /** Page number for pagination */ + page?: number + /** Number of results per page */ + per?: number + /** Search filters */ + filters?: SearchFilters +} + +/** + * Individual search result item + * Represents a weapon, character, or summon + */ +export interface SearchResult { + /** Unique entity ID */ + id: string + /** Granblue game ID */ + granblueId: string + /** Localized names */ + name: { + en?: string + ja?: string + } + /** Element type (1-6 for different elements) */ + element?: number + /** Rarity level */ + rarity?: number + /** Weapon/Character proficiency */ + proficiency?: number + /** Series ID for categorization */ + series?: number + /** URL for entity image */ + imageUrl?: string + /** Type of entity */ + searchableType: 'Weapon' | 'Character' | 'Summon' +} + +/** + * Search API response structure + * Contains results and pagination metadata + */ +export interface SearchResponse { + /** Array of search results */ + results: SearchResult[] + /** Total number of results */ + total?: number + /** Current page number */ + page?: number + /** Total number of pages */ + totalPages?: number + /** Pagination metadata */ + meta?: { + count: number + page: number + perPage: number + totalPages: number + } +} + +/** + * Adapter for search-related API operations + * Handles entity search with filtering, pagination, and caching + * + * @example + * ```typescript + * const searchAdapter = new SearchAdapter() + * + * // Search for fire weapons + * const weapons = await searchAdapter.searchWeapons({ + * query: 'sword', + * filters: { element: [1] } + * }) + * + * // Search across all entity types + * const results = await searchAdapter.searchAll({ + * query: 'bahamut', + * page: 1 + * }) + * ``` + */ +export class SearchAdapter extends BaseAdapter { + /** + * Creates a new SearchAdapter instance + * + * @param options - Adapter configuration options + */ + constructor(options?: AdapterOptions) { + super({ + ...options, + // Search endpoints don't use credentials to avoid CORS issues + // This is handled per-request instead + }) + } + + /** + * Builds search request body from parameters + * Handles filtering logic and defaults + * + * @param params - Search parameters + * @param includeFilters - Which filters to include + * @returns Request body object + */ + private buildSearchBody( + params: SearchParams, + includeFilters: { + element?: boolean + rarity?: boolean + proficiency1?: boolean + proficiency2?: boolean + series?: boolean + extra?: boolean + subaura?: boolean + } = {} + ): any { + const body: any = { + locale: params.locale || 'en', + page: params.page || 1 + } + + // Only include per if specified + if (params.per) { + body.per = params.per + } + + // Only include query if provided and not empty + if (params.query) { + body.query = params.query + } + + // Only include exclude if provided + if (params.exclude?.length) { + body.exclude = params.exclude + } + + // Build filters based on what's allowed for this search type + if (params.filters) { + const filters: any = {} + + if (includeFilters.element && params.filters.element?.length) { + filters.element = params.filters.element + } + if (includeFilters.rarity && params.filters.rarity?.length) { + filters.rarity = params.filters.rarity + } + if (includeFilters.proficiency1 && params.filters.proficiency1?.length) { + filters.proficiency1 = params.filters.proficiency1 + } + if (includeFilters.proficiency2 && params.filters.proficiency2?.length) { + filters.proficiency2 = params.filters.proficiency2 + } + if (includeFilters.series && params.filters.series?.length) { + filters.series = params.filters.series + } + if (includeFilters.extra && params.filters.extra !== undefined) { + filters.extra = params.filters.extra + } + if (includeFilters.subaura && params.filters.subaura !== undefined) { + filters.subaura = params.filters.subaura + } + + if (Object.keys(filters).length > 0) { + body.filters = filters + } + } + + return body + } + + /** + * Searches across all entity types (weapons, characters, summons) + * + * @param params - Search parameters + * @returns Promise resolving to search results + */ + async searchAll(params: SearchParams = {}): Promise { + const body = this.buildSearchBody(params, { + element: true, + rarity: true, + proficiency1: true, + proficiency2: true, + series: true, + extra: true, + subaura: true + }) + + // Search endpoints don't use credentials to avoid CORS + return this.request('/search/all', { + method: 'POST', + body: JSON.stringify(body), + credentials: 'omit', + // Cache search results for 5 minutes by default + cache: params.query ? 300000 : 0 // Don't cache empty searches + }) + } + + /** + * Searches for weapons with specific filters + * + * @param params - Search parameters + * @returns Promise resolving to weapon search results + */ + async searchWeapons(params: SearchParams = {}): Promise { + const body = this.buildSearchBody(params, { + element: true, + rarity: true, + proficiency1: true, + extra: true + }) + + return this.request('/search/weapons', { + method: 'POST', + body: JSON.stringify(body), + credentials: 'omit', + cache: params.query ? 300000 : 0 + }) + } + + /** + * Searches for characters with specific filters + * + * @param params - Search parameters + * @returns Promise resolving to character search results + */ + async searchCharacters(params: SearchParams = {}): Promise { + const body = this.buildSearchBody(params, { + element: true, + rarity: true, + proficiency1: true, + proficiency2: true + }) + + return this.request('/search/characters', { + method: 'POST', + body: JSON.stringify(body), + credentials: 'omit', + cache: params.query ? 300000 : 0 + }) + } + + /** + * Searches for summons with specific filters + * + * @param params - Search parameters + * @returns Promise resolving to summon search results + */ + async searchSummons(params: SearchParams = {}): Promise { + const body = this.buildSearchBody(params, { + element: true, + rarity: true, + subaura: true + }) + + return this.request('/search/summons', { + method: 'POST', + body: JSON.stringify(body), + credentials: 'omit', + cache: params.query ? 300000 : 0 + }) + } + + /** + * Clears all cached search results + * Useful when entity data has been updated + */ + clearSearchCache(): void { + this.clearCache('search') + } +} + +/** + * Default singleton instance for search operations + * Use this for most search needs unless you need custom configuration + */ +export const searchAdapter = new SearchAdapter() \ No newline at end of file