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
This commit is contained in:
Justin Edmund 2025-09-19 23:06:46 -07:00
parent 51c30edc50
commit ece44a54a3
3 changed files with 745 additions and 0 deletions

View file

@ -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)
})
})
})

View file

@ -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'

View file

@ -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<SearchResponse> {
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<SearchResponse>('/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<SearchResponse> {
const body = this.buildSearchBody(params, {
element: true,
rarity: true,
proficiency1: true,
extra: true
})
return this.request<SearchResponse>('/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<SearchResponse> {
const body = this.buildSearchBody(params, {
element: true,
rarity: true,
proficiency1: true,
proficiency2: true
})
return this.request<SearchResponse>('/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<SearchResponse> {
const body = this.buildSearchBody(params, {
element: true,
rarity: true,
subaura: true
})
return this.request<SearchResponse>('/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()