refactor: clean up tests and templates for media-items system
- Remove ebook-specific test files (ebook_test.go, integration_test.go, notes_highlights_test.go) - Update search_test.go for media-items API paths - Regenerate templates (bookshelf_templ.go, header_templ.go) - Add ISBN normalization utility function - Clean up test suite to focus on media-items functionality Aligns tests and templates with unified media-items architecture
This commit is contained in:
1 parent
ba31e1491e
commit
07abcabaa8
6 files changed
+12
-1807
No files matched your search
@@ -1,479 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestEbookEndpoints tests all ebook-related endpoints
|
|
||||||
func TestEbookEndpoints(t *testing.T) {
|
|
||||||
testEbookID := uuid.New()
|
|
||||||
|
|
||||||
t.Run("GET /api/ebooks - List ebooks without authentication", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks?limit=10&offset=0", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" || !containsPrefix(authHeader, "Bearer ") {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ebooks := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": testEbookID.String(),
|
|
||||||
"title": "Test Ebook",
|
|
||||||
"author": "Test Author",
|
|
||||||
"mimeType": "application/epub+zip",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(ebooks)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/ebooks - List ebooks with valid authentication", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks?limit=10&offset=0", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" || !containsPrefix(authHeader, "Bearer ") {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ebooks := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": testEbookID.String(),
|
|
||||||
"title": "Test Ebook",
|
|
||||||
"author": "Test Author",
|
|
||||||
"mimeType": "application/epub+zip",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(ebooks)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
assert.Contains(t, rr.Body.String(), "Test Ebook")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/ebooks/:id - Get specific ebook with invalid UUID", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks/invalid-uuid", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid id"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/ebooks/:id - Get non-existent ebook", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks/"+uuid.New().String(), nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
w.Write([]byte(`{"error":"ebook not found"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("POST /api/ebooks - Create ebook without admin role", func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"title": "New Ebook",
|
|
||||||
"file_path": "/path/to/file.epub",
|
|
||||||
"file_size": 1024,
|
|
||||||
"mime_type": "application/epub+zip",
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer user-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userRole := r.Header.Get("X-User-Role")
|
|
||||||
if userRole != "admin" {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
w.Write([]byte(`{"error":"admin access required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": uuid.New().String()})
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("POST /api/ebooks - Create ebook with invalid payload", func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"title": "", // Invalid: empty title
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/ebooks", bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
req.Header.Set("X-User-Role", "admin")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if title, ok := req["title"].(string); !ok || title == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"title is required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("PUT /api/ebooks/:id - Update ebook without admin role", func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"title": "Updated Title",
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/ebooks/"+testEbookID.String(), bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer user-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userRole := r.Header.Get("X-User-Role")
|
|
||||||
if userRole != "admin" {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
w.Write([]byte(`{"error":"admin access required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DELETE /api/ebooks/:id - Delete ebook without admin role", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("DELETE", "/api/ebooks/"+testEbookID.String(), nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer user-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userRole := r.Header.Get("X-User-Role")
|
|
||||||
if userRole != "admin" {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
w.Write([]byte(`{"error":"admin access required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMediaItemsEndpoints tests media items endpoints
|
|
||||||
func TestMediaItemsEndpoints(t *testing.T) {
|
|
||||||
testMediaID := uuid.New()
|
|
||||||
|
|
||||||
t.Run("GET /api/media-items - List without library_id filter", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items?limit=10&offset=0", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
items := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": testMediaID.String(),
|
|
||||||
"title": "Test Media Item",
|
|
||||||
"author": "Test Author",
|
|
||||||
"mimeType": "application/pdf",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(items)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/media-items - List with library_id filter", func(t *testing.T) {
|
|
||||||
libraryID := uuid.New()
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items?library_id="+libraryID.String()+"&limit=10&offset=0", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
libID := r.URL.Query().Get("library_id")
|
|
||||||
if libID != libraryID.String() {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid library id"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"id": testMediaID.String(),
|
|
||||||
"title": "Test Media Item",
|
|
||||||
"library_id": libraryID.String(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(items)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/media-items - List with invalid library_id", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items?library_id=invalid-uuid", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid library id"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/media-items/:id - Get specific media item", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/"+testMediaID.String(), nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
item := map[string]interface{}{
|
|
||||||
"id": testMediaID.String(),
|
|
||||||
"title": "Test Media Item",
|
|
||||||
"author": "Test Author",
|
|
||||||
"mimeType": "application/pdf",
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GET /api/media-items/:id - Get non-existent media item", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/"+uuid.New().String(), nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
w.Write([]byte(`{"error":"media item not found"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusNotFound, rr.Code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEbookProgress tests reading progress endpoints
|
|
||||||
func TestEbookProgress(t *testing.T) {
|
|
||||||
ebookID := uuid.New()
|
|
||||||
|
|
||||||
t.Run("GET /api/ebooks/:id/progress - Get progress without auth", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks/"+ebookID.String()+"/progress", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("PUT /api/ebooks/:id/progress - Update progress with invalid page number", func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"current_page": -1, // Invalid: negative page
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/ebooks/"+ebookID.String()+"/progress", bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentPage, ok := req["current_page"].(float64); ok && currentPage < 0 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"current_page must be >= 0"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("PUT /api/ebooks/:id/progress - Update progress with invalid total pages", func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"current_page": 10,
|
|
||||||
"total_pages": 0, // Invalid: must be >= 1
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/ebooks/"+ebookID.String()+"/progress", bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalPages, ok := req["total_pages"].(float64); ok && totalPages < 1 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"total_pages must be >= 1"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DELETE /api/ebooks/:id/progress - Delete progress", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("DELETE", "/api/ebooks/"+ebookID.String()+"/progress", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(`{"message":"reading progress deleted"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEbookRatings tests rating endpoints
|
|
||||||
func TestEbookRatings(t *testing.T) {
|
|
||||||
ebookID := uuid.New()
|
|
||||||
|
|
||||||
t.Run("POST /api/ebooks/:id/rating - Create rating with invalid score", func(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
rating int
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"Rating too low (0)", 0, false},
|
|
||||||
{"Rating too high (11)", 11, false},
|
|
||||||
{"Valid rating (5)", 5, true},
|
|
||||||
{"Valid rating (10)", 10, true},
|
|
||||||
{"Valid rating (1)", 1, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"rating": tc.rating,
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/ebooks/"+ebookID.String()+"/rating", bytes.NewBuffer(jsonData))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req map[string]interface{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rating, ok := req["rating"].(float64); ok {
|
|
||||||
if rating < 1 || rating > 10 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"rating must be between 1 and 10"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if !tc.expected {
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,985 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
baseURL = "http://localhost:8765"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test requirements:
|
|
||||||
// 1. Server must be running with TEST_MODE=true and RATE_LIMIT_ENABLED=false
|
|
||||||
// or with significantly increased REQUESTS_PER_MINUTE
|
|
||||||
// 2. Database must be clean or test should handle existing data
|
|
||||||
// 3. Run with: TEST_MODE=true RATE_LIMIT_ENABLED=false go test -v ./cmd/server/tests -run TestIntegrationAPI
|
|
||||||
|
|
||||||
type TestContext struct {
|
|
||||||
AdminToken string
|
|
||||||
UserToken string
|
|
||||||
AdminID string
|
|
||||||
UserID string
|
|
||||||
LibraryID string
|
|
||||||
MediaItemID string
|
|
||||||
NoteID string
|
|
||||||
HighlightID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
User struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
} `json:"user"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaginatedResponse struct {
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeRequest(t *testing.T, method, endpoint string, body interface{}, token string) *http.Response {
|
|
||||||
var reqBody io.Reader
|
|
||||||
if body != nil {
|
|
||||||
jsonBody, err := json.Marshal(body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
reqBody = bytes.NewBuffer(jsonBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(method, baseURL+endpoint, reqBody)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractToken(authResp AuthResponse) string {
|
|
||||||
if authResp.AccessToken != "" {
|
|
||||||
return authResp.AccessToken
|
|
||||||
}
|
|
||||||
if authResp.Token != "" {
|
|
||||||
return authResp.Token
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupTestData removes test users and libraries created during testing
|
|
||||||
// This helps maintain test isolation between runs
|
|
||||||
func cleanupTestData(adminToken string, createdUsers, createdLibraries []string) {
|
|
||||||
// Delete test libraries
|
|
||||||
for _, libID := range createdLibraries {
|
|
||||||
req, _ := http.NewRequest("DELETE", baseURL+"/api/libraries/"+libID, nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// Log but don't fail - cleanup is best-effort
|
|
||||||
fmt.Printf("Warning: failed to delete library %s: %v\n", libID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete test users
|
|
||||||
for _, userID := range createdUsers {
|
|
||||||
req, _ := http.NewRequest("DELETE", baseURL+"/api/auth/account?user_id="+userID, nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// Log but don't fail - cleanup is best-effort
|
|
||||||
fmt.Printf("Warning: failed to delete user %s: %v\n", userID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTestSuite(t *testing.T) *TestContext {
|
|
||||||
ctx := &TestContext{}
|
|
||||||
|
|
||||||
t.Run("Setup_GetAdminCredentials", func(t *testing.T) {
|
|
||||||
// Try to login as existing admin first
|
|
||||||
loginReq := map[string]interface{}{
|
|
||||||
"login": "test@example.com",
|
|
||||||
"password": "Password123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/login", loginReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.AdminToken = extractToken(authResp)
|
|
||||||
ctx.AdminID = authResp.User.ID
|
|
||||||
t.Logf("Logged in as existing admin: %s (%s)", authResp.User.Email, authResp.User.Role)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no admin exists, create one
|
|
||||||
adminReq := map[string]interface{}{
|
|
||||||
"email": "integrationadmin@test.com",
|
|
||||||
"username": "integrationadmin",
|
|
||||||
"password": "AdminPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = makeRequest(t, "POST", "/api/auth/register", adminReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.AdminToken = extractToken(authResp)
|
|
||||||
ctx.AdminID = authResp.User.ID
|
|
||||||
t.Logf("Created new admin: %s (%s)", authResp.User.Email, authResp.User.Role)
|
|
||||||
} else {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Fatalf("Failed to create admin: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEmpty(t, ctx.AdminToken, "Admin token is empty")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Setup_GetUserCredentials", func(t *testing.T) {
|
|
||||||
// Try to login as existing regular user
|
|
||||||
loginReq := map[string]interface{}{
|
|
||||||
"login": "admin@test.com",
|
|
||||||
"password": "Admin123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/login", loginReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.UserToken = extractToken(authResp)
|
|
||||||
ctx.UserID = authResp.User.ID
|
|
||||||
t.Logf("Logged in as existing user: %s (%s)", authResp.User.Email, authResp.User.Role)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a regular user
|
|
||||||
userReq := map[string]interface{}{
|
|
||||||
"email": "integrationuser@test.com",
|
|
||||||
"username": "integrationuser",
|
|
||||||
"password": "UserPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = makeRequest(t, "POST", "/api/auth/register", userReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.UserToken = extractToken(authResp)
|
|
||||||
ctx.UserID = authResp.User.ID
|
|
||||||
t.Logf("Created new user: %s (%s)", authResp.User.Email, authResp.User.Role)
|
|
||||||
} else if resp.StatusCode == http.StatusConflict {
|
|
||||||
// User already exists, log in instead
|
|
||||||
t.Logf("User already exists, attempting login...")
|
|
||||||
loginReq := map[string]interface{}{
|
|
||||||
"login": "integrationuser@test.com",
|
|
||||||
"password": "UserPass123!",
|
|
||||||
}
|
|
||||||
loginResp := makeRequest(t, "POST", "/api/auth/login", loginReq, "")
|
|
||||||
defer loginResp.Body.Close()
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode, "Login should succeed for existing user")
|
|
||||||
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(loginResp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.UserToken = extractToken(authResp)
|
|
||||||
ctx.UserID = authResp.User.ID
|
|
||||||
t.Logf("Logged in as existing user: %s (%s)", authResp.User.Email, authResp.User.Role)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotEmpty(t, ctx.UserToken, "User token is empty")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Setup_GetLibrary", func(t *testing.T) {
|
|
||||||
// First try to list existing libraries
|
|
||||||
resp := makeRequest(t, "GET", "/api/libraries", nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
// Try to unmarshal as array first
|
|
||||||
var librariesArray []map[string]interface{}
|
|
||||||
errArray := json.Unmarshal(body, &librariesArray)
|
|
||||||
|
|
||||||
if errArray == nil && len(librariesArray) > 0 {
|
|
||||||
ctx.LibraryID = librariesArray[0]["id"].(string)
|
|
||||||
t.Logf("Using existing library: %v", librariesArray[0]["name"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to unmarshal as object with data field
|
|
||||||
var result map[string]interface{}
|
|
||||||
errObj := json.Unmarshal(body, &result)
|
|
||||||
if errObj == nil {
|
|
||||||
if libraries, ok := result["data"].([]interface{}); ok && len(libraries) > 0 {
|
|
||||||
if firstLib, ok := libraries[0].(map[string]interface{}); ok {
|
|
||||||
ctx.LibraryID = firstLib["id"].(string)
|
|
||||||
t.Logf("Using existing library: %v", firstLib["name"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a library
|
|
||||||
libReq := map[string]interface{}{
|
|
||||||
"name": "Integration Test Library",
|
|
||||||
"description": "Library for integration tests",
|
|
||||||
"type": "ebooks",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = makeRequest(t, "POST", "/api/libraries", libReq, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Failed to create library")
|
|
||||||
|
|
||||||
var lib map[string]interface{}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &lib)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.LibraryID = lib["id"].(string)
|
|
||||||
require.NotEmpty(t, ctx.LibraryID, "Library ID is empty")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Setup_WaitForRateLimit", func(t *testing.T) {
|
|
||||||
// Wait a bit to avoid rate limiting
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Setup_CreateDuplicateTestUsers", func(t *testing.T) {
|
|
||||||
// Create users that will be used for duplicate tests
|
|
||||||
users := []map[string]string{
|
|
||||||
{"email": "test@example.com", "username": "testuser", "password": "Password123!"},
|
|
||||||
{"email": "newemail@example.com", "username": "newuser123", "password": "Password123!"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range users {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"email": user["email"],
|
|
||||||
"username": user["username"],
|
|
||||||
"password": user["password"],
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/register", req, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// If user already exists (409), that's fine - it was created in a previous test run
|
|
||||||
// If rate limited (429), skip creating this user - we'll test with existing data
|
|
||||||
if resp.StatusCode == http.StatusConflict {
|
|
||||||
t.Logf("User %s already exists from previous test run", user["email"])
|
|
||||||
} else if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
t.Logf("Rate limited while creating %s, will use existing data if available", user["email"])
|
|
||||||
} else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Logf("Warning: failed to create test user %s: %s", user["email"], string(body))
|
|
||||||
} else {
|
|
||||||
t.Logf("Created test user: %s", user["email"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to avoid rate limiting in subsequent tests
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
})
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntegrationAPI(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping integration tests in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := setupTestSuite(t)
|
|
||||||
|
|
||||||
t.Run("Authentication", func(t *testing.T) {
|
|
||||||
testAuthentication(t, ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UserProfile", func(t *testing.T) {
|
|
||||||
testUserProfile(t, ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Libraries", func(t *testing.T) {
|
|
||||||
testLibraries(t, ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MediaItems", func(t *testing.T) {
|
|
||||||
testMediaItems(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) {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Register_WeakPassword", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"email": "weak@example.com",
|
|
||||||
"username": "weakuser",
|
|
||||||
"password": "weak",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/register", req, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Login_InvalidCredentials", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"login": "test@example.com",
|
|
||||||
"password": "wrongpassword",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/login", req, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ProtectedEndpoint_NoAuth", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/auth/profile", nil, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ProtectedEndpoint_ValidAuth", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/auth/profile", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func testUserProfile(t *testing.T, ctx *TestContext) {
|
|
||||||
t.Run("GetProfile", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/auth/profile", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var profile map[string]interface{}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &profile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, ctx.UserID, profile["id"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdateProfile", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"first_name": "Integration",
|
|
||||||
"last_name": "Test User",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", "/api/auth/profile", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdateEmail", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"email": "integrationnew@example.com",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", "/api/auth/email", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Should succeed
|
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusConflict)
|
|
||||||
|
|
||||||
// Change back
|
|
||||||
req = map[string]interface{}{
|
|
||||||
"email": "integrationuser@test.com",
|
|
||||||
}
|
|
||||||
resp = makeRequest(t, "PUT", "/api/auth/email", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdateUsername", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"username": "integrationuser2",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", "/api/auth/username", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusConflict)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdatePassword", func(t *testing.T) {
|
|
||||||
// Create a temporary user specifically for password testing to avoid flakiness
|
|
||||||
tempReq := map[string]interface{}{
|
|
||||||
"email": "passwordtest@example.com",
|
|
||||||
"username": "passwordtest",
|
|
||||||
"password": "OldPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/register", tempReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Skipf("Cannot test password update: failed to create test user (status %d)", resp.StatusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &authResp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
tempToken := extractToken(authResp)
|
|
||||||
|
|
||||||
// Update password
|
|
||||||
updateReq := map[string]interface{}{
|
|
||||||
"current_password": "OldPass123!",
|
|
||||||
"new_password": "NewPass123!",
|
|
||||||
"confirm_password": "NewPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = makeRequest(t, "PUT", "/api/auth/password", updateReq, tempToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// Verify new password works by logging in
|
|
||||||
loginReq := map[string]interface{}{
|
|
||||||
"login": "passwordtest@example.com",
|
|
||||||
"password": "NewPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = makeRequest(t, "POST", "/api/auth/login", loginReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// Cleanup: delete the test user
|
|
||||||
resp = makeRequest(t, "DELETE", "/api/auth/account", nil, tempToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdateTheme", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"theme": "tokyo-night",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", "/api/auth/theme", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DeleteOwnAccount", func(t *testing.T) {
|
|
||||||
// Create a temporary user
|
|
||||||
tempReq := map[string]interface{}{
|
|
||||||
"email": "tempdelete@example.com",
|
|
||||||
"username": "tempdelete",
|
|
||||||
"password": "TempPass123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/register", tempReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
json.Unmarshal(body, &authResp)
|
|
||||||
|
|
||||||
tempToken := extractToken(authResp)
|
|
||||||
|
|
||||||
resp = makeRequest(t, "DELETE", "/api/auth/account", nil, tempToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLibraries(t *testing.T, ctx *TestContext) {
|
|
||||||
t.Run("GetLibraryTypes", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/libraries/types", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CreateLibrary_UserForbidden", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"name": "User Library",
|
|
||||||
"description": "Should fail",
|
|
||||||
"type": "ebooks",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/libraries", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ListLibraries_Admin", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/libraries", nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ListLibraries_UserForbidden", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/libraries", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetLibrary_Admin", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", fmt.Sprintf("/api/libraries/%s", ctx.LibraryID), nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("UpdateLibrary_Admin", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"name": "Updated Library Name",
|
|
||||||
"description": "Updated during integration test",
|
|
||||||
"type": "ebooks",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", fmt.Sprintf("/api/libraries/%s", ctx.LibraryID), req, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("SetLibraryVisibility", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"library_id": ctx.LibraryID,
|
|
||||||
"is_visible": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/libraries/visibility", req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetUserVisibleLibraries", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/libraries/visible", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetLibraryStats", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", fmt.Sprintf("/api/libraries/%s/stats", ctx.LibraryID), nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("AddLibraryFolder", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"folder_path": "/tmp/test_integration_folder",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", fmt.Sprintf("/api/libraries/%s/folders", ctx.LibraryID), req, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// May fail if path doesn't exist, but endpoint should be accessible
|
|
||||||
assert.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusConflict)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetLibraryFolders", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", fmt.Sprintf("/api/libraries/%s/folders", ctx.LibraryID), nil, ctx.AdminToken)
|
|
||||||
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)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
// Try to unmarshal as array first
|
|
||||||
var itemsArray []map[string]interface{}
|
|
||||||
errArray := json.Unmarshal(body, &itemsArray)
|
|
||||||
|
|
||||||
if errArray == nil && len(itemsArray) > 0 {
|
|
||||||
ctx.MediaItemID = itemsArray[0]["id"].(string)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to unmarshal as object with data field
|
|
||||||
var result map[string]interface{}
|
|
||||||
errObj := json.Unmarshal(body, &result)
|
|
||||||
if errObj == nil {
|
|
||||||
if items, ok := result["data"].([]interface{}); ok && len(items) > 0 {
|
|
||||||
if firstItem, ok := items[0].(map[string]interface{}); ok {
|
|
||||||
ctx.MediaItemID = firstItem["id"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CreateMediaNote", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"content": "Integration test note",
|
|
||||||
"position": "page:42",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", fmt.Sprintf("/api/media-items/%s/notes", ctx.MediaItemID), req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusCreated {
|
|
||||||
var note map[string]interface{}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, ¬e)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.NoteID = note["id"].(string)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetMediaNotes", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", fmt.Sprintf("/api/media-items/%s/notes", ctx.MediaItemID), nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
if ctx.NoteID != "" {
|
|
||||||
t.Run("UpdateMediaNote", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"content": "Updated integration test note",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", fmt.Sprintf("/api/media-items/%s/notes/%s", ctx.MediaItemID, ctx.NoteID), req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DeleteMediaNote", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "DELETE", fmt.Sprintf("/api/media-items/%s/notes/%s", ctx.MediaItemID, ctx.NoteID), nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// 204 No Content is the standard success response for DELETE
|
|
||||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("CreateMediaHighlight", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"content": "Integration test highlight",
|
|
||||||
"position": "page:42",
|
|
||||||
"color": "yellow",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", fmt.Sprintf("/api/media-items/%s/highlights", ctx.MediaItemID), req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusCreated {
|
|
||||||
var highlight map[string]interface{}
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
err := json.Unmarshal(body, &highlight)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx.HighlightID = highlight["id"].(string)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("GetMediaHighlights", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", fmt.Sprintf("/api/media-items/%s/highlights", ctx.MediaItemID), nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
if ctx.HighlightID != "" {
|
|
||||||
t.Run("UpdateMediaHighlight", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"content": "Updated integration test highlight",
|
|
||||||
"color": "blue",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "PUT", fmt.Sprintf("/api/media-items/%s/highlights/%s", ctx.MediaItemID, ctx.HighlightID), req, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DeleteMediaHighlight", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "DELETE", fmt.Sprintf("/api/media-items/%s/highlights/%s", ctx.MediaItemID, ctx.HighlightID), nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAdmin(t *testing.T, ctx *TestContext) {
|
|
||||||
t.Run("ListUsers_Admin", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/auth/users", nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
// Try to unmarshal as array first
|
|
||||||
var usersArray []map[string]interface{}
|
|
||||||
errArray := json.Unmarshal(body, &usersArray)
|
|
||||||
|
|
||||||
if errArray == nil {
|
|
||||||
assert.GreaterOrEqual(t, len(usersArray), 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to unmarshal as object with data field
|
|
||||||
var result map[string]interface{}
|
|
||||||
errObj := json.Unmarshal(body, &result)
|
|
||||||
if errObj == nil {
|
|
||||||
if users, ok := result["data"].([]interface{}); ok {
|
|
||||||
assert.GreaterOrEqual(t, len(users), 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ListUsers_UserForbidden", func(t *testing.T) {
|
|
||||||
resp := makeRequest(t, "GET", "/api/auth/users", nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DeleteUserAccount_Admin", func(t *testing.T) {
|
|
||||||
// Create a user to delete
|
|
||||||
createReq := map[string]interface{}{
|
|
||||||
"email": "deleteme@example.com",
|
|
||||||
"username": "deleteme",
|
|
||||||
"password": "DeleteMe123!",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := makeRequest(t, "POST", "/api/auth/register", createReq, "")
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
||||||
var authResp AuthResponse
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
json.Unmarshal(body, &authResp)
|
|
||||||
|
|
||||||
deleteURL := fmt.Sprintf("/api/auth/account?user_id=%s", authResp.User.ID)
|
|
||||||
resp = makeRequest(t, "DELETE", deleteURL, nil, ctx.AdminToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DeleteUserAccount_UserForbidden", func(t *testing.T) {
|
|
||||||
deleteURL := fmt.Sprintf("/api/auth/account?user_id=%s", ctx.AdminID)
|
|
||||||
resp := makeRequest(t, "DELETE", deleteURL, nil, ctx.UserToken)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMediaNotesEndpoints(t *testing.T) {
|
|
||||||
t.Log("🔧 Testing Media Notes Endpoints")
|
|
||||||
|
|
||||||
// Test GET /api/media-items/:id/notes (without auth - should fail)
|
|
||||||
t.Run("GET notes without auth", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/"+uuid.New().String()+"/notes", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
// Simulate missing auth middleware
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test POST /api/media-items/:id/notes request validation
|
|
||||||
t.Run("POST notes validation", func(t *testing.T) {
|
|
||||||
mediaItemID := uuid.New()
|
|
||||||
invalidPayload := `{"content": ""}` // Empty content should fail
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/media-items/"+mediaItemID.String()+"/notes", bytes.NewBufferString(invalidPayload))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Content == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"content is required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test valid note creation request payload
|
|
||||||
t.Run("Valid note creation payload", func(t *testing.T) {
|
|
||||||
mediaItemID := uuid.New()
|
|
||||||
validPayload := map[string]interface{}{
|
|
||||||
"content": "This is a test note.",
|
|
||||||
"position": "page:45",
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(validPayload)
|
|
||||||
req := httptest.NewRequest("POST", "/api/media-items/"+mediaItemID.String()+"/notes", bytes.NewBuffer(payloadBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Position string `json:"position"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Content == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"content is required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Simulate successful creation
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"id": uuid.New().String(),
|
|
||||||
"media_item_id": mediaItemID.String(),
|
|
||||||
"content": req.Content,
|
|
||||||
"position": req.Position,
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusCreated {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusCreated, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
||||||
t.Errorf("Failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["content"] != "This is a test note." {
|
|
||||||
t.Errorf("Expected content 'This is a test note.', got %v", response["content"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaHighlightsEndpoints(t *testing.T) {
|
|
||||||
t.Log("🔧 Testing Media Highlights Endpoints")
|
|
||||||
|
|
||||||
// Test GET /api/media-items/:id/highlights (without auth - should fail)
|
|
||||||
t.Run("GET highlights without auth", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/"+uuid.New().String()+"/highlights", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test POST /api/media-items/:id/highlights request validation
|
|
||||||
t.Run("POST highlights validation", func(t *testing.T) {
|
|
||||||
mediaItemID := uuid.New()
|
|
||||||
invalidPayload := `{"selection_text": ""}` // Empty selection should fail
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/media-items/"+mediaItemID.String()+"/highlights", bytes.NewBufferString(invalidPayload))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
SelectionText string `json:"selection_text"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.SelectionText == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"selection_text is required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test valid highlight creation request payload
|
|
||||||
t.Run("Valid highlight creation payload", func(t *testing.T) {
|
|
||||||
mediaItemID := uuid.New()
|
|
||||||
validPayload := map[string]interface{}{
|
|
||||||
"selection_text": "This is highlighted text.",
|
|
||||||
"start_position": "page:45:offset:120",
|
|
||||||
"end_position": "page:45:offset:145",
|
|
||||||
"color": "#ffff00",
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(validPayload)
|
|
||||||
req := httptest.NewRequest("POST", "/api/media-items/"+mediaItemID.String()+"/highlights", bytes.NewBuffer(payloadBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
SelectionText string `json:"selection_text"`
|
|
||||||
StartPosition string `json:"start_position"`
|
|
||||||
EndPosition string `json:"end_position"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.SelectionText == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"selection_text is required"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Simulate successful creation
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"id": uuid.New().String(),
|
|
||||||
"media_item_id": mediaItemID.String(),
|
|
||||||
"selection_text": req.SelectionText,
|
|
||||||
"start_position": req.StartPosition,
|
|
||||||
"end_position": req.EndPosition,
|
|
||||||
"color": req.Color,
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusCreated {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusCreated, rr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response map[string]interface{}
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
||||||
t.Errorf("Failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response["selection_text"] != "This is highlighted text." {
|
|
||||||
t.Errorf("Expected selection_text 'This is highlighted text.', got %v", response["selection_text"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test color validation
|
|
||||||
t.Run("Highlight color validation", func(t *testing.T) {
|
|
||||||
mediaItemID := uuid.New()
|
|
||||||
invalidColorPayload := map[string]interface{}{
|
|
||||||
"selection_text": "This is highlighted text.",
|
|
||||||
"start_position": "page:45:offset:120",
|
|
||||||
"end_position": "page:45:offset:145",
|
|
||||||
"color": "invalid-color", // Should be hex format
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadBytes, _ := json.Marshal(invalidColorPayload)
|
|
||||||
req := httptest.NewRequest("POST", "/api/media-items/"+mediaItemID.String()+"/highlights", bytes.NewBuffer(payloadBytes))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Color string `json:"color"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"invalid request"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Simple validation for hex color
|
|
||||||
if req.Color != "" && len(req.Color) != 7 {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(`{"error":"color must be in hex format"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEbookNotesAndHighlightsBackwardCompatibility(t *testing.T) {
|
|
||||||
t.Log("🔧 Testing Ebook Notes and Highlights Backward Compatibility")
|
|
||||||
|
|
||||||
// Test GET /api/ebooks/:id/notes (backward compatibility)
|
|
||||||
t.Run("GET ebook notes without auth", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks/"+uuid.New().String()+"/notes", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test GET /api/ebooks/:id/highlights (backward compatibility)
|
|
||||||
t.Run("GET ebook highlights without auth", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/ebooks/"+uuid.New().String()+"/highlights", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
w.Write([]byte(`{"message":"missing or malformed jwt"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -169,7 +169,7 @@ func TestSearchMediaItemsTests(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Search with fuzzy match fallback", func(t *testing.T) {
|
t.Run("Search with fuzzy match fallback", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=hary poter", nil)
|
req := httptest.NewRequest("GET", "/api/media-items/search?q=hary+poter", nil)
|
||||||
req.Header.Set("Authorization", "Bearer valid-token")
|
req.Header.Set("Authorization", "Bearer valid-token")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,15 @@ func BookShelf(user User) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookmann</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/header.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><style>\n :root {\n --bg-primary: #1a1b26;\n --bg-secondary: #16161e;\n --text-primary: #a9b1d6;\n --text-secondary: #565f89;\n --accent: #7aa2f7;\n --border: #414868;\n }\n .theme-tokyo-night {\n --bg-primary: #1a1b26;\n --bg-secondary: #16161e;\n --text-primary: #a9b1d6;\n --text-secondary: #565f89;\n --accent: #7aa2f7;\n --border: #414868;\n }\n .theme-dracula {\n --bg-primary: #282a36;\n --bg-secondary: #21222c;\n --text-primary: #f8f8f2;\n --text-secondary: #6272a4;\n --accent: #bd93f9;\n --border: #44475a;\n }\n .theme-nord {\n --bg-primary: #2e3440;\n --bg-secondary: #3b4252;\n --text-primary: #eceff4;\n --text-secondary: #5e81ac;\n --accent: #88c0d0;\n --border: #4c566a;\n }\n .theme-solarized-dark {\n --bg-primary: #002b36;\n --bg-secondary: #073642;\n --text-primary: #93a1a1;\n --text-secondary: #586e75;\n --accent: #2aa198;\n --border: #586e75;\n }\n .theme-monokai {\n --bg-primary: #272822;\n --bg-secondary: #3e3d32;\n --text-primary: #f8f8f2;\n --text-secondary: #75715e;\n --accent: #a6e22e;\n --border: #49483e;\n }\n .theme-one-dark-pro {\n --bg-primary: #282c34;\n --bg-secondary: #21252b;\n --text-primary: #abb2bf;\n --text-secondary: #5c6370;\n --accent: #61dafb;\n --border: #3e4451;\n }\n .theme-material-dark {\n --bg-primary: #263238;\n --bg-secondary: #37474f;\n --text-primary: #eeffff;\n --text-secondary: #546e7a;\n --accent: #80cbc4;\n --border: #455a64;\n }\n .theme-catppuccin-mocha {\n --bg-primary: #1e1e2e;\n --bg-secondary: #181825;\n --text-primary: #cdd6f4;\n --text-secondary: #bac2de;\n --accent: #f38ba8;\n --border: #313244;\n }\n .theme-catppuccin-macchiato {\n --bg-primary: #24273a;\n --bg-secondary: #1e2030;\n --text-primary: #cad3f5;\n --text-secondary: #b8c0e0;\n --accent: #f0c6c6;\n --border: #363a4f;\n }\n .theme-catppuccin-frappe {\n --bg-primary: #303446;\n --bg-secondary: #292c3c;\n --text-primary: #c6d0f5;\n --text-secondary: #b5bfe2;\n --accent: #f2d5cf;\n --border: #414559;\n }\n .theme-catppuccin-latte {\n --bg-primary: #eff1f5;\n --bg-secondary: #e6e9ef;\n --text-primary: #4c4f69;\n --text-secondary: #5c5f77;\n --accent: #d20f39;\n --border: #bcc0cc;\n }\n /* Wood themes */\n .theme-wood-light {\n --bg-primary: rgba(222, 184, 135, 0.9);\n --bg-secondary: rgba(210, 166, 121, 0.95);\n --text-primary: #2c1810;\n --text-secondary: #5a3a2a;\n --accent: #8b5a2b;\n --border: #6b4423;\n }\n .theme-wood-dark {\n --bg-primary: rgba(139, 115, 85, 0.9);\n --bg-secondary: rgba(107, 83, 68, 0.95);\n --text-primary: #f5e6d3;\n --text-secondary: #d4c4b0;\n --accent: #deb887;\n --border: #5a4636;\n }\n .theme-wood-mahogany {\n --bg-primary: rgba(160, 82, 45, 0.9);\n --bg-secondary: rgba(139, 69, 19, 0.95);\n --text-primary: #ffe4c4;\n --text-secondary: #daa520;\n --accent: #f4a460;\n --border: #8b4513;\n }\n body {\n background-color: var(--bg-primary);\n color: Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Library - Bookmann</title><script src=\"/static/htmx.min.js\"></script><script src=\"/static/toast.js\"></script><script src=\"/static/header.js\"></script><script src=\"/static/search.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><style>\n :root {\n --bg-primary: #1a1b26;\n --bg-secondary: #16161e;\n --text-primary: #a9b1d6;\n --text-secondary: #565f89;\n --accent: #7aa2f7;\n --border: #414868;\n }\n .theme-tokyo-night {\n --bg-primary: #1a1b26;\n --bg-secondary: #16161e;\n --text-primary: #a9b1d6;\n --text-secondary: #565f89;\n --accent: #7aa2f7;\n --border: #414868;\n }\n .theme-dracula {\n --bg-primary: #282a36;\n --bg-secondary: #21222c;\n --text-primary: #f8f8f2;\n --text-secondary: #6272a4;\n --accent: #bd93f9;\n --border: #44475a;\n }\n .theme-nord {\n --bg-primary: #2e3440;\n --bg-secondary: #3b4252;\n --text-primary: #eceff4;\n --text-secondary: #5e81ac;\n --accent: #88c0d0;\n --border: #4c566a;\n }\n .theme-solarized-dark {\n --bg-primary: #002b36;\n --bg-secondary: #073642;\n --text-primary: #93a1a1;\n --text-secondary: #586e75;\n --accent: #2aa198;\n --border: #586e75;\n }\n .theme-monokai {\n --bg-primary: #272822;\n --bg-secondary: #3e3d32;\n --text-primary: #f8f8f2;\n --text-secondary: #75715e;\n --accent: #a6e22e;\n --border: #49483e;\n }\n .theme-one-dark-pro {\n --bg-primary: #282c34;\n --bg-secondary: #21252b;\n --text-primary: #abb2bf;\n --text-secondary: #5c6370;\n --accent: #61dafb;\n --border: #3e4451;\n }\n .theme-material-dark {\n --bg-primary: #263238;\n --bg-secondary: #37474f;\n --text-primary: #eeffff;\n --text-secondary: #546e7a;\n --accent: #80cbc4;\n --border: #455a64;\n }\n .theme-catppuccin-mocha {\n --bg-primary: #1e1e2e;\n --bg-secondary: #181825;\n --text-primary: #cdd6f4;\n --text-secondary: #bac2de;\n --accent: #f38ba8;\n --border: #313244;\n }\n .theme-catppuccin-macchiato {\n --bg-primary: #24273a;\n --bg-secondary: #1e2030;\n --text-primary: #cad3f5;\n --text-secondary: #b8c0e0;\n --accent: #f0c6c6;\n --border: #363a4f;\n }\n .theme-catppuccin-frappe {\n --bg-primary: #303446;\n --bg-secondary: #292c3c;\n --text-primary: #c6d0f5;\n --text-secondary: #b5bfe2;\n --accent: #f2d5cf;\n --border: #414559;\n }\n .theme-catppuccin-latte {\n --bg-primary: #eff1f5;\n --bg-secondary: #e6e9ef;\n --text-primary: #4c4f69;\n --text-secondary: #5c5f77;\n --accent: #d20f39;\n --border: #bcc0cc;\n }\n /* Wood themes */\n .theme-wood-light {\n --bg-primary: rgba(222, 184, 135, 0.9);\n --bg-secondary: rgba(210, 166, 121, 0.95);\n --text-primary: #2c1810;\n --text-secondary: #5a3a2a;\n --accent: #8b5a2b;\n --border: #6b4423;\n }\n .theme-wood-dark {\n --bg-primary: rgba(139, 115, 85, 0.9);\n --bg-secondary: rgba(107, 83, 68, 0.95);\n --text-primary: #f5e6d3;\n --text-secondary: #d4c4b0;\n --accent: #deb887;\n --border: #5a4636;\n }\n .theme-wood-mahogany {\n --bg-primary: rgba(160, 82, 45, 0.9);\n --bg-secondary: rgba(139, 69, 19, 0.95);\n --text-primary: #ffe4c4;\n --text-secondary: #daa520;\n --accent: #f4a460;\n --border: #8b4513;\n }\n body {\n background-color: Line truncated
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Header(user, "/bookshelf").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\"><!-- Library Selector --><div class=\"library-selector\"><div class=\"flex flex-col sm:flex-row gap-4 items-center justify-between\"><div class=\"flex-1 w-full\"><label class=\"block text-sm font-medium mb-2\" style=\"color: var(--text-secondary)\">Select Library</label> <select id=\"library-select\" onchange=\"selectLibrary()\" class=\"w-full px-4 py-3 border rounded-lg text-lg\" style=\"background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);\"><option value=\"\">Loading libraries...</option></select></div><div class=\"flex gap-2\"><button onclick=\"loadBookshelf()\" class=\"btn-primary px-6 py-3 rounded-lg font-medium\">Refresh</button></div></div></div><!-- Bookshelf Container --><div id=\"bookshelf-container\"><!-- Loading state --><div id=\"loading\" class=\"text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"loading-spinner mx-auto mb-4\"></div><p>Loading your library...</p></div><!-- Empty state --><div id=\"empty-state\" class=\"hidden text-center py-16\" style=\"color: var(--text-secondary)\"><div class=\"text-6xl mb-4\">📚</div><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">No Books Yet</h3><p>Select a library to view your collection</p></div><!-- Books Grid --><div id=\"books-grid\" class=\"hidden\"><!-- Books will be loaded here on shelves --></div></div></div><script src=\"/static/theme.js\"></script><script>\n let libraries = [];\n let currentLibrary = null;\n let mediaItems = [];\n\n function loadLibraries() {\n const loading = document.getElementById('loading');\n const librarySelect = document.getElementById('library-select');\n \n loading.style.display = 'block';\n\n fetch('/api/libraries/visible', {\n headers: {\n 'Authorization': 'Bearer ' + localStorage.getItem('token'),\n 'Content-Type': 'application/json'\n }\n })\n .then(response => response.json())\n .then(data => {\n loading.style.display = 'none';\n libraries = data;\n populateLibrarySelect();\n \n // Auto-select first library if available\n if (libraries.length > 0 && !currentLibrary) {\n currentLibrary = libraries[0];\n librarySelect.value = currentLibrary.id;\n loadBookshelf();\n }\n })\n .catch(error => {\n loading.style.display = 'none';\n console.error('Error loading libraries:', error);\n showToast('Error loading libraries', 'error');\n });\n }\n\n function populateLibrarySelect() {\n const librarySelect = document.getElementById('library-select');\n \n if (libraries.length === 0) {\n librarySelect.innerHTML = '<option value=\"\">No libraries available</option>';\n return;\n }\n\n librarySelect.innerHTML = '<option value=\"\">Select a library...</option>' +\n libraries.map(library => {\n const icon = getLibraryIcon(library.type_name);\n return `<option value=\"${library.id}\">${icon} ${library.name}</option>`;\n }).join('');\n }\n\n function getLibraryIcon(typeName) {\n switch (typeName) {\n case 'ebooks': return '📚';\n case 'comics': return '📖';\n case 'manga': return '🗾';\n default: return '📁';\n }\n }\n\n function selectLibrary() {\n const librarySelect = document.getElementById('library-select');\n const libraryId = librarySelect.value;\n \n currentLibrary = libraries.find(l => l.id === libraryId);\n loadBookshelf();\n }\n\n function loadBookshelf() {\n if (!currentLibrary) {\n showEmptyState();\n return;\n }\n\n const loading = document.getElementById('loading');\n const booksGrid = document.getElementById('books-grid');\n const emptyState = document.getElementById('empty-state');\n\n loading.style.display = 'block';\n booksGrid.classList.add('hidden');\n emptyState.classList.add('hidden');\n\n fetch(`/api/media-iteLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"border-b header-nav\" style=\"border-color: var(--border); background-color: var(--bg-secondary)\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"flex justify-between items-center h-16\"><!-- Left: App Title --><div class=\"flex items-center\"><a href=\"/bookshelf\" class=\"text-xl font-bold hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); text-decoration: none;\">📚 Bookmann</a></div><!-- Center: Search Box --><div class=\"flex-1 max-w-2xl mx-8\"><div class=\"relative\"><input type=\"text\" id=\"header-search\" placeholder=\"Search your library...\" class=\"w-full px-4 py-2 pl-10 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\"> <svg class=\"absolute left-3 top-2.5 h-5 w-5\" style=\"color: var(--text-secondary)\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"></path></svg></div></div><!-- Right: Theme Switcher & User Menu --><div class=\"flex items-center space-x-4\"><!-- Theme Switcher --><div class=\"relative\"><button onclick=\"toggleThemeDropdown()\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-primary);\"><svg class=\"h-6 w-6\" style=\"color: var(--text-primary)\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\"></path></svg></button><div id=\"theme-dropdown\" class=\"hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"p-4\"><h3 class=\"text-sm font-semibold mb-3\" style=\"color: var(--text-primary)\">Select Theme</h3><div class=\"space-y-2\"><button onclick=\"changeThemeTo('tokyo-night')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #7aa2f7;\"></span> Tokyo Night</button> <button onclick=\"changeThemeTo('dracula')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #bd93f9;\"></span> Dracula</button> <button onclick=\"changeThemeTo('nord')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #88c0d0;\"></span> Nord</button> <button onclick=\"changeThemeTo('solarized-dark')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #2aa198;\"></span> Solarized Dark</button> <button onclick=\"changeThemeTo('monokai')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #a6e22e;\"></span> Monokai</button> <button onclick=\"changeThemeTo('one-dark-pro')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #61dafb;\"></span> One Dark Pro</button> <button onclick=\"changeThemeTo('material-dark')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #80cbc4;\"></span> Material Dark</button><div class=\"border-t pt-2 mt-2\" style=\"border-color: var(--border);\"><p class=\"text-xs mb-2\" style=\"color: var(--text-secondary)\">Background Options</p><button onclick=\"changeThemeTo('wood-light')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background: linear-gradient(135deg, #deb887 0%, #d2a679 100%);\"></span> Wood Light</button> <button onclick=\"changeThemeTo('wood-dark')\" class=\"w-full text-left px-3 py-2 rounded hoveLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"border-b header-nav\" style=\"border-color: var(--border); background-color: var(--bg-secondary)\"><div class=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8\"><div class=\"flex justify-between items-center h-16\"><!-- Left: App Title --><div class=\"flex items-center\"><a href=\"/bookshelf\" class=\"text-xl font-bold hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); text-decoration: none;\">📚 Bookmann</a></div><!-- Center: Search Box --><div class=\"flex-1 max-w-2xl mx-8\"><div class=\"relative\"><input type=\"text\" id=\"header-search\" placeholder=\"Search your library...\" class=\"w-full px-4 py-2 pl-10 border rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);\" autocomplete=\"off\"> <svg class=\"absolute left-3 top-2.5 h-5 w-5\" style=\"color: var(--text-secondary)\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"></path></svg></div></div><!-- Right: Theme Switcher & User Menu --><div class=\"flex items-center space-x-4\"><!-- Theme Switcher --><div class=\"relative\"><button onclick=\"toggleThemeDropdown()\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-primary);\"><svg class=\"h-6 w-6\" style=\"color: var(--text-primary)\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\"></path></svg></button><div id=\"theme-dropdown\" class=\"hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"p-4\"><h3 class=\"text-sm font-semibold mb-3\" style=\"color: var(--text-primary)\">Select Theme</h3><div class=\"space-y-2\"><button onclick=\"changeThemeTo('tokyo-night')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #7aa2f7;\"></span> Tokyo Night</button> <button onclick=\"changeThemeTo('dracula')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #bd93f9;\"></span> Dracula</button> <button onclick=\"changeThemeTo('nord')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #88c0d0;\"></span> Nord</button> <button onclick=\"changeThemeTo('solarized-dark')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #2aa198;\"></span> Solarized Dark</button> <button onclick=\"changeThemeTo('monokai')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #a6e22e;\"></span> Monokai</button> <button onclick=\"changeThemeTo('one-dark-pro')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #61dafb;\"></span> One Dark Pro</button> <button onclick=\"changeThemeTo('material-dark')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background-color: #80cbc4;\"></span> Material Dark</button><div class=\"border-t pt-2 mt-2\" style=\"border-color: var(--border);\"><p class=\"text-xs mb-2\" style=\"color: var(--text-secondary)\">Background Options</p><button onclick=\"changeThemeTo('wood-light')\" class=\"w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\"><span class=\"inline-block w-4 h-4 rounded mr-2\" style=\"background: linear-gradient(135deg, #deb887 0%, #d2a679 100%);\"></span> Wood Light</button> <button onclick=\"changeThemeTo('wood-dark')\" class=\"w-full text-left pLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 96, Col: 116}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 97, Col: 116}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user