|
|
@@ -0,0 +1,341 @@
|
|
|
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
|
|
+// SPDX-License-Identifier: Apache-2.0
|
|
|
+
|
|
|
+package linkwarden
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "io"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "testing"
|
|
|
+)
|
|
|
+
|
|
|
+func TestCreateBookmark(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ baseURL string
|
|
|
+ apiKey string
|
|
|
+ collectionId *int64
|
|
|
+ entryURL string
|
|
|
+ entryTitle string
|
|
|
+ serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64)
|
|
|
+ wantErr bool
|
|
|
+ errContains string
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "successful bookmark creation without collection",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test Article",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Verify authorization header
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
+ if auth != "Bearer test-api-key" {
|
|
|
+ t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify content type
|
|
|
+ contentType := r.Header.Get("Content-Type")
|
|
|
+ if contentType != "application/json" {
|
|
|
+ t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse and verify request
|
|
|
+ body, _ := io.ReadAll(r.Body)
|
|
|
+ var req map[string]interface{}
|
|
|
+ if err := json.Unmarshal(body, &req); err != nil {
|
|
|
+ t.Errorf("Failed to parse request body: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify URL
|
|
|
+ if reqURL := req["url"]; reqURL != "https://example.com" {
|
|
|
+ t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify title/name
|
|
|
+ if reqName := req["name"]; reqName != "Test Article" {
|
|
|
+ t.Errorf("Expected name 'Test Article', got %v", reqName)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify collection is not present when nil
|
|
|
+ if _, ok := req["collection"]; ok {
|
|
|
+ t.Error("Expected collection field to be omitted when collectionId is nil")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return success response
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
+ json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
+ "id": "123",
|
|
|
+ "url": "https://example.com",
|
|
|
+ "name": "Test Article",
|
|
|
+ })
|
|
|
+ },
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "successful bookmark creation with collection",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: int64Ptr(42),
|
|
|
+ entryURL: "https://example.com/article",
|
|
|
+ entryTitle: "Test Article With Collection",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Verify authorization header
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
+ if auth != "Bearer test-api-key" {
|
|
|
+ t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse and verify request
|
|
|
+ body, _ := io.ReadAll(r.Body)
|
|
|
+ var req map[string]interface{}
|
|
|
+ if err := json.Unmarshal(body, &req); err != nil {
|
|
|
+ t.Errorf("Failed to parse request body: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify URL
|
|
|
+ if reqURL := req["url"]; reqURL != "https://example.com/article" {
|
|
|
+ t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify title/name
|
|
|
+ if reqName := req["name"]; reqName != "Test Article With Collection" {
|
|
|
+ t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Verify collection is present and correct
|
|
|
+ if collection, ok := req["collection"]; ok {
|
|
|
+ collectionMap, ok := collection.(map[string]interface{})
|
|
|
+ if !ok {
|
|
|
+ t.Error("Expected collection to be a map")
|
|
|
+ }
|
|
|
+ if collectionID, ok := collectionMap["id"]; ok {
|
|
|
+ // JSON numbers are float64
|
|
|
+ if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
|
|
|
+ t.Errorf("Expected collection id 42, got %v", collectionID)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ t.Error("Expected collection to have 'id' field")
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ t.Error("Expected collection field to be present when collectionId is set")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return success response
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
+ json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
+ "id": "124",
|
|
|
+ "url": "https://example.com/article",
|
|
|
+ "name": "Test Article With Collection",
|
|
|
+ })
|
|
|
+ },
|
|
|
+ wantErr: false,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "missing API key",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Should not be called
|
|
|
+ t.Error("Server should not be called when API key is missing")
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "missing base URL or API key",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "server error",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ w.WriteHeader(http.StatusInternalServerError)
|
|
|
+ w.Write([]byte(`{"error": "Internal server error"}`))
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "unable to create link: status=500",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "bad request with null collection id error",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ w.WriteHeader(http.StatusBadRequest)
|
|
|
+ w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "unable to create link: status=400",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "unauthorized",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "invalid-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ w.WriteHeader(http.StatusUnauthorized)
|
|
|
+ w.Write([]byte(`{"error": "Unauthorized"}`))
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "unable to create link: status=401",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "invalid base URL",
|
|
|
+ baseURL: ":",
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Should not be called
|
|
|
+ t.Error("Server should not be called when base URL is invalid")
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "invalid API endpoint",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "missing base URL",
|
|
|
+ baseURL: "",
|
|
|
+ apiKey: "",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Should not be called
|
|
|
+ t.Error("Server should not be called when base URL is missing")
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "missing base URL or API key",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "network connection error",
|
|
|
+ baseURL: "http://localhost:1", // Invalid port that should fail to connect
|
|
|
+ apiKey: "test-api-key",
|
|
|
+ collectionId: nil,
|
|
|
+ entryURL: "https://example.com",
|
|
|
+ entryTitle: "Test",
|
|
|
+ serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
|
|
|
+ // Should not be called due to connection failure
|
|
|
+ t.Error("Server should not be called when connection fails")
|
|
|
+ },
|
|
|
+ wantErr: true,
|
|
|
+ errContains: "unable to send request",
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ // Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
|
|
|
+ var server *httptest.Server
|
|
|
+ if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
|
|
|
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ tt.serverResponse(w, r, t, tt.collectionId)
|
|
|
+ }))
|
|
|
+ defer server.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // Use test server URL if baseURL is empty and we have a server
|
|
|
+ baseURL := tt.baseURL
|
|
|
+ if baseURL == "" && server != nil {
|
|
|
+ baseURL = server.URL
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create client
|
|
|
+ client := NewClient(baseURL, tt.apiKey, tt.collectionId)
|
|
|
+
|
|
|
+ // Call CreateBookmark
|
|
|
+ err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
|
|
|
+
|
|
|
+ // Check error
|
|
|
+ if tt.wantErr {
|
|
|
+ if err == nil {
|
|
|
+ t.Error("Expected error, got nil")
|
|
|
+ } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
|
|
|
+ t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if err != nil {
|
|
|
+ t.Errorf("Expected no error, got %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestNewClient(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ name string
|
|
|
+ baseURL string
|
|
|
+ apiKey string
|
|
|
+ collectionId *int64
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "client without collection",
|
|
|
+ baseURL: "https://linkwarden.example.com",
|
|
|
+ apiKey: "test-key",
|
|
|
+ collectionId: nil,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "client with collection",
|
|
|
+ baseURL: "https://linkwarden.example.com",
|
|
|
+ apiKey: "test-key",
|
|
|
+ collectionId: int64Ptr(123),
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
+ client := NewClient(tt.baseURL, tt.apiKey, tt.collectionId)
|
|
|
+
|
|
|
+ if client.baseURL != tt.baseURL {
|
|
|
+ t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ if client.apiKey != tt.apiKey {
|
|
|
+ t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
|
|
|
+ }
|
|
|
+
|
|
|
+ if tt.collectionId == nil {
|
|
|
+ if client.collectionId != nil {
|
|
|
+ t.Errorf("Expected collectionId to be nil, got %v", *client.collectionId)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if client.collectionId == nil {
|
|
|
+ t.Error("Expected collectionId to be set, got nil")
|
|
|
+ } else if *client.collectionId != *tt.collectionId {
|
|
|
+ t.Errorf("Expected collectionId %d, got %d", *tt.collectionId, *client.collectionId)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Helper function to create int64 pointer
|
|
|
+func int64Ptr(i int64) *int64 {
|
|
|
+ return &i
|
|
|
+}
|
|
|
+
|
|
|
+// Helper function to check if string contains substring
|
|
|
+func contains(s, substr string) bool {
|
|
|
+ return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsSubstring(s, substr))
|
|
|
+}
|
|
|
+
|
|
|
+func containsSubstring(s, substr string) bool {
|
|
|
+ for i := 0; i <= len(s)-len(substr); i++ {
|
|
|
+ if s[i:i+len(substr)] == substr {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|