package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" ) func setupTestServer(t *testing.T) (*DB, *httptest.Server) { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") db, err := OpenDB(dbPath) if err != nil { t.Fatalf("OpenDB failed: %v", err) } journals := []Journal{ {FullName: "Journal of Applied Physics", Abbreviation: "J APPL PHYS"}, {FullName: "Nature Medicine", Abbreviation: "NAT MED"}, {FullName: "Physical Review Letters", Abbreviation: "PHYS REV LETT"}, {FullName: "Nature Biotechnology", Abbreviation: "NAT BIOTECHNOL"}, {FullName: "Nature & Science", Abbreviation: "NAT SCI"}, {FullName: "C++ Weekly", Abbreviation: "C WEEKLY"}, {FullName: "Physical Review (Letters)", Abbreviation: "PHYS REV LETT"}, {FullName: "Proceedings of the National Academy of Sciences", Abbreviation: "PROC NAT ACAD SCI"}, } if err := db.InsertJournals(journals); err != nil { t.Fatalf("InsertJournals failed: %v", err) } mux := http.NewServeMux() mux.HandleFunc("/", handleRoot()) mux.HandleFunc("/api/search", handleSearch(db, 1000)) mux.HandleFunc("/api/health", handleHealth(db, 1000)) server := httptest.NewServer(mux) t.Cleanup(func() { server.Close() db.Close() }) return db, server } func get(t *testing.T, url string) *http.Response { t.Helper() resp, err := http.Get(url) if err != nil { t.Fatalf("GET %s failed: %v", url, err) } return resp } func readBody(t *testing.T, resp *http.Response) string { t.Helper() defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("reading body: %v", err) } return string(b) } func decodeJournals(t *testing.T, body string) []Journal { t.Helper() var results []Journal if err := json.Unmarshal([]byte(body), &results); err != nil { t.Fatalf("json decode failed: %v\nbody: %s", err, body) } return results } func TestGetRoot(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/") defer resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("expected 200, got %d", resp.StatusCode) } ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "text/html") { t.Errorf("expected text/html content type, got %s", ct) } body := readBody(t, resp) if !strings.Contains(body, "Journal Abbreviations") { t.Error("expected page title in HTML body") } } func TestGetRootNotFound(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/nonexistent") defer resp.Body.Close() if resp.StatusCode != 404 { t.Errorf("expected 404 for /nonexistent, got %d", resp.StatusCode) } } func TestSearchByFullName(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=physics") body := readBody(t, resp) results := decodeJournals(t, body) found := false for _, j := range results { if j.FullName == "Journal of Applied Physics" { found = true if j.Abbreviation != "J APPL PHYS" { t.Errorf("expected abbreviation 'J APPL PHYS', got %q", j.Abbreviation) } } } if !found { t.Error("expected to find 'Journal of Applied Physics' when searching 'physics'") } } func TestSearchByAbbreviation(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=NAT+MED") body := readBody(t, resp) results := decodeJournals(t, body) found := false for _, j := range results { if j.FullName == "Nature Medicine" { found = true } } if !found { t.Errorf("expected to find 'Nature Medicine' when searching abbreviation 'NAT MED', got %d results", len(results)) } } func TestSearchCaseInsensitive(t *testing.T) { _, server := setupTestServer(t) lowerResp := get(t, server.URL+"/api/search?q=nature+medicine") lowerBody := readBody(t, lowerResp) lowerResults := decodeJournals(t, lowerBody) upperResp := get(t, server.URL+"/api/search?q=NATURE+MEDICINE") upperBody := readBody(t, upperResp) upperResults := decodeJournals(t, upperBody) if len(lowerResults) == 0 { t.Error("lowercase search returned no results") } if len(upperResults) == 0 { t.Error("uppercase search returned no results") } if len(lowerResults) != len(upperResults) { t.Errorf("case insensitive: lower=%d results, upper=%d results", len(lowerResults), len(upperResults)) } } func TestSearchMultipleMatches(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=nature") body := readBody(t, resp) results := decodeJournals(t, body) if len(results) < 2 { t.Errorf("expected at least 2 results for 'nature', got %d", len(results)) } names := map[string]bool{} for _, j := range results { names[j.FullName] = true } if !names["Nature Medicine"] { t.Error("expected 'Nature Medicine' in results") } if !names["Nature Biotechnology"] { t.Error("expected 'Nature Biotechnology' in results") } } func TestSearchNoResults(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=zzzzznonexistent") body := readBody(t, resp) if resp.StatusCode != 200 { t.Errorf("expected 200 for no-results query, got %d", resp.StatusCode) } results := decodeJournals(t, body) if len(results) != 0 { t.Errorf("expected 0 results for nonexistent query, got %d", len(results)) } } func TestSearchMissingQuery(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search") body := readBody(t, resp) if resp.StatusCode != 400 { t.Errorf("expected 400 for missing q param, got %d", resp.StatusCode) } if !strings.Contains(body, "missing query") { t.Errorf("expected error message about missing query, got: %s", body) } } func TestSearchWrongMethod(t *testing.T) { _, server := setupTestServer(t) resp, err := http.Post(server.URL+"/api/search?q=test", "text/plain", nil) if err != nil { t.Fatalf("POST request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != 405 { t.Errorf("expected 405 for POST, got %d", resp.StatusCode) } } func TestSearchLimitParameter(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=nature&limit=1") body := readBody(t, resp) results := decodeJournals(t, body) if len(results) > 1 { t.Errorf("expected at most 1 result with limit=1, got %d", len(results)) } } func TestSearchInvalidLimit(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=nature&limit=abc") body := readBody(t, resp) if resp.StatusCode != 200 { t.Errorf("expected 200 with invalid limit (should default), got %d", resp.StatusCode) } results := decodeJournals(t, body) if len(results) == 0 { t.Error("invalid limit should default to 50 and still return results") } } func TestSearchSpecialChars(t *testing.T) { _, server := setupTestServer(t) for _, q := range []string{"Nature & Science", "C++"} { resp := get(t, server.URL+"/api/search?q="+url.QueryEscape(q)) body := readBody(t, resp) if resp.StatusCode != 200 { t.Errorf("search for %q returned %d", q, resp.StatusCode) } results := decodeJournals(t, body) if len(results) == 0 { t.Errorf("expected results for %q", q) } } } func TestHealthEndpoint(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/health") body := readBody(t, resp) if resp.StatusCode != 200 { t.Errorf("expected 200, got %d", resp.StatusCode) } var result map[string]interface{} if err := json.Unmarshal([]byte(body), &result); err != nil { t.Fatalf("json decode failed: %v", err) } if result["status"] != "ok" { t.Errorf("expected status 'ok', got %v", result["status"]) } if result["db_loaded"] != true { t.Errorf("expected db_loaded true, got %v", result["db_loaded"]) } count, ok := result["total_journals"].(float64) if !ok || count < 1 { t.Errorf("expected total_journals >= 1, got %v", result["total_journals"]) } } func TestHealthWrongMethod(t *testing.T) { _, server := setupTestServer(t) resp, err := http.Post(server.URL+"/api/health", "text/plain", nil) if err != nil { t.Fatalf("POST request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != 405 { t.Errorf("expected 405 for POST to health, got %d", resp.StatusCode) } } func TestSearchResponseIsJSONArray(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=nature") body := readBody(t, resp) ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/json") { t.Errorf("expected application/json, got %s", ct) } if !strings.HasPrefix(strings.TrimSpace(body), "[") { t.Errorf("expected JSON array response, got: %s", body[:min(len(body), 50)]) } } func TestSearchResultStructure(t *testing.T) { _, server := setupTestServer(t) resp := get(t, server.URL+"/api/search?q=Nature+Medicine") body := readBody(t, resp) results := decodeJournals(t, body) if len(results) == 0 { t.Fatal("expected at least one result") } j := results[0] if j.ID == 0 { t.Error("expected non-zero ID") } if j.FullName == "" { t.Error("expected non-empty FullName") } if j.Abbreviation == "" { t.Error("expected non-empty Abbreviation") } } func TestRateLimiting(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") db, err := OpenDB(dbPath) if err != nil { t.Fatalf("OpenDB failed: %v", err) } journals := []Journal{ {FullName: "Test Journal", Abbreviation: "TEST J"}, } if err := db.InsertJournals(journals); err != nil { t.Fatalf("InsertJournals failed: %v", err) } mux := http.NewServeMux() mux.HandleFunc("/api/search", handleSearch(db, 3)) srv := httptest.NewServer(mux) t.Cleanup(func() { srv.Close() db.Close() }) tooManyCount := 0 for i := 0; i < 10; i++ { resp := get(t, srv.URL+"/api/search?q=test") resp.Body.Close() if resp.StatusCode == 429 { tooManyCount++ } } if tooManyCount == 0 { t.Error("expected at least one 429 response when rate limit is 3/min and we made 10 rapid requests") } }