sources.skip_rules_file names a JSON file of filters, each with a
comment, an active flag, a logic mode ("or" by default, or "and") and
rules by buyer, title words or CPV codes. The fetcher applies them
before saving, so only new opportunities are marked; the marks of
stored ones are never touched. CPV rules match when the opportunity's
codes are all in the list, or any of them with exclusive false, and
by prefix with exact false.
The file is read again when its time or size changes: an invalid edit
is logged and the last good rules kept, and a deleted file means no
rules until it's back. fetcher service and serve refuse to start if it
is define but missing or invalid.
job-search skip-rules check validates a rules file, and skip-rules
apply marks the stored opportunities that match Skipped, or only lists
them with --dry-run.
120 lines
3.4 KiB
Go
120 lines
3.4 KiB
Go
package skiprules
|
|
|
|
import (
|
|
"bytes"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
buyerA = `[{"comment":"a","active":true,"rules":[{"rule_type":"buyer","values":["A"]}]}]`
|
|
buyerB = `[{"comment":"b","active":true,"rules":[{"rule_type":"buyer","values":["B"]}]}]`
|
|
)
|
|
|
|
// write writes contents to path with a modification time of when, so each
|
|
// write is seen as a change however fast the test runs.
|
|
func write(t *testing.T, path, contents string, when time.Time) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(path, when, when); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// comment returns the comment of the filter rules match o with, or "".
|
|
func comment(rules *Rules, buyer string) string {
|
|
matched, _ := rules.Match(opportunity(buyer, "x"))
|
|
return matched
|
|
}
|
|
|
|
func TestOpenRefuses(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := Open(filepath.Join(dir, "missing.json")); err == nil {
|
|
t.Error("a missing file was accepted")
|
|
}
|
|
|
|
path := filepath.Join(dir, "bad.json")
|
|
write(t, path, `[{}]`, time.Now())
|
|
_, err := Open(path)
|
|
if err == nil || !strings.Contains(err.Error(), "bad.json: filter 1") {
|
|
t.Errorf("err = %v; want one naming the file and filter", err)
|
|
}
|
|
}
|
|
|
|
// Rules reads the file again when it changes, keeps the last good rules when
|
|
// it is invalid, and has none while it is missing, logging each problem once.
|
|
func TestFileReloads(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "rules.json")
|
|
start := time.Now().Add(-time.Hour)
|
|
write(t, path, buyerA, start)
|
|
|
|
var logged bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&logged, nil))
|
|
file, err := Open(path, WithLogger(logger))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := comment(file.Rules(), "A"); got != "a" {
|
|
t.Fatalf("first rules: %q; want a", got)
|
|
}
|
|
|
|
write(t, path, buyerB, start.Add(time.Minute))
|
|
if got := comment(file.Rules(), "B"); got != "b" {
|
|
t.Errorf("after an edit: %q; want b", got)
|
|
}
|
|
if !strings.Contains(logged.String(), "skip rules reloaded") {
|
|
t.Errorf("reload not logged:\n%s", logged.String())
|
|
}
|
|
|
|
// Invalid: the last good rules are kept, and the error logged once.
|
|
logged.Reset()
|
|
write(t, path, `[{]`, start.Add(2*time.Minute))
|
|
file.Rules()
|
|
if got := comment(file.Rules(), "B"); got != "b" {
|
|
t.Errorf("after a bad edit: %q; want b kept", got)
|
|
}
|
|
if strings.Count(logged.String(), "skip rules file invalid") != 1 {
|
|
t.Errorf("want the bad file logged once:\n%s", logged.String())
|
|
}
|
|
|
|
// Missing: no rules, and a warning logged once.
|
|
logged.Reset()
|
|
os.Remove(path)
|
|
file.Rules()
|
|
if rules := file.Rules(); rules != nil {
|
|
t.Errorf("missing file: rules %v; want nil", rules)
|
|
}
|
|
if strings.Count(logged.String(), "skip rules file missing") != 1 {
|
|
t.Errorf("want the missing file logged once:\n%s", logged.String())
|
|
}
|
|
|
|
// Back again, even with an old modification time.
|
|
write(t, path, buyerA, start)
|
|
if got := comment(file.Rules(), "A"); got != "a" {
|
|
t.Errorf("file back: %q; want a", got)
|
|
}
|
|
}
|
|
|
|
// A change that keeps the modification time but changes the size is still
|
|
// seen.
|
|
func TestFileReloadsOnSize(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "rules.json")
|
|
when := time.Now().Add(-time.Hour)
|
|
write(t, path, buyerA, when)
|
|
file, err := Open(path, WithLogger(slog.New(slog.DiscardHandler)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
write(t, path, buyerB+"\n", when)
|
|
if got := comment(file.Rules(), "B"); got != "b" {
|
|
t.Errorf("after a same-time edit: %q; want b", got)
|
|
}
|
|
}
|