From c5c2700311be15833c3e3fa5d6d92fdace03294b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Jan 2026 12:08:28 -0500 Subject: [PATCH] test(server): add integration tests --- cmd/server/tests/integration_test.go | 871 +++++++++++++++++++++++++++ 1 file changed, 871 insertions(+) create mode 100644 cmd/server/tests/integration_test.go diff --git a/cmd/server/tests/integration_test.go b/cmd/server/tests/integration_test.go new file mode 100644 index 0000000..f019b8a --- /dev/null +++ b/cmd/server/tests/integration_test.go @@ -0,0 +1,871 @@ +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" +) + +type TestContext struct { + AdminToken string + UserToken string + AdminID string + UserID string + LibraryID string + EbookID 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 "" +} + +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) + } + + 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() + + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK { + var lib map[string]interface{} + body, _ := io.ReadAll(resp.Body) + err := json.Unmarshal(body, &lib) + require.NoError(t, err) + + ctx.LibraryID = lib["id"].(string) + t.Logf("Created new library: %v", lib["name"]) + } + + 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) + }) + + 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("Ebooks", func(t *testing.T) { + testEbooks(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) + }) + + 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) { + req := map[string]interface{}{ + "current_password": "UserPass123!", + "new_password": "NewPass123!", + "confirm_password": "NewPass123!", + } + + resp := makeRequest(t, "PUT", "/api/auth/password", req, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Change back + req = map[string]interface{}{ + "current_password": "NewPass123!", + "new_password": "UserPass123!", + "confirm_password": "UserPass123!", + } + resp = makeRequest(t, "PUT", "/api/auth/password", req, ctx.UserToken) + 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_ids": []string{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 testEbooks(t *testing.T, ctx *TestContext) { + t.Run("ListEbooks", func(t *testing.T) { + resp := makeRequest(t, "GET", "/api/ebooks", nil, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("CreateEbook_Admin", func(t *testing.T) { + req := map[string]interface{}{ + "title": "Integration Test Ebook", + "author": "Test Author", + "isbn": "9999999999", + "description": "Created during integration test", + "file_path": "/tmp/test_integration.epub", + "file_size": 1024, + "mime_type": "application/epub+zip", + "publisher": "Test Publisher", + "date_published": "2024-01-01", + } + + resp := makeRequest(t, "POST", "/api/ebooks", req, ctx.AdminToken) + defer resp.Body.Close() + + if resp.StatusCode == http.StatusCreated { + var ebook map[string]interface{} + body, _ := io.ReadAll(resp.Body) + err := json.Unmarshal(body, &ebook) + require.NoError(t, err) + + ctx.EbookID = ebook["id"].(string) + } + }) + + t.Run("CreateEbook_UserForbidden", func(t *testing.T) { + req := map[string]interface{}{ + "title": "User Ebook", + "file_path": "/tmp/user.epub", + "file_size": 1024, + "mime_type": "application/epub+zip", + } + + resp := makeRequest(t, "POST", "/api/ebooks", req, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + if ctx.EbookID != "" { + t.Run("GetEbook", func(t *testing.T) { + resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s", ctx.EbookID), nil, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("UpdateEbook_Admin", func(t *testing.T) { + req := map[string]interface{}{ + "title": "Updated Ebook Title", + "description": "Updated during test", + } + + resp := makeRequest(t, "PUT", fmt.Sprintf("/api/ebooks/%s", ctx.EbookID), req, ctx.AdminToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("CreateEbookRating", func(t *testing.T) { + req := map[string]interface{}{ + "rating": 5, + } + + resp := makeRequest(t, "POST", fmt.Sprintf("/api/ebooks/%s/rating", ctx.EbookID), req, ctx.UserToken) + defer resp.Body.Close() + + assert.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK) + }) + + t.Run("GetEbookRating", func(t *testing.T) { + resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s/rating", ctx.EbookID), nil, ctx.UserToken) + defer resp.Body.Close() + + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("UpdateReadingProgress", func(t *testing.T) { + req := map[string]interface{}{ + "progress_percentage": 50, + "page_number": 125, + "total_pages": 250, + } + + resp := makeRequest(t, "POST", fmt.Sprintf("/api/ebooks/%s/progress", ctx.EbookID), req, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("GetReadingProgress", func(t *testing.T) { + resp := makeRequest(t, "GET", fmt.Sprintf("/api/ebooks/%s/progress", ctx.EbookID), nil, ctx.UserToken) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } +} + +func testMediaItems(t *testing.T, ctx *TestContext) { + t.Run("ListMediaItems", func(t *testing.T) { + resp := makeRequest(t, "GET", "/api/media-items", nil, ctx.UserToken) + 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) + } + } + } + }) + + if ctx.MediaItemID != "" { + 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() + + assert.Equal(t, http.StatusOK, 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) + }) +}