From ecacfab4d68a3b104e612bdedb0aa2c9a1be58bb Mon Sep 17 00:00:00 2001 From: Rustem Kamalov Date: Tue, 14 Apr 2026 02:05:58 +0300 Subject: [PATCH] Add integration tests for multiple search engines and enhance error handling - Implement integration tests for Baidu, Bing, DuckDuckGo, Google, and Yandex. - Refactor test utility functions to avoid core import cycles. --- README.md | 5 +-- baidu/search_integration_test.go | 54 +++++++++++++++++++++++++++ bing/search_integration_test.go | 54 +++++++++++++++++++++++++++ cmd/root.go | 2 +- duckduckgo/search_integration_test.go | 54 +++++++++++++++++++++++++++ google/search_integration_test.go | 54 +++++++++++++++++++++++++++ testutil/fixtures.go | 48 ++++++++++++++++-------- testutil/integration.go | 12 ++++++ testutil/ithelper/ithelper.go | 53 ++++++++++++++++++++++++++ yandex/search_test.go | 27 +++----------- 10 files changed, 321 insertions(+), 42 deletions(-) create mode 100644 baidu/search_integration_test.go create mode 100644 bing/search_integration_test.go create mode 100644 duckduckgo/search_integration_test.go create mode 100644 google/search_integration_test.go create mode 100644 testutil/ithelper/ithelper.go diff --git a/README.md b/README.md index 88cf312..dc144c3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/karust/openserp)](https://goreportcard.com/report/github.com/karust/openserp) [![Go Reference](https://pkg.go.dev/badge/github/karust/openserp?style=for-the-badge)](https://pkg.go.dev/github.com/karust/openserp) [![release](https://img.shields.io/github/release/karust/openserp)](https://github.com/karust/openserp/releases) +[![CI](https://github.com/karust/openserp/actions/workflows/ci.yml/badge.svg?branch=tests)](https://github.com/karust/openserp/actions/workflows/ci.yml) @@ -15,7 +16,6 @@ A developer-friendly alternative to paid SERP API services! > 💡 OpenSerp is free and open-source. Only links listed in this repository and on the official website are associated with the project. - ## Features - 🔍 **Multi-engine** - search with dedicated endpoints for each engine @@ -184,5 +184,4 @@ If you encounter issues or have questions: - Check existing issues for similar reports - Review the documentation and example config - -###### *"OpenSerp" is the name of this open-source project. Use of the name in a way that implies affiliation, endorsement, or official status is not permitted.* +###### _"OpenSerp" is the name of this open-source project. Use of the name in a way that implies affiliation, endorsement, or official status is not permitted._ diff --git a/baidu/search_integration_test.go b/baidu/search_integration_test.go new file mode 100644 index 0000000..c609be5 --- /dev/null +++ b/baidu/search_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration +// +build integration + +package baidu + +import ( + "testing" + + "github.com/karust/openserp/core" + "github.com/karust/openserp/testutil" + "github.com/karust/openserp/testutil/ithelper" +) + +func TestSearchBaidu(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + baid := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golang programming", Limit: 10} + results, err := baid.Search(query) + ithelper.HandleError(t, "baidu web search", err) + + if len(results) == 0 { + t.Fatal("returned empty results") + } + if results[0].URL == "" { + t.Fatal("first result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first result title is empty") + } +} + +func TestImageSearchBaidu(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + baid := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golden retriever puppy", Limit: 10} + results, err := baid.SearchImage(query) + ithelper.HandleError(t, "baidu image search", err) + + if len(results) == 0 { + t.Fatal("returned empty image results") + } + if results[0].URL == "" { + t.Fatal("first image result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first image result title is empty") + } +} diff --git a/bing/search_integration_test.go b/bing/search_integration_test.go new file mode 100644 index 0000000..76530d9 --- /dev/null +++ b/bing/search_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration +// +build integration + +package bing + +import ( + "testing" + + "github.com/karust/openserp/core" + "github.com/karust/openserp/testutil" + "github.com/karust/openserp/testutil/ithelper" +) + +func TestSearchBing(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + bing := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golang programming", Limit: 10} + results, err := bing.Search(query) + ithelper.HandleError(t, "bing web search", err) + + if len(results) == 0 { + t.Fatal("returned empty results") + } + if results[0].URL == "" { + t.Fatal("first result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first result title is empty") + } +} + +func TestImageSearchBing(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + bing := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golden retriever puppy", Limit: 10} + results, err := bing.SearchImage(query) + ithelper.HandleError(t, "bing image search", err) + + if len(results) == 0 { + t.Fatal("returned empty image results") + } + if results[0].URL == "" { + t.Fatal("first image result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first image result title is empty") + } +} diff --git a/cmd/root.go b/cmd/root.go index 4c5a301..42b6d38 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,7 +14,7 @@ import ( ) const ( - version = "0.6.2" + version = "0.6.3" defaultConfigFilename = "config" envPrefix = "OPENSERP" ) diff --git a/duckduckgo/search_integration_test.go b/duckduckgo/search_integration_test.go new file mode 100644 index 0000000..4c4b0d3 --- /dev/null +++ b/duckduckgo/search_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration +// +build integration + +package duckduckgo + +import ( + "testing" + + "github.com/karust/openserp/core" + "github.com/karust/openserp/testutil" + "github.com/karust/openserp/testutil/ithelper" +) + +func TestSearchDuckDuckGo(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + ddg := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "wikipedia", Limit: 10} + results, err := ddg.Search(query) + ithelper.HandleError(t, "duckduckgo web search", err) + + if len(results) == 0 { + t.Fatal("returned empty results") + } + if results[0].URL == "" { + t.Fatal("first result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first result title is empty") + } +} + +func TestImageSearchDuckDuckGo(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + ddg := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golden retriever puppy", Limit: 10} + results, err := ddg.SearchImage(query) + ithelper.HandleError(t, "duckduckgo image search", err) + + if len(results) == 0 { + t.Fatal("returned empty image results") + } + if results[0].URL == "" { + t.Fatal("first image result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first image result title is empty") + } +} diff --git a/google/search_integration_test.go b/google/search_integration_test.go new file mode 100644 index 0000000..912f3ad --- /dev/null +++ b/google/search_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration +// +build integration + +package google + +import ( + "testing" + + "github.com/karust/openserp/core" + "github.com/karust/openserp/testutil" + "github.com/karust/openserp/testutil/ithelper" +) + +func TestSearchGoogle(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + gogl := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golang programming", Limit: 10} + results, err := gogl.Search(query) + ithelper.HandleError(t, "google web search", err) + + if len(results) == 0 { + t.Fatal("returned empty results") + } + if results[0].URL == "" { + t.Fatal("first result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first result title is empty") + } +} + +func TestImageSearchGoogle(t *testing.T) { + testutil.RequireIntegration(t) + + browser := ithelper.CreateBrowser(t) + gogl := New(*browser, core.SearchEngineOptions{}) + + query := core.Query{Text: "golden retriever puppy", Limit: 10} + results, err := gogl.SearchImage(query) + ithelper.HandleError(t, "google image search", err) + + if len(results) == 0 { + t.Fatal("returned empty image results") + } + if results[0].URL == "" { + t.Fatal("first image result URL is empty") + } + if results[0].Title == "" { + t.Fatal("first image result title is empty") + } +} diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 20f027f..82c2d92 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -6,9 +6,8 @@ import ( "net/http" "os" "path/filepath" + "reflect" "testing" - - "github.com/karust/openserp/core" ) // ResponseFromFixture reads an HTML file from the package's testdata/ directory @@ -39,32 +38,49 @@ func ResponseFromBytes(data []byte) *http.Response { } // AssertSequentialRanks verifies that result ranks start at 1 and increase by 1. -func AssertSequentialRanks(t *testing.T, results []core.SearchResult) { +// Accepts any slice of structs with an int Rank field (avoids core import cycle). +func AssertSequentialRanks(t *testing.T, results any) { t.Helper() - for i, r := range results { - if r.Rank != i+1 { - t.Fatalf("rank sequence broken at index %d: got %d, want %d", i, r.Rank, i+1) + v := reflect.ValueOf(results) + if v.Kind() != reflect.Slice { + t.Fatalf("expected a slice of results, got %T", results) + } + + for i := 0; i < v.Len(); i++ { + item := v.Index(i) + if item.Kind() == reflect.Ptr { + item = item.Elem() + } + rank := int(item.FieldByName("Rank").Int()) + if rank != i+1 { + t.Fatalf("rank sequence broken at index %d: got %d, want %d", i, rank, i+1) } } } // AssertFirstResultFilled checks that the first result has non-empty URL, Title, and Description. -func AssertFirstResultFilled(t *testing.T, results []core.SearchResult) { +// Accepts any slice of structs with those string fields (avoids core import cycle). +func AssertFirstResultFilled(t *testing.T, results any) { t.Helper() - if len(results) == 0 { + v := reflect.ValueOf(results) + if v.Kind() != reflect.Slice { + t.Fatalf("expected a slice of results, got %T", results) + } + if v.Len() == 0 { t.Fatal("expected at least one result") } - first := results[0] - if first.URL == "" { - t.Fatal("first result URL is empty") + first := v.Index(0) + if first.Kind() == reflect.Ptr { + first = first.Elem() } - if first.Title == "" { - t.Fatal("first result title is empty") - } - if first.Description == "" { - t.Fatal("first result description is empty") + + for _, field := range []string{"URL", "Title", "Description"} { + val := first.FieldByName(field) + if !val.IsValid() || val.String() == "" { + t.Fatalf("first result %s is empty", field) + } } } diff --git a/testutil/integration.go b/testutil/integration.go index bef55af..e0b6435 100644 --- a/testutil/integration.go +++ b/testutil/integration.go @@ -7,6 +7,8 @@ import ( ) const IntegrationEnv = "OPENSERP_INTEGRATION_TESTS" +const IntegrationHeadfulEnv = "OPENSERP_INTEGRATION_HEADFUL" +const IntegrationStrictEnv = "OPENSERP_INTEGRATION_STRICT" func RequireIntegration(t *testing.T) { t.Helper() @@ -15,6 +17,16 @@ func RequireIntegration(t *testing.T) { } } +func IntegrationHeadful() bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(IntegrationHeadfulEnv))) + return value == "1" || value == "true" || value == "yes" +} + +func IntegrationStrict() bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(IntegrationStrictEnv))) + return value == "1" || value == "true" || value == "yes" +} + func RequireEnv(t *testing.T, key string) string { t.Helper() value := strings.TrimSpace(os.Getenv(key)) diff --git a/testutil/ithelper/ithelper.go b/testutil/ithelper/ithelper.go new file mode 100644 index 0000000..7877fd1 --- /dev/null +++ b/testutil/ithelper/ithelper.go @@ -0,0 +1,53 @@ +package ithelper + +import ( + "testing" + "time" + + "github.com/karust/openserp/core" + "github.com/karust/openserp/testutil" +) + +// HandleError skips on captcha/timeout (expected in live environments), +// fatals on other errors. In strict mode, captcha/timeout also fatal. +func HandleError(t *testing.T, operation string, err error) { + t.Helper() + if err == nil { + return + } + + if err == core.ErrCaptcha { + t.Logf("captcha detected during %s: %v", operation, err) + if testutil.IntegrationStrict() { + t.Fatalf("%s failed (strict mode): %v", operation, err) + } + t.Skipf("skipping flaky live %s due to captcha: %v", operation, err) + } + + if err == core.ErrSearchTimeout { + if testutil.IntegrationStrict() { + t.Fatalf("%s failed (strict mode): %v", operation, err) + } + t.Skipf("skipping flaky live %s due to timeout: %v", operation, err) + } + + t.Fatalf("%s failed: %v", operation, err) +} + +// CreateBrowser creates a browser configured for integration tests. +// Respects OPENSERP_INTEGRATION_HEADFUL for debugging. +func CreateBrowser(t *testing.T) *core.Browser { + t.Helper() + headful := testutil.IntegrationHeadful() + opts := core.BrowserOpts{ + IsHeadless: !headful, + IsLeakless: false, + Timeout: time.Second * 15, + LeavePageOpen: headful, + } + b, err := core.NewBrowser(opts) + if err != nil { + t.Fatalf("failed to create test browser: %v", err) + } + return b +} diff --git a/yandex/search_test.go b/yandex/search_test.go index fc66615..23abcaf 100644 --- a/yandex/search_test.go +++ b/yandex/search_test.go @@ -5,36 +5,21 @@ package yandex import ( "testing" - "time" "github.com/karust/openserp/core" "github.com/karust/openserp/testutil" + "github.com/karust/openserp/testutil/ithelper" ) -func createTestBrowser(t *testing.T) *core.Browser { - t.Helper() - opts := core.BrowserOpts{IsHeadless: false, IsLeakless: false, Timeout: time.Second * 15, LeavePageOpen: true} - b, err := core.NewBrowser(opts) - if err != nil { - t.Fatalf("failed to create test browser: %v", err) - } - return b -} - func TestSearchYandex(t *testing.T) { testutil.RequireIntegration(t) - browser := createTestBrowser(t) + browser := ithelper.CreateBrowser(t) yand := New(*browser, core.SearchEngineOptions{}) query := core.Query{Text: "HEY", Limit: 10} results, err := yand.Search(query) - if err != nil { - if err == core.ErrSearchTimeout || err == core.ErrCaptcha { - t.Skipf("skipping unstable live yandex search result: %v", err) - } - t.Fatalf("Cannot [SearchYandex]: %s", err) - } + ithelper.HandleError(t, "yandex web search", err) if len(results) == 0 { t.Fatalf("[SearchYandex] returned empty result") @@ -44,14 +29,12 @@ func TestSearchYandex(t *testing.T) { func TestImageYandex(t *testing.T) { testutil.RequireIntegration(t) - browser := createTestBrowser(t) + browser := ithelper.CreateBrowser(t) yand := New(*browser, core.SearchEngineOptions{}) query := core.Query{Text: "furry tiger", Limit: 30} results, err := yand.SearchImage(query) - if err != nil { - t.Fatalf("Cannot [ImageYandex]: %s", err) - } + ithelper.HandleError(t, "yandex image search", err) if len(results) == 0 { t.Fatalf("[ImageYandex] returned empty result")