2026-10-05 13:41:48 -04:00
package recipes
import (
"context"
"crypto/rand"
2026-10-05 16:05:06 -04:00
"database/sql"
2026-10-05 13:41:48 -04:00
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
)
const searxngBody = `{
"query": "chicken curry recipe",
"results": [
{"url": "https://www.bbcgoodfood.com/recipes/easy-chicken-curry", "title": "Easy chicken curry recipe | BBC Good Food", "content": "Make our easy chicken curry & rice.", "engine": "duckduckgo"},
{"url": "https://www.recipetineats.com/chicken-curry/", "title": "Chicken Curry", "content": "A proper curry."},
{"url": "https://example.com/third", "title": "Third", "content": ""}
],
"number_of_results": 0
}`
const braveBody = `{
"type": "search",
"web": {
"type": "search",
"results": [
{"url": "https://www.bbcgoodfood.com/recipes/easy-chicken-curry", "title": "Easy <strong>chicken curry</strong> recipe", "description": "Make our <strong>easy</strong> chicken curry & rice.", "language": "en"},
{"url": "https://www.recipetineats.com/chicken-curry/", "title": "Chicken Curry", "description": "A proper curry."}
]
}
}`
func newSearXNGServer ( t * testing . T , status int , body string , got * http . Request ) * SearXNG {
t . Helper ()
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if got != nil {
* got = * r . Clone ( context . Background ())
}
w . Header (). Set ( "Content-Type" , "application/json" )
w . WriteHeader ( status )
w . Write ([] byte ( body ))
}))
t . Cleanup ( srv . Close )
s , err := NewSearXNG ( srv . URL + "/searx" )
if err != nil {
t . Fatal ( err )
}
return s
}
func newBraveServer ( t * testing . T , key string , status int , body string , got * http . Request ) * Brave {
t . Helper ()
srv := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
if got != nil {
* got = * r . Clone ( context . Background ())
}
w . WriteHeader ( status )
w . Write ([] byte ( body ))
}))
t . Cleanup ( srv . Close )
b , err := NewBrave ( key )
if err != nil {
t . Fatal ( err )
}
b . endpoint = srv . URL + "/res/v1/web/search"
return b
}
func TestSearXNGSearch ( t * testing . T ) {
var req http . Request
s := newSearXNGServer ( t , http . StatusOK , searxngBody , & req )
got , err := s . Search ( context . Background (), "chicken curry" , 2 )
if err != nil {
t . Fatal ( err )
}
if req . URL . Path != "/searx/search" {
t . Errorf ( "path = %q, want /searx/search" , req . URL . Path )
}
q := req . URL . Query ()
if q . Get ( "q" ) != "chicken curry recipe" || q . Get ( "format" ) != "json" || q . Get ( "categories" ) != "general" {
t . Errorf ( "query = %v" , q )
}
if req . Header . Get ( "User-Agent" ) != UserAgent {
t . Errorf ( "User-Agent = %q" , req . Header . Get ( "User-Agent" ))
}
want := [] Candidate {
{ URL : "https://www.bbcgoodfood.com/recipes/easy-chicken-curry" , Title : "Easy chicken curry recipe | BBC Good Food" , Snippet : "Make our easy chicken curry & rice." },
{ URL : "https://www.recipetineats.com/chicken-curry/" , Title : "Chicken Curry" , Snippet : "A proper curry." },
}
if ! reflect . DeepEqual ( got , want ) {
t . Errorf ( "candidates =\n%#v\nwant\n%#v" , got , want )
}
}
func TestBraveSearch ( t * testing . T ) {
var req http . Request
b := newBraveServer ( t , "brave-key-123" , http . StatusOK , braveBody , & req )
got , err := b . Search ( context . Background (), "Chicken curry RECIPE" , 5 )
if err != nil {
t . Fatal ( err )
}
if req . Header . Get ( "X-Subscription-Token" ) != "brave-key-123" {
t . Errorf ( "X-Subscription-Token = %q" , req . Header . Get ( "X-Subscription-Token" ))
}
if req . Header . Get ( "Accept" ) != "application/json" {
t . Errorf ( "Accept = %q" , req . Header . Get ( "Accept" ))
}
q := req . URL . Query ()
if q . Get ( "q" ) != "Chicken curry RECIPE" || q . Get ( "count" ) != "5" {
t . Errorf ( "query = %v" , q )
}
want := [] Candidate {
{ URL : "https://www.bbcgoodfood.com/recipes/easy-chicken-curry" , Title : "Easy chicken curry recipe" , Snippet : "Make our easy chicken curry & rice." },
{ URL : "https://www.recipetineats.com/chicken-curry/" , Title : "Chicken Curry" , Snippet : "A proper curry." },
}
if ! reflect . DeepEqual ( got , want ) {
t . Errorf ( "candidates =\n%#v\nwant\n%#v" , got , want )
}
}
func TestSearchQuery ( t * testing . T ) {
for _ , tc := range [] struct { in , want string }{
{ "chicken curry" , "chicken curry recipe" },
{ " chicken curry " , "chicken curry recipe" },
{ "chicken curry recipe" , "chicken curry recipe" },
{ "Recipe for chicken curry" , "Recipe for chicken curry" },
{ "recipes for curry" , "recipes for curry recipe" },
} {
got , err := searchQuery ( tc . in , 3 )
if err != nil || got != tc . want {
t . Errorf ( "searchQuery(%q) = %q, %v; want %q" , tc . in , got , err , tc . want )
}
}
for _ , tc := range [] struct {
q string
n int
}{{ "" , 3 }, { " " , 3 }, { "curry" , 0 }, { "curry" , MaxSearchResults + 1 }} {
if _ , err := searchQuery ( tc . q , tc . n ); err == nil {
t . Errorf ( "searchQuery(%q, %d) succeeded, want error" , tc . q , tc . n )
}
}
}
func TestSearchErrors ( t * testing . T ) {
tests := [] struct {
name string
status int
body string
check func ( error ) bool
}{
{ "unauthorized" , http . StatusUnauthorized , `{}` , func ( err error ) bool { return errors . Is ( err , ErrSearchRefused ) }},
{ "forbidden" , http . StatusForbidden , `{}` , func ( err error ) bool { return errors . Is ( err , ErrSearchRefused ) }},
{ "rate limited" , http . StatusTooManyRequests , `{}` , func ( err error ) bool { return errors . Is ( err , ErrSearchRateLimited ) }},
{ "server error" , http . StatusBadGateway , `{}` , func ( err error ) bool {
var se * SearchStatusError
return errors . As ( err , & se ) && se . Status == http . StatusBadGateway
}},
{ "redirect not followed" , http . StatusFound , `` , func ( err error ) bool {
var se * SearchStatusError
return errors . As ( err , & se ) && se . Status == http . StatusFound
}},
{ "not JSON" , http . StatusOK , `<html>` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "result without URL" , http . StatusOK , `{"type":"search","results":[{"title":"x"}],"web":{"results":[{"title":"x"}]}}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "relative URL" , http . StatusOK , `{"type":"search","results":[{"url":"/x"}],"web":{"results":[{"url":"/x"}]}}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "empty object" , http . StatusOK , `{}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "null results" , http . StatusOK , `{"type":"search","results":null,"web":{"results":null}}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "results not an array" , http . StatusOK , `{"type":"search","results":{},"web":{"results":{}}}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
{ "body too large" , http . StatusOK , `{"results":[],"pad":"` + strings . Repeat ( "x" , MaxSearchBodyBytes ) + `"}` , func ( err error ) bool { return errors . Is ( err , ErrSearchResponse ) }},
}
for _ , tc := range tests {
t . Run ( tc . name , func ( t * testing . T ) {
providers := map [ string ] SearchProvider {
"SearXNG" : newSearXNGServer ( t , tc . status , tc . body , nil ),
"Brave" : newBraveServer ( t , "secret-key" , tc . status , tc . body , nil ),
}
for name , p := range providers {
_ , err := p . Search ( context . Background (), "curry" , 3 )
if err == nil || ! tc . check ( err ) {
t . Errorf ( "%s: err = %v" , name , err )
}
if err != nil && strings . Contains ( err . Error (), "secret-key" ) {
t . Errorf ( "%s: error leaks the API key: %v" , name , err )
}
}
})
}
}
// TestSearchNoMatches checks a well-formed response with nothing found is an
// empty search, not an error.
func TestSearchNoMatches ( t * testing . T ) {
providers := map [ string ] SearchProvider {
"SearXNG" : newSearXNGServer ( t , http . StatusOK , `{"query":"x recipe","results":[]}` , nil ),
"Brave without web" : newBraveServer ( t , "secret-key" , http . StatusOK , `{"type":"search","query":{"original":"x recipe"}}` , nil ),
"Brave empty web" : newBraveServer ( t , "secret-key" , http . StatusOK , `{"type":"search","web":{"type":"search","results":[]}}` , nil ),
}
for name , p := range providers {
got , err := p . Search ( context . Background (), "x" , 3 )
if err != nil || len ( got ) != 0 {
t . Errorf ( "%s: got %v, %v; want no candidates and no error" , name , got , err )
}
}
}
func TestSearchContextCancelled ( t * testing . T ) {
s := newSearXNGServer ( t , http . StatusOK , searxngBody , nil )
ctx , cancel := context . WithCancel ( context . Background ())
cancel ()
if _ , err := s . Search ( ctx , "curry" , 3 ); ! errors . Is ( err , context . Canceled ) {
t . Errorf ( "err = %v, want context.Canceled" , err )
}
}
func TestSearchUnreachable ( t * testing . T ) {
srv := httptest . NewServer ( http . NotFoundHandler ())
url := srv . URL
srv . Close ()
s , err := NewSearXNG ( url )
if err != nil {
t . Fatal ( err )
}
_ , err = s . Search ( context . Background (), "curry" , 3 )
if err == nil || ! strings . Contains ( err . Error (), "SearXNG search" ) {
t . Errorf ( "err = %v" , err )
}
}
func TestNewSearXNGRejects ( t * testing . T ) {
for _ , u := range [] string { "" , "searxng:8080" , "ftp://searxng" , "http://" , "http://u:p@searxng" , "http://searxng?x=1" , "http://searxng#f" } {
if _ , err := NewSearXNG ( u ); err == nil {
t . Errorf ( "NewSearXNG(%q) succeeded, want error" , u )
}
}
if _ , err := NewSearXNG ( "http://searxng:8080" ); err != nil {
t . Errorf ( "internal URL rejected: %v" , err )
}
}
func TestNewBraveRejects ( t * testing . T ) {
for _ , k := range [] string { "" , "with space" , "line\nbreak" } {
if _ , err := NewBrave ( k ); err == nil {
t . Errorf ( "NewBrave(%q) succeeded, want error" , k )
}
}
}
func TestNewSearchProvider ( t * testing . T ) {
raw := make ([] byte , secretbox . KeySize )
rand . Read ( raw )
box , err := secretbox . New ( base64 . StdEncoding . EncodeToString ( raw ))
if err != nil {
t . Fatal ( err )
}
ct , err := box . Encrypt ([] byte ( "brave-key" ))
if err != nil {
t . Fatal ( err )
}
2026-10-05 16:05:06 -04:00
p , err := NewSearchProvider ( db . RecipeSearchSetting { Provider : "brave" , ApiKeyCt : ct }, box )
2026-10-05 13:41:48 -04:00
if err != nil {
t . Fatal ( err )
}
if b , ok := p .( * Brave ); ! ok || b . key != "brave-key" || b . endpoint != BraveEndpoint {
t . Errorf ( "provider = %#v, want Brave with decrypted key" , p )
}
2026-10-05 16:05:06 -04:00
p , err = NewSearchProvider ( db . RecipeSearchSetting { Provider : "searxng" , BaseUrl : sql . NullString { String : "http://searxng:8080" , Valid : true }}, box )
2026-10-05 13:41:48 -04:00
if err != nil {
t . Fatal ( err )
}
if s , ok := p .( * SearXNG ); ! ok || s . endpoint . String () != "http://searxng:8080/search" {
t . Errorf ( "provider = %#v, want SearXNG at http://searxng:8080/search" , p )
}
2026-10-05 16:05:06 -04:00
for name , s := range map [ string ] db . RecipeSearchSetting {
"brave without key" : { Provider : "brave" },
"brave bad key" : { Provider : "brave" , ApiKeyCt : [] byte ( "not a token at all, far too plain" )},
"searxng without URL" : { Provider : "searxng" },
"unknown provider" : { Provider : "google" },
2026-10-05 13:41:48 -04:00
} {
2026-10-05 16:05:06 -04:00
if _ , err := NewSearchProvider ( s , box ); err == nil {
2026-10-05 13:41:48 -04:00
t . Errorf ( "%s: succeeded, want error" , name )
}
}
}