diff --git a/cmd/server/tests/integration_test.go b/cmd/server/tests/integration_test.go index 6c323a9..09b2e1a 100644 --- a/cmd/server/tests/integration_test.go +++ b/cmd/server/tests/integration_test.go @@ -1,5 +1,7 @@ package main +package main + import ( "bytes" "encoding/json" @@ -29,7 +31,6 @@ type TestContext struct { AdminID string UserID string LibraryID string - EbookID string MediaItemID string NoteID string HighlightID string @@ -358,17 +359,44 @@ func TestIntegrationAPI(t *testing.T) { testLibraries(t, ctx) }) - t.Run("Ebooks", func(t *testing.T) { - testEbooks(t, ctx) - }) + t.Run("MediaItems", func(t *testing.T) { + testMediaItems(t, ctx) + }) - t.Run("MediaItems", func(t *testing.T) { - testMediaItems(t, ctx) - }) + t.Run("Admin", func(t *testing.T) { + testAdmin(t, ctx) + }) + } + } - t.Run("Admin", func(t *testing.T) { - testAdmin(t, ctx) - }) + func testAuthentication(t *testing.T, ctx *TestContext) { + t.Run("Register_DuplicateEmail", func(t *testing.T) { + req := map[string]interface{}{ + "email": "test@example.com", + "username": "newuser123", + "password": "Password123!", + } + + resp := makeRequest(t, "POST", "/api/auth/register", req, "") + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + }) + + t.Run("Register_DuplicateUsername", func(t *testing.T) { + req := map[string]interface{}{ + "email": "newemail@example.com", + "username": "testuser", + "password": "Password123!", + } + + resp := makeRequest(t, "POST", "/api/auth/register", req, "") + + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + }) + } } func testAuthentication(t *testing.T, ctx *TestContext) { @@ -683,114 +711,6 @@ func testLibraries(t *testing.T, ctx *TestContext) { }) } -func testEbooks(t *testing.T, ctx *TestContext) { - t.Run("ListEbooks", func(t *testing.T) { - resp := makeRequest(t, "GET", "/api/ebooks", nil, ctx.UserToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("CreateEbook_Admin", func(t *testing.T) { - req := map[string]interface{}{ - "title": "Integration Test Ebook", - "author": "Test Author", - "isbn": "9999999999", - "description": "Created during integration test", - "file_path": "/tmp/test_integration.epub", - "file_size": 1024, - "mime_type": "application/epub+zip", - "publisher": "Test Publisher", - "date_published": "2024-01-01", - } - - resp := makeRequest(t, "POST", "/api/ebooks", req, ctx.AdminToken) - defer resp.Body.Close() - - if resp.StatusCode == http.StatusCreated { - var ebook map[string]interface{} - body, _ := io.ReadAll(resp.Body) - err := json.Unmarshal(body, &ebook) - require.NoError(t, err) - - ctx.EbookID = ebook["id"].(string) - } - }) - - t.Run("CreateEbook_UserForbidden", func(t *testing.T) { - req := map[string]interface{}{ - "title": "User Ebook", - "file_path": "/tmp/user.epub", - "file_size": 1024, - "mime_type": "application/epub+zip", - } - - resp := makeRequest(t, "POST", "/api/ebooks", req, ctx.UserToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusForbidden, resp.StatusCode) - }) - - if ctx.EbookID != "" { - t.Run("GetEbook", func(t *testing.T) { - resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s", ctx.EbookID), nil, ctx.UserToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("UpdateEbook_Admin", func(t *testing.T) { - req := map[string]interface{}{ - "title": "Updated Ebook Title", - "description": "Updated during test", - } - - resp := makeRequest(t, "PUT", fmt.Sprintf("/api/ebooks/%s", ctx.EbookID), req, ctx.AdminToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("CreateEbookRating", func(t *testing.T) { - req := map[string]interface{}{ - "rating": 5, - } - - resp := makeRequest(t, "POST", fmt.Sprintf("/api/ebooks/%s/rating", ctx.EbookID), req, ctx.UserToken) - defer resp.Body.Close() - - assert.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK) - }) - - t.Run("GetEbookRating", func(t *testing.T) { - resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s/rating", ctx.EbookID), nil, ctx.UserToken) - defer resp.Body.Close() - - assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) - }) - - t.Run("UpdateReadingProgress", func(t *testing.T) { - req := map[string]interface{}{ - "progress_percentage": 50, - "current_page": 125, - "total_pages": 250, - } - - resp := makeRequest(t, "PUT", fmt.Sprintf("/api/ebooks/%s/progress", ctx.EbookID), req, ctx.UserToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("GetReadingProgress", func(t *testing.T) { - resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s/progress", ctx.EbookID), nil, ctx.UserToken) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - }) - } -} - func testMediaItems(t *testing.T, ctx *TestContext) { t.Run("ListMediaItems", func(t *testing.T) { resp := makeRequest(t, "GET", "/api/media-items", nil, ctx.UserToken) @@ -821,7 +741,77 @@ func testMediaItems(t *testing.T, ctx *TestContext) { } }) + // Admin operations + t.Run("CreateMediaItem_Admin", func(t *testing.T) { + if ctx.LibraryID == "" { + t.Skip("No library available for creating media item") + } + + req := map[string]interface{}{ + "library_id": ctx.LibraryID, + "title": "Integration Test Media Item", + "author": "Test Author", + "isbn": "9999999999", + "description": "Created during integration test", + "file_path": "/tmp/test_integration.epub", + "file_size": int64(1024), + "mime_type": "application/epub+zip", + "publisher": "Test Publisher", + "date_published": "2024-01-01", + } + + resp := makeRequest(t, "POST", "/api/media-items", req, ctx.AdminToken) + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + var mediaItem map[string]interface{} + body, _ := io.ReadAll(resp.Body) + err := json.Unmarshal(body, &mediaItem) + require.NoError(t, err) + + ctx.MediaItemID = mediaItem["id"].(string) + } + }) + + t.Run("CreateMediaItem_UserForbidden", func(t *testing.T) { + if ctx.LibraryID == "" { + t.Skip("No library available") + } + + req := map[string]interface{}{ + "library_id": ctx.LibraryID, + "title": "User Media Item", + "file_path": "/tmp/user.epub", + "file_size": int64(1024), + "mime_type": "application/epub+zip", + } + + resp := makeRequest(t, "POST", "/api/media-items", req, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + if ctx.MediaItemID != "" { + t.Run("UpdateMediaItem_Admin", func(t *testing.T) { + req := map[string]interface{}{ + "title": "Updated Media Item Title", + "description": "Updated during test", + } + + resp := makeRequest(t, "PUT", fmt.Sprintf("/api/media-items/%s", ctx.MediaItemID), req, ctx.AdminToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("DeleteMediaItem_Admin", func(t *testing.T) { + resp := makeRequest(t, "DELETE", fmt.Sprintf("/api/media-items/%s", ctx.MediaItemID), nil, ctx.AdminToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + t.Run("GetMediaItem", func(t *testing.T) { resp := makeRequest(t, "GET", fmt.Sprintf("/api/media-items/%s", ctx.MediaItemID), nil, ctx.UserToken) defer resp.Body.Close() diff --git a/database/schema/schema.sql b/database/schema/schema.sql index f3f958e..0743e4a 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1,9 +1,6 @@ -- Consolidated Bookmann Database Schema -- This file contains the complete current schema for the Bookmann media library management system --- Enable pg_trgm extension for fuzzy string matching and partial search -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -- Create library types table CREATE TABLE library_types ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -13,6 +10,101 @@ CREATE TABLE library_types ( created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- Insert default library types +INSERT INTO library_types (name, description, allowed_extensions) VALUES +('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']), +('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.pdf']), +('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']); + +-- Create users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(255), + last_name VARCHAR(255), + role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), + theme VARCHAR(50) DEFAULT 'tokyo-night', + scan_frequency_minutes INTEGER DEFAULT 60, + auto_scan_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create refresh_tokens table +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + revoked_at TIMESTAMP WITH TIME ZONE +); + +-- Create libraries table +CREATE TABLE libraries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + library_type_id UUID NOT NULL REFERENCES library_types(id) ON DELETE RESTRICT, + created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create library_folders table for multiple folders per library +CREATE TABLE library_folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + folder_path VARCHAR(500) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(library_id, folder_path) +); + +-- Create library_visibility table for user-specific library visibility +CREATE TABLE library_visibility ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + is_visible BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, library_id) +); + +-- Create media_items table (replaces ebooks table for broader media support) +CREATE TABLE media_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + author VARCHAR(255), + isbn VARCHAR(13), -- Still relevant for ebooks + description TEXT, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT, + mime_type VARCHAR(100), + cover_image_path VARCHAR(500), + series VARCHAR(255), + series_number INTEGER, + tags TEXT, + asin VARCHAR(20), -- Still relevant for ebooks + date_published DATE, + publisher VARCHAR(255), + contributors TEXT, + added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create ebooks view for backward compatibility +CREATE VIEW ebooks AS +SELECT mi.* +FROM media_items mi +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE lt.name = 'ebooks'; + -- Create reading_progress table CREATE TABLE reading_progress ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -24,6 +116,16 @@ CREATE TABLE reading_progress ( UNIQUE(media_item_id, user_id) ); +-- Create reading_progress view for backward compatibility +CREATE VIEW ebook_reading_progress AS +SELECT rp.*, + mi.id as ebook_id -- Map media_item_id to ebook_id for compatibility +FROM reading_progress rp +JOIN media_items mi ON rp.media_item_id = mi.id +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE lt.name = 'ebooks'; + -- Create media_ratings table (replaces ebook_ratings) CREATE TABLE media_ratings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -60,25 +162,39 @@ CREATE TABLE media_highlights ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- Create ebook_ratings view for backward compatibility +CREATE VIEW ebook_ratings AS +SELECT mr.*, + mi.id as ebook_id -- Map media_item_id to ebook_id for compatibility +FROM media_ratings mr +JOIN media_items mi ON mr.media_item_id = mi.id +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE lt.name = 'ebooks'; + +-- Create ebook_notes view for backward compatibility +CREATE VIEW ebook_notes AS +SELECT mn.*, + mi.id as ebook_id -- Map media_item_id to ebook_id for compatibility +FROM media_notes mn +JOIN media_items mi ON mn.media_item_id = mi.id +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE lt.name = 'ebooks'; + +-- Create ebook_highlights view for backward compatibility +CREATE VIEW ebook_highlights AS +SELECT mh.*, + mi.id as ebook_id -- Map media_item_id to ebook_id for compatibility +FROM media_highlights mh +JOIN media_items mi ON mh.media_item_id = mi.id +JOIN libraries l ON mi.library_id = l.id +JOIN library_types lt ON l.library_type_id = lt.id +WHERE lt.name = 'ebooks'; + -- Note: user_ebook_folders table is replaced by library_folders table -- Libraries now handle folder management instead of individual users --- ISBN normalization trigger --- Automatically normalizes ISBN on insert and update -CREATE OR REPLACE FUNCTION normalize_media_item_isbn() RETURNS TRIGGER AS $$ -BEGIN - IF NEW.isbn IS NOT NULL THEN - NEW.isbn := normalize_isbn(NEW.isbn); - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_normalize_media_item_isbn - BEFORE INSERT OR UPDATE ON media_items - FOR EACH ROW - EXECUTE FUNCTION normalize_media_item_isbn(); - -- Create indexes for better query performance CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_username ON users(username); @@ -99,19 +215,6 @@ CREATE INDEX idx_media_items_title ON media_items(title); CREATE INDEX idx_media_items_author ON media_items(author); CREATE INDEX idx_media_items_library_id ON media_items(library_id); CREATE INDEX idx_media_items_added_by_admin_id ON media_items(added_by_admin_id); -CREATE INDEX idx_media_items_language ON media_items(language); -CREATE INDEX idx_media_items_genre ON media_items(genre); -CREATE INDEX idx_media_items_page_count ON media_items(page_count); -CREATE INDEX idx_media_items_copyright_year ON media_items(copyright_year); -CREATE INDEX idx_media_items_series_order ON media_items(series, series_number); -CREATE INDEX idx_media_items_date_published ON media_items(date_published); - --- GIN indexes for fast fuzzy search using pg_trgm -CREATE INDEX idx_media_items_title_gin ON media_items USING gin (title gin_trgm_ops); -CREATE INDEX idx_media_items_author_gin ON media_items USING gin (author gin_trgm_ops); -CREATE INDEX idx_media_items_series_gin ON media_items USING gin (series gin_trgm_ops); -CREATE INDEX idx_media_items_tags_gin ON media_items USING gin (tags gin_trgm_ops); -CREATE INDEX idx_media_items_contributors_gin ON media_items USING gin (contributors gin_trgm_ops); -- Progress and ratings indexes CREATE INDEX idx_reading_progress_media_item_id ON reading_progress(media_item_id); @@ -130,7 +233,7 @@ CREATE INDEX idx_media_highlights_note_id ON media_highlights(note_id); COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half-stars: 1,3,5,7,9 = 0.5,1.5,2.5,3.5,4.5 stars)'; -- Library Type File Extensions Notes: --- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc', .docx, .lit', .fb2, .pdb' +-- - Ebooks: .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb -- - Comics: .cbz, .cbr, .cb7, .cbt, .pdf -- - Manga: .cbz, .cbr, .png, .jpg, .jpeg, .gif, .bmp, .webp (note: manga includes image folders) @@ -141,4 +244,5 @@ COMMENT ON COLUMN media_ratings.rating IS 'Rating scale 1-10 (odd numbers = half -- - To create first admin: UPDATE users SET role = 'admin' WHERE email = 'your-admin-email'; -- - Library visibility is controlled through library_visibility table - admins can hide/show libraries per user -- - Notes and highlights support position data (page:offset or CFI format) for precise location tracking --- - Highlights can have associated notes for detailed annotations \ No newline at end of file +-- - Highlights can have associated notes for detailed annotations +-- - Backward compatibility views (ebooks, ebook_ratings, ebook_reading_progress, ebook_notes, ebook_highlights) maintain existing API contracts \ No newline at end of file