feat: Go SearXNG client; throttled is not absence
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const cacheSchema = `
|
||||
CREATE TABLE IF NOT EXISTS responses (
|
||||
key TEXT PRIMARY KEY,
|
||||
fetched REAL NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenCache(path string) (*Cache, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(cacheSchema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Cache{db: db}, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Close() error {
|
||||
if c == nil || c.db == nil {
|
||||
return nil
|
||||
}
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func (c *Cache) Get(key string, ttl, now float64) (*Payload, error) {
|
||||
var fetched float64
|
||||
var raw string
|
||||
err := c.db.QueryRow("SELECT fetched, payload FROM responses WHERE key = ?", key).Scan(&fetched, &raw)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if now-fetched > ttl {
|
||||
return nil, nil
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Put(key string, p Payload, now float64) error {
|
||||
raw, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.db.Exec(
|
||||
"INSERT OR REPLACE INTO responses (key, fetched, payload) VALUES (?, ?, ?)",
|
||||
key, now, string(raw),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Cache) LastCall() (*float64, error) {
|
||||
var v float64
|
||||
err := c.db.QueryRow("SELECT value FROM meta WHERE key = 'last_call'").Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (c *Cache) MarkCall(now float64) error {
|
||||
_, err := c.db.Exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_call', ?)", now)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
User string
|
||||
Pass string
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("no credentials at %s (mode 600, BRAIN_SEARCH_URL)", path)
|
||||
}
|
||||
conf := map[string]string{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, "=")
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, `"'`)
|
||||
conf[strings.TrimSpace(k)] = v
|
||||
}
|
||||
out := Config{
|
||||
URL: conf["BRAIN_SEARCH_URL"],
|
||||
User: conf["BRAIN_SEARCH_USER"],
|
||||
Pass: conf["BRAIN_SEARCH_PASS"],
|
||||
}
|
||||
if out.URL == "" {
|
||||
return Config{}, fmt.Errorf("%s is missing BRAIN_SEARCH_URL", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Fetch(client *http.Client, conf Config, query string, params map[string]string, timeout time.Duration) (Payload, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: timeout}
|
||||
} else if timeout > 0 {
|
||||
c := *client
|
||||
c.Timeout = timeout
|
||||
client = &c
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("q", query)
|
||||
q.Set("format", "json")
|
||||
for k, v := range params {
|
||||
if v != "" {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
u := strings.TrimRight(conf.URL, "/") + "/search?" + q.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
if conf.User != "" || conf.Pass != "" {
|
||||
token := base64.StdEncoding.EncodeToString([]byte(conf.User + ":" + conf.Pass))
|
||||
req.Header.Set("Authorization", "Basic "+token)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return Payload{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadConfigRequiresURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "search.env")
|
||||
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_USER=x\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadConfig(p); err == nil {
|
||||
t.Fatal("expected missing URL error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigOptionalAuth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "search.env")
|
||||
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_URL=http://127.0.0.1:8080\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadConfig(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.URL != "http://127.0.0.1:8080" || c.User != "" || c.Pass != "" {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchJSONNoBasicAuth(t *testing.T) {
|
||||
payload := Payload{Query: "x", Results: []RawHit{{Title: "t", URL: "http://example.com", Content: "c", Engine: "bing"}}}
|
||||
var sawAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawAuth = r.Header.Get("Authorization")
|
||||
if r.URL.Query().Get("format") != "json" || r.URL.Query().Get("q") != "x" {
|
||||
t.Errorf("query = %s", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
got, err := Fetch(srv.Client(), Config{URL: srv.URL}, "x", nil, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sawAuth != "" {
|
||||
t.Fatalf("Authorization = %q, want empty for local instance", sawAuth)
|
||||
}
|
||||
if Classify(got) != StatusOK {
|
||||
t.Fatalf("classify = %s", Classify(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSendsBasicAuthWhenConfigured(t *testing.T) {
|
||||
var sawAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawAuth = r.Header.Get("Authorization")
|
||||
w.Write([]byte(`{"query":"x","results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := Fetch(srv.Client(), Config{URL: srv.URL, User: "u", Pass: "p"}, "x", nil, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sawAuth == "" {
|
||||
t.Fatal("expected Basic auth")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package websearch is the SearXNG client used as the second independent source.
|
||||
//
|
||||
// An empty result list from this instance is throttling, not evidence of absence.
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusThrottled = "throttled"
|
||||
|
||||
DefaultLimit = 5
|
||||
DefaultSnippetChars = 150
|
||||
MinInterval = 10.0
|
||||
CacheTTL = 7 * 24 * 3600
|
||||
)
|
||||
|
||||
var RetryBackoff = []float64{20, 60}
|
||||
|
||||
type Payload struct {
|
||||
Query string `json:"query"`
|
||||
Results []RawHit `json:"results"`
|
||||
UnresponsiveEngines [][]string `json:"unresponsive_engines"`
|
||||
}
|
||||
|
||||
type RawHit struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Engine string `json:"engine"`
|
||||
}
|
||||
|
||||
type Hit struct {
|
||||
Rank int `json:"rank"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Engine string `json:"engine"`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
Query string `json:"query"`
|
||||
Status string `json:"status"`
|
||||
Results []Hit `json:"results"`
|
||||
Unresponsive []string `json:"unresponsive,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Cached bool `json:"cached,omitempty"`
|
||||
}
|
||||
|
||||
func Classify(p Payload) string {
|
||||
if len(p.Results) > 0 {
|
||||
return StatusOK
|
||||
}
|
||||
return StatusThrottled
|
||||
}
|
||||
|
||||
func Project(p Payload, limit, snippetChars int) Output {
|
||||
if limit <= 0 {
|
||||
limit = DefaultLimit
|
||||
}
|
||||
if snippetChars <= 0 {
|
||||
snippetChars = DefaultSnippetChars
|
||||
}
|
||||
status := Classify(p)
|
||||
n := limit
|
||||
if n > len(p.Results) {
|
||||
n = len(p.Results)
|
||||
}
|
||||
hits := make([]Hit, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
item := p.Results[i]
|
||||
hits = append(hits, Hit{
|
||||
Rank: i + 1,
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: trimSnippet(item.Content, snippetChars),
|
||||
Engine: item.Engine,
|
||||
})
|
||||
}
|
||||
out := Output{
|
||||
Query: p.Query,
|
||||
Status: status,
|
||||
Results: hits,
|
||||
}
|
||||
for _, pair := range p.UnresponsiveEngines {
|
||||
if len(pair) >= 2 {
|
||||
out.Unresponsive = append(out.Unresponsive, pair[0]+": "+pair[1])
|
||||
} else if len(pair) == 1 {
|
||||
out.Unresponsive = append(out.Unresponsive, pair[0])
|
||||
}
|
||||
}
|
||||
if status == StatusThrottled {
|
||||
out.Note = "no engine answered - this is a throttled instance, not evidence that nothing exists"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var spaceRE = regexp.MustCompile(`\s+`)
|
||||
|
||||
func trimSnippet(s string, max int) string {
|
||||
s = strings.TrimSpace(spaceRE.ReplaceAllString(s, " "))
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
cut := strings.TrimRightFunc(string(runes[:max]), unicode.IsSpace)
|
||||
return cut + "..."
|
||||
}
|
||||
|
||||
func CacheKey(query string, params map[string]string) string {
|
||||
norm := strings.Join(strings.Fields(strings.ToLower(query)), " ")
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
stable, _ := json.Marshal(params)
|
||||
sum := sha256.Sum256([]byte(norm + "\x00" + string(stable)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func WaitFor(last *float64, now, interval float64) float64 {
|
||||
if last == nil {
|
||||
return 0
|
||||
}
|
||||
d := interval - (now - *last)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func PHIReason(query string) string {
|
||||
for _, p := range phiPatterns {
|
||||
if p.re.MatchString(query) {
|
||||
return p.reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type phiPat struct {
|
||||
re *regexp.Regexp
|
||||
reason string
|
||||
}
|
||||
|
||||
var phiPatterns = []phiPat{
|
||||
{regexp.MustCompile(`\d{6,}`), "a run of six or more digits looks like an ID"},
|
||||
{regexp.MustCompile(`(?i)\bpersonalnummer\b`), "Personalnummer is staff data"},
|
||||
{regexp.MustCompile(`(?i)\bkv[-\s]?nr\b`), "KV-Nr is an insurance number"},
|
||||
{regexp.MustCompile(`(?i)\bversichertennummer\b`), "insurance number"},
|
||||
{regexp.MustCompile(`(?i)\b[A-Za-zÄÖÜäöüß]+(?:stra(?:ss|ß)e|str\.)\s*\d+`), "a street with a house number looks like an address"},
|
||||
{regexp.MustCompile(`(?i)\bgeb(?:urtsdatum)?\.?\s*\d{1,2}[./]\d{1,2}[./]\d{2,4}`), "a date of birth"},
|
||||
}
|
||||
|
||||
func (o Output) YAML() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "query: %s\n", yamlScalar(o.Query))
|
||||
fmt.Fprintf(&b, "status: %s\n", yamlScalar(o.Status))
|
||||
if len(o.Results) == 0 {
|
||||
b.WriteString("results: []\n")
|
||||
} else {
|
||||
b.WriteString("results:\n")
|
||||
for _, r := range o.Results {
|
||||
b.WriteString("-\n")
|
||||
fmt.Fprintf(&b, " rank: %d\n", r.Rank)
|
||||
fmt.Fprintf(&b, " title: %s\n", yamlScalar(r.Title))
|
||||
fmt.Fprintf(&b, " url: %s\n", yamlScalar(r.URL))
|
||||
fmt.Fprintf(&b, " snippet: %s\n", yamlScalar(r.Snippet))
|
||||
fmt.Fprintf(&b, " engine: %s\n", yamlScalar(r.Engine))
|
||||
}
|
||||
}
|
||||
if len(o.Unresponsive) > 0 {
|
||||
b.WriteString("unresponsive:\n")
|
||||
for _, u := range o.Unresponsive {
|
||||
fmt.Fprintf(&b, "- %s\n", yamlScalar(u))
|
||||
}
|
||||
}
|
||||
if o.Note != "" {
|
||||
fmt.Fprintf(&b, "note: %s\n", yamlScalar(o.Note))
|
||||
}
|
||||
if o.Cached {
|
||||
b.WriteString("cached: true\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func yamlScalar(s string) string {
|
||||
if strings.Contains(s, "\n") {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
if s == "" || strings.ContainsAny(s, ":#'\"[]{}&*!|>%@`") || s != strings.TrimSpace(s) {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func loadFixture(t *testing.T, name string) Payload {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller")
|
||||
}
|
||||
path := filepath.Join(filepath.Dir(file), "..", "..", "bin", "tools", "web-search", "fixtures", name)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestClassifyHealthyIsOK(t *testing.T) {
|
||||
if got := Classify(loadFixture(t, "healthy.json")); got != StatusOK {
|
||||
t.Fatalf("classify healthy = %q, want ok", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyEmptyIsThrottledNotEmpty(t *testing.T) {
|
||||
got := Classify(loadFixture(t, "throttled.json"))
|
||||
if got != StatusThrottled {
|
||||
t.Fatalf("classify empty = %q, want throttled", got)
|
||||
}
|
||||
if got == "empty" || got == "no_results" {
|
||||
t.Fatal("status must never sound like absence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectKeepsContextFields(t *testing.T) {
|
||||
out := Project(loadFixture(t, "healthy.json"), 3, DefaultSnippetChars)
|
||||
if out.Status != StatusOK {
|
||||
t.Fatalf("status = %q", out.Status)
|
||||
}
|
||||
if len(out.Results) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(out.Results))
|
||||
}
|
||||
r := out.Results[0]
|
||||
if r.Rank != 1 || r.Title == "" || r.URL == "" {
|
||||
t.Fatalf("hit = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectTrimsSnippet(t *testing.T) {
|
||||
out := Project(loadFixture(t, "healthy.json"), 5, 40)
|
||||
for _, r := range out.Results {
|
||||
n := utf8.RuneCountInString(r.Snippet)
|
||||
if n > 43 {
|
||||
t.Fatalf("snippet len %d > 43: %q", n, r.Snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectIsCheaperThanRaw(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(fixtureDir(t), "healthy.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := json.Marshal(Project(loadFixture(t, "healthy.json"), 5, DefaultSnippetChars))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out)*3 >= len(raw) {
|
||||
t.Fatalf("projected %d not cheaper than raw %d", len(out), len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottledProjectionCarriesEngineReasons(t *testing.T) {
|
||||
out := Project(loadFixture(t, "throttled.json"), 5, DefaultSnippetChars)
|
||||
if out.Status != StatusThrottled {
|
||||
t.Fatalf("status = %q", out.Status)
|
||||
}
|
||||
if len(out.Results) != 0 {
|
||||
t.Fatalf("results = %v", out.Results)
|
||||
}
|
||||
if len(out.Unresponsive) == 0 {
|
||||
t.Fatal("unresponsive empty")
|
||||
}
|
||||
if !strings.Contains(out.Note, "not evidence that nothing exists") {
|
||||
t.Fatalf("note = %q", out.Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheKeyStable(t *testing.T) {
|
||||
if CacheKey("Pflegegrad", nil) != CacheKey("Pflegegrad", map[string]string{}) {
|
||||
t.Fatal("nil vs empty params")
|
||||
}
|
||||
if CacheKey(" Pflegegrad ", nil) != CacheKey("pflegegrad", nil) {
|
||||
t.Fatal("case/padding")
|
||||
}
|
||||
if CacheKey("x", map[string]string{"lang": "de"}) == CacheKey("x", nil) {
|
||||
t.Fatal("params must change key")
|
||||
}
|
||||
a := CacheKey("x", map[string]string{"a": "1", "b": "2"})
|
||||
b := CacheKey("x", map[string]string{"b": "2", "a": "1"})
|
||||
if a != b {
|
||||
t.Fatal("param order must not change key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPHIGuard(t *testing.T) {
|
||||
if PHIReason("Pflegegrad SGB XI Einstufung") != "" {
|
||||
t.Fatal("technical query refused")
|
||||
}
|
||||
if PHIReason("site:example.com technical query") != "" {
|
||||
t.Fatal("site query refused")
|
||||
}
|
||||
if PHIReason("SGB XI Paragraph 45b") != "" {
|
||||
t.Fatal("short numbers refused")
|
||||
}
|
||||
if PHIReason("Kunde 4711220385 Adresse") == "" {
|
||||
t.Fatal("long digit run allowed")
|
||||
}
|
||||
if PHIReason("KV-Nr A123456789") == "" {
|
||||
t.Fatal("KV-Nr allowed")
|
||||
}
|
||||
if PHIReason("Hauptstraße 14 Berlin") == "" {
|
||||
t.Fatal("street allowed")
|
||||
}
|
||||
if PHIReason("Lindenstr. 7") == "" {
|
||||
t.Fatal("str. allowed")
|
||||
}
|
||||
if PHIReason("Personalnummer 12") == "" {
|
||||
t.Fatal("Personalnummer allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFor(t *testing.T) {
|
||||
last := 100.0
|
||||
if got := WaitFor(&last, 104.0, 10); got != 6 {
|
||||
t.Fatalf("wait = %v, want 6", got)
|
||||
}
|
||||
if got := WaitFor(&last, 130.0, 10); got != 0 {
|
||||
t.Fatalf("wait = %v, want 0", got)
|
||||
}
|
||||
if got := WaitFor(nil, 130.0, 10); got != 0 {
|
||||
t.Fatalf("first call wait = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCacheRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c, err := OpenCache(filepath.Join(dir, "web-search.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
p := loadFixture(t, "healthy.json")
|
||||
key := CacheKey("pflegegrad", nil)
|
||||
if got, err := c.Get(key, CacheTTL, 1_000); err != nil || got != nil {
|
||||
t.Fatalf("empty get = %v %v", got, err)
|
||||
}
|
||||
if err := c.Put(key, p, 1_000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := c.Get(key, CacheTTL, 1_001)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("get = %v %v", got, err)
|
||||
}
|
||||
if Classify(*got) != StatusOK {
|
||||
t.Fatalf("cached classify = %s", Classify(*got))
|
||||
}
|
||||
expired, err := c.Get(key, 10, 2_000)
|
||||
if err != nil || expired != nil {
|
||||
t.Fatalf("expired = %v %v", expired, err)
|
||||
}
|
||||
if v, err := c.LastCall(); err != nil || v != nil {
|
||||
t.Fatalf("last = %v %v", v, err)
|
||||
}
|
||||
if err := c.MarkCall(50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := c.LastCall()
|
||||
if err != nil || v == nil || *v != 50 {
|
||||
t.Fatalf("last after mark = %v %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "bin", "tools", "web-search", "fixtures")
|
||||
}
|
||||
Reference in New Issue
Block a user