From 67886fdbc357cb11df6cabda9defa866f3f6f978 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 2 Apr 2025 15:08:23 +0000 Subject: [PATCH] Fix issue #4: Book API source model --- src/services/bookApiSourceService.ts | 146 +++++++++ .../services/bookApiSourceService.test.ts | 286 ++++++++++++++++++ src/types/book.ts | 13 +- .../20250402000000_add_book_api_sources.sql | 38 +++ 4 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src/services/bookApiSourceService.ts create mode 100644 src/test/services/bookApiSourceService.test.ts create mode 100644 supabase/migrations/20250402000000_add_book_api_sources.sql diff --git a/src/services/bookApiSourceService.ts b/src/services/bookApiSourceService.ts new file mode 100644 index 0000000..270d297 --- /dev/null +++ b/src/services/bookApiSourceService.ts @@ -0,0 +1,146 @@ +import { supabase } from '@/lib/supabase'; +import { BookApiSource } from '@/types/book'; + +/** + * Fetches all available book API sources + */ +export const getBookApiSources = async (): Promise => { + const { data, error } = await supabase + .from('book_api_sources') + .select('*') + .order('name'); + + if (error) { + console.error('Error fetching book API sources:', error); + throw new Error(`Failed to fetch book API sources: ${error.message}`); + } + + return data.map(source => ({ + id: source.id, + name: source.name, + code: source.code, + baseUrl: source.base_url, + apiKey: source.api_key, + isActive: source.is_active, + createdAt: source.created_at, + updatedAt: source.updated_at + })); +}; + +/** + * Fetches a single book API source by its code + */ +export const getBookApiSourceByCode = async (code: string): Promise => { + const { data, error } = await supabase + .from('book_api_sources') + .select('*') + .eq('code', code) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + // No rows returned + return null; + } + console.error(`Error fetching book API source with code ${code}:`, error); + throw new Error(`Failed to fetch book API source: ${error.message}`); + } + + return { + id: data.id, + name: data.name, + code: data.code, + baseUrl: data.base_url, + apiKey: data.api_key, + isActive: data.is_active, + createdAt: data.created_at, + updatedAt: data.updated_at + }; +}; + +/** + * Creates a new book API source + * Note: This function should only be accessible to admin users + */ +export const createBookApiSource = async (source: Omit): Promise => { + const { data, error } = await supabase + .from('book_api_sources') + .insert({ + name: source.name, + code: source.code, + base_url: source.baseUrl, + api_key: source.apiKey, + is_active: source.isActive + }) + .select() + .single(); + + if (error) { + console.error('Error creating book API source:', error); + throw new Error(`Failed to create book API source: ${error.message}`); + } + + return { + id: data.id, + name: data.name, + code: data.code, + baseUrl: data.base_url, + apiKey: data.api_key, + isActive: data.is_active, + createdAt: data.created_at, + updatedAt: data.updated_at + }; +}; + +/** + * Updates an existing book API source + * Note: This function should only be accessible to admin users + */ +export const updateBookApiSource = async (id: string, updates: Partial>): Promise => { + const updateData: Record = {}; + + if (updates.name !== undefined) updateData.name = updates.name; + if (updates.code !== undefined) updateData.code = updates.code; + if (updates.baseUrl !== undefined) updateData.base_url = updates.baseUrl; + if (updates.apiKey !== undefined) updateData.api_key = updates.apiKey; + if (updates.isActive !== undefined) updateData.is_active = updates.isActive; + + const { data, error } = await supabase + .from('book_api_sources') + .update(updateData) + .eq('id', id) + .select() + .single(); + + if (error) { + console.error(`Error updating book API source with ID ${id}:`, error); + throw new Error(`Failed to update book API source: ${error.message}`); + } + + return { + id: data.id, + name: data.name, + code: data.code, + baseUrl: data.base_url, + apiKey: data.api_key, + isActive: data.is_active, + createdAt: data.created_at, + updatedAt: data.updated_at + }; +}; + +/** + * Deletes a book API source + * Note: This function should only be accessible to admin users + */ +export const deleteBookApiSource = async (id: string): Promise => { + const { error } = await supabase + .from('book_api_sources') + .delete() + .eq('id', id); + + if (error) { + console.error(`Error deleting book API source with ID ${id}:`, error); + throw new Error(`Failed to delete book API source: ${error.message}`); + } +}; diff --git a/src/test/services/bookApiSourceService.test.ts b/src/test/services/bookApiSourceService.test.ts new file mode 100644 index 0000000..001a198 --- /dev/null +++ b/src/test/services/bookApiSourceService.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getBookApiSources, getBookApiSourceByCode, createBookApiSource, updateBookApiSource, deleteBookApiSource } from '@/services/bookApiSourceService'; +import { supabase } from '@/lib/supabase'; + +// Mock the Supabase client +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + single: vi.fn().mockReturnThis(), + } +})); + +describe('Book API Source Service', () => { + const mockSources = [ + { + id: '1', + name: 'Google Books API', + code: 'google', + base_url: 'https://www.googleapis.com/books/v1', + api_key: null, + is_active: true, + created_at: '2025-04-02T00:00:00Z', + updated_at: '2025-04-02T00:00:00Z' + }, + { + id: '2', + name: 'Open Library API', + code: 'openlibrary', + base_url: 'https://openlibrary.org/api', + api_key: null, + is_active: true, + created_at: '2025-04-02T00:00:00Z', + updated_at: '2025-04-02T00:00:00Z' + } + ]; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('getBookApiSources', () => { + it('should fetch all book API sources', async () => { + // Mock the Supabase response + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: mockSources, + error: null + }) + }) + } as any); + + const sources = await getBookApiSources(); + + expect(supabase.from).toHaveBeenCalledWith('book_api_sources'); + expect(sources).toHaveLength(2); + expect(sources[0].code).toBe('google'); + expect(sources[1].code).toBe('openlibrary'); + }); + + it('should throw an error if the fetch fails', async () => { + // Mock the Supabase error response + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + }) + } as any); + + await expect(getBookApiSources()).rejects.toThrow('Failed to fetch book API sources: Database error'); + }); + }); + + describe('getBookApiSourceByCode', () => { + it('should fetch a book API source by code', async () => { + // Mock the Supabase response + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: mockSources[0], + error: null + }) + }) + }) + } as any); + + const source = await getBookApiSourceByCode('google'); + + expect(supabase.from).toHaveBeenCalledWith('book_api_sources'); + expect(source?.code).toBe('google'); + expect(source?.name).toBe('Google Books API'); + }); + + it('should return null if no source is found', async () => { + // Mock the Supabase error response for no rows + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: null, + error: { code: 'PGRST116', message: 'No rows returned' } + }) + }) + }) + } as any); + + const source = await getBookApiSourceByCode('nonexistent'); + + expect(source).toBeNull(); + }); + + it('should throw an error for other errors', async () => { + // Mock the Supabase error response + vi.mocked(supabase.from).mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: null, + error: { code: 'OTHER', message: 'Database error' } + }) + }) + }) + } as any); + + await expect(getBookApiSourceByCode('google')).rejects.toThrow('Failed to fetch book API source: Database error'); + }); + }); + + describe('createBookApiSource', () => { + it('should create a new book API source', async () => { + const newSource = { + name: 'New API', + code: 'newapi', + baseUrl: 'https://api.example.com', + isActive: true + }; + + const createdSource = { + id: '3', + name: 'New API', + code: 'newapi', + base_url: 'https://api.example.com', + api_key: null, + is_active: true, + created_at: '2025-04-02T00:00:00Z', + updated_at: '2025-04-02T00:00:00Z' + }; + + // Mock the Supabase response + vi.mocked(supabase.from).mockReturnValue({ + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: createdSource, + error: null + }) + }) + }) + } as any); + + const source = await createBookApiSource(newSource); + + expect(supabase.from).toHaveBeenCalledWith('book_api_sources'); + expect(source.id).toBe('3'); + expect(source.code).toBe('newapi'); + }); + + it('should throw an error if creation fails', async () => { + // Mock the Supabase error response + vi.mocked(supabase.from).mockReturnValue({ + insert: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + }) + }) + } as any); + + await expect(createBookApiSource({ + name: 'New API', + code: 'newapi', + baseUrl: 'https://api.example.com', + isActive: true + })).rejects.toThrow('Failed to create book API source: Database error'); + }); + }); + + describe('updateBookApiSource', () => { + it('should update a book API source', async () => { + const updates = { + name: 'Updated API', + isActive: false + }; + + const updatedSource = { + id: '1', + name: 'Updated API', + code: 'google', + base_url: 'https://www.googleapis.com/books/v1', + api_key: null, + is_active: false, + created_at: '2025-04-02T00:00:00Z', + updated_at: '2025-04-02T00:00:00Z' + }; + + // Mock the Supabase response + vi.mocked(supabase.from).mockReturnValue({ + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: updatedSource, + error: null + }) + }) + }) + }) + } as any); + + const source = await updateBookApiSource('1', updates); + + expect(supabase.from).toHaveBeenCalledWith('book_api_sources'); + expect(source.id).toBe('1'); + expect(source.name).toBe('Updated API'); + expect(source.isActive).toBe(false); + }); + + it('should throw an error if update fails', async () => { + // Mock the Supabase error response + vi.mocked(supabase.from).mockReturnValue({ + update: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + }) + }) + }) + } as any); + + await expect(updateBookApiSource('1', { name: 'Updated API' })).rejects.toThrow('Failed to update book API source: Database error'); + }); + }); + + describe('deleteBookApiSource', () => { + it('should delete a book API source', async () => { + // Mock the Supabase response + vi.mocked(supabase.from).mockReturnValue({ + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + error: null + }) + }) + } as any); + + await expect(deleteBookApiSource('1')).resolves.not.toThrow(); + expect(supabase.from).toHaveBeenCalledWith('book_api_sources'); + }); + + it('should throw an error if deletion fails', async () => { + // Mock the Supabase error response + vi.mocked(supabase.from).mockReturnValue({ + delete: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + error: { message: 'Database error' } + }) + }) + } as any); + + await expect(deleteBookApiSource('1')).rejects.toThrow('Failed to delete book API source: Database error'); + }); + }); +}); diff --git a/src/types/book.ts b/src/types/book.ts index 0b6d919..ce92011 100644 --- a/src/types/book.ts +++ b/src/types/book.ts @@ -6,7 +6,18 @@ export interface Author { books?: Book[]; } -export type BookSource = 'google' | 'libro'; +export interface BookApiSource { + id: string; + name: string; + code: string; + baseUrl: string; + apiKey?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export type BookSource = string; export interface Book { source: BookSource; diff --git a/supabase/migrations/20250402000000_add_book_api_sources.sql b/supabase/migrations/20250402000000_add_book_api_sources.sql new file mode 100644 index 0000000..76fb302 --- /dev/null +++ b/supabase/migrations/20250402000000_add_book_api_sources.sql @@ -0,0 +1,38 @@ +-- Create book_api_sources table +CREATE TABLE book_api_sources ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + code TEXT NOT NULL UNIQUE, + base_url TEXT NOT NULL, + api_key TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Enable RLS +ALTER TABLE book_api_sources ENABLE ROW LEVEL SECURITY; + +-- Create policies +CREATE POLICY "Book API sources are viewable by everyone" ON book_api_sources FOR +SELECT USING (true); + +CREATE POLICY "Only admins can manage book API sources" ON book_api_sources FOR ALL USING ( + auth.jwt() ->> 'role' = 'admin' +); + +-- Create trigger for updated_at +CREATE TRIGGER set_book_api_sources_timestamp BEFORE +UPDATE ON book_api_sources FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp(); + +-- Insert default sources +INSERT INTO book_api_sources (name, code, base_url, api_key) VALUES +('Google Books API', 'google', 'https://www.googleapis.com/books/v1', NULL), +('Open Library API', 'openlibrary', 'https://openlibrary.org/api', NULL), +('Libro Database', 'libro', '', NULL); + +-- Modify user_books table to use the new source codes +ALTER TABLE user_books DROP CONSTRAINT valid_source; +ALTER TABLE user_books ADD CONSTRAINT valid_source CHECK ( + source IN (SELECT code FROM book_api_sources) +);