Files
job-search/internal/skiprules/rules_test.go
T
bwilliams 0543762330 Added skip rules, which mark new opportunities Skipped when they're fetched
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.
2026-10-20 08:55:19 +01:00

301 lines
11 KiB
Go

package skiprules
import (
"encoding/json"
"os"
"strings"
"testing"
"git.avsdev.uk/Go/job-search/internal/types"
)
// mustParse parses text as a rules file, failing the test on error.
func mustParse(t *testing.T, text string) *Rules {
t.Helper()
rules, err := Parse(strings.NewReader(text))
if err != nil {
t.Fatal(err)
}
return rules
}
// withFilter returns a rules file of one filter with fields (and a comment
// "c"); withRule one of an active filter holding rules.
func withFilter(fields string) string {
return `[{"comment":"c",` + fields + `}]`
}
func withRule(rules ...string) string {
return withFilter(`"active":true,"rules":[` + strings.Join(rules, ",") + `]`)
}
// ruleJSON returns a rule of ruleType with values as JSON, and extra fields.
func ruleJSON(ruleType, extra string, values ...string) string {
encoded, _ := json.Marshal(values)
return `{"rule_type":"` + ruleType + `","values":` + string(encoded) +
extra + `}`
}
func buyer(values ...string) string { return ruleJSON("buyer", "", values...) }
func title(values ...string) string { return ruleJSON("title", "", values...) }
func cpv(values ...string) string { return ruleJSON("cpv", "", values...) }
// cpvPrefixes is a CPV rule with exact false, cpvOnly one with exclusive
// true (the default), cpvAny one with exclusive false, and cpvAnyPrefixes
// one with exact and exclusive false.
func cpvPrefixes(values ...string) string {
return ruleJSON("cpv", `,"exact":false`, values...)
}
func cpvOnly(values ...string) string {
return ruleJSON("cpv", `,"exclusive":true`, values...)
}
func cpvAny(values ...string) string {
return ruleJSON("cpv", `,"exclusive":false`, values...)
}
func cpvAnyPrefixes(values ...string) string {
return ruleJSON("cpv", `,"exact":false,"exclusive":false`, values...)
}
// opportunity returns an opportunity with buyer, title and CPV codes.
func opportunity(buyer, title string, cpvs ...string) *types.Opportunity {
o := &types.Opportunity{
UUID: "ocds-1",
BuyerID: buyer,
Title: title,
}
for _, code := range cpvs {
o.Classifications = append(o.Classifications, types.ClassificationCode{
IsCPV: true,
Code: code,
})
}
return o
}
// withCPVs and titled are opportunities with only CPV codes or a title.
func withCPVs(codes ...string) *types.Opportunity {
return opportunity("", "x", codes...)
}
func titled(text string) *types.Opportunity { return opportunity("", text) }
func TestParseRefuses(t *testing.T) {
active := `"active":true,`
tests := []struct {
name, text, want string
}{
{"not JSON", `{`, "reading JSON"},
{"not a list", `{}`, "reading JSON"},
{"null", `null`, "must be a list of filters"},
{"trailing", `[] []`, "unexpected content"},
{"unknown filter field", withFilter(`"filter_type":"x"`), `unknown field "filter_type"`},
{"unknown rule field", withRule(`{"value":"a"}`), `unknown field "value"`},
{"no comment", `[{"active":true,"rules":[` + buyer("a") + `]}]`, "filter 1: skiprules: comm"},
{"no active", withFilter(`"rules":[` + buyer("a") + `]`), `filter 1 ("c"): skiprules: active`},
{"no rules", withFilter(`"active":true`), "rules is required"},
{"empty rules", withRule(), "rules is required"},
{"bad logic", withFilter(active + `"logic_mode":"xor","rules":[` + buyer("a") + `]`), `"xor"`},
{"empty logic", withFilter(active + `"logic_mode":"","rules":[` + buyer("a") + `]`), `""`},
{"no rule type", withRule(`{"values":["a"]}`), "rule 1: skiprules: rule_type is required"},
{"bad rule type", withRule(ruleJSON("status", "", "a")), `rule_type "status"`},
{"no values", withRule(`{"rule_type":"buyer"}`), "values is required"},
{"empty values", withRule(buyer()), "values is required"},
{"exact on title", withRule(ruleJSON("title", `,"exact":true`, "a")), `exact is only for`},
{"exclusive on buyer", withRule(ruleJSON("buyer", `,"exclusive":false`, "a")), "exclusive is"},
{"no words", withRule(title("--")), "values has no words"},
{"short CPV", withRule(cpv("7200000")), `"7200000" is not a CPV code`},
{"letters CPV", withRule(cpv("7200000x")), "is not a CPV code"},
}
for _, tt := range tests {
_, err := Parse(strings.NewReader(tt.text))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("%s: err = %v; want one containing %q", tt.name, err, tt.want)
}
}
}
// An error names the filter and rule by their place in the file.
func TestParseNamesFilterAndRule(t *testing.T) {
_, err := Parse(strings.NewReader(`[
{"comment": "a", "active": true, "rules": [
{"rule_type": "buyer", "values": ["a"]}
]},
{"comment": "b", "active": true, "rules": [
{"rule_type": "buyer", "values": ["a"]},
{"rule_type": "cpv", "values": ["x"]}
]}
]`))
want := `filter 2 ("b"): rule 2: skiprules: "x" is not a CPV code`
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("err = %v; want one containing %q", err, want)
}
}
func TestParseAccepts(t *testing.T) {
rules := mustParse(t, `[
{"comment": "a", "active": true, "rules": [
{"rule_type": "buyer", "values": ["b"]}
]},
{"comment": "", "active": false, "logic_mode": "and", "rules": [
{"rule_type": "cpv", "values": ["72000000-5"], "exact": false},
{"rule_type": "title", "values": ["road"]}
]}
]`)
if filters, active := rules.Filters(); filters != 2 || active != 1 {
t.Errorf("Filters() = %d, %d; want 2, 1", filters, active)
}
if filters, _ := mustParse(t, `[]`).Filters(); filters != 0 {
t.Errorf("empty list: %d filters", filters)
}
var none *Rules
if _, matched := none.Match(opportunity("b", "x")); matched {
t.Error("nil rules matched")
}
}
func TestMatch(t *testing.T) {
tests := []struct {
name string
rule string
o *types.Opportunity
match bool
}{
// buyer: any of the values
{"buyer", buyer("GB-1", "GB-2"), opportunity("GB-2", "x"), true},
{"other buyer", buyer("GB-1"), opportunity("GB-10", "x"), false},
// title: every word, whole words, any case, punctuation splits
{"title", title("road", "REPAIRS"), titled("Road-repairs, phase 2"), true},
{"title phrase", title("road repairs"), titled("Repairs to the road"), true},
{"title word missing", title("road", "bridge"), titled("Road repairs"), false},
{"title part word", title("road"), titled("Railroad works"), false},
{"title digits", title("phase 2"), titled("Road-repairs, phase 2"), true},
// cpv: all the opportunity's CPVs in the values (a subset)
{"cpv subset", cpv("03000000", "72000000", "48000000"), withCPVs("72000000", "48000000"), true},
{"cpv equal", cpv("72000000"), withCPVs("72000000"), true},
{"cpv one outside", cpv("72000000"), withCPVs("72000000", "48000000"), false},
{"cpv none", cpv("72000000"), withCPVs(), false},
{"cpv check digits", cpv("72000000-5"), withCPVs("72000000-1"), true},
{"cpv exact by default", cpv("72000000"), withCPVs("72200000"), false},
{"cpv prefix", cpvPrefixes("72000000"), withCPVs("72212000-4", "72000000"), true},
{"cpv prefix group", cpvPrefixes("72200000"), withCPVs("72300000"), false},
{"cpv prefix division", cpvPrefixes("70000000"), withCPVs("71000000"), false},
{"cpv prefix in division", cpvPrefixes("70000000"), withCPVs("70110000"), true},
{"cpv exclusive", cpvOnly("72000000"), withCPVs("72000000", "48000000"), false},
// cpv, exclusive false: any of the opportunity's CPVs in the values
{"cpv any: one of two", cpvAny("72000000"), withCPVs("48000000", "72000000"), true},
{"cpv any: all", cpvAny("72000000", "48000000"), withCPVs("72000000", "48000000"), true},
{"cpv any: none in values", cpvAny("72000000"), withCPVs("48000000", "03000000"), false},
{"cpv any: no CPVs", cpvAny("72000000"), withCPVs(), false},
{"cpv any: exact by default", cpvAny("72000000"), withCPVs("72200000", "48000000"), false},
{"cpv any: prefix", cpvAnyPrefixes("72000000"), withCPVs("48000000", "72212000-4"), true},
{"cpv any: check digits", cpvAny("72000000-5"), withCPVs("48000000", "72000000-1"), true},
}
for _, tt := range tests {
rules := mustParse(t, withRule(tt.rule))
comment, matched := rules.Match(tt.o)
if matched != tt.match {
t.Errorf("%s: matched %v; want %v", tt.name, matched, tt.match)
}
if matched && comment != "c" {
t.Errorf("%s: comment %q; want c", tt.name, comment)
}
}
}
// Non-CPV classifications are ignored: only CPVs must be covered.
func TestMatchIgnoresOtherSchemes(t *testing.T) {
rules := mustParse(t, withRule(cpv("72000000")))
o := withCPVs("72000000")
o.Classifications = append(o.Classifications, types.ClassificationCode{
Code: "1234",
})
if _, matched := rules.Match(o); !matched {
t.Error("a non-CPV code stopped the match")
}
}
// Buyer X, or CPVs within 03000000 and 04000000; "and" needs both.
func TestMatchLogicModes(t *testing.T) {
rules := buyer("X") + "," + cpv("03000000", "04000000")
or, and := `"logic_mode":"or",`, `"logic_mode":"and",`
tests := []struct {
name, mode string
o *types.Opportunity
match bool
}{
{"default: buyer only", ``, opportunity("X", "t", "72000000"), true},
{"or: cpv only", or, opportunity("Y", "t", "03000000"), true},
{"default: neither", ``, opportunity("Y", "t", "72000000"), false},
{"and: both", and, opportunity("X", "t", "03000000", "04000000"), true},
{"and: buyer only", and, opportunity("X", "t", "72000000"), false},
{"and: cpv only", and, opportunity("Y", "t", "03000000"), false},
}
for _, tt := range tests {
text := withFilter(`"active":true,` + tt.mode + `"rules":[` + rules + `]`)
if _, matched := mustParse(t, text).Match(tt.o); matched != tt.match {
t.Errorf("%s: matched %v; want %v", tt.name, matched, tt.match)
}
}
}
// Inactive filters are ignored; any active filter that matches is enough,
// and the first one's comment is returned.
func TestMatchFilters(t *testing.T) {
rules := mustParse(t, `[
{"comment": "off", "active": false, "rules": [
{"rule_type": "buyer", "values": ["X"]}
]},
{"comment": "title", "active": true, "rules": [
{"rule_type": "title", "values": ["road"]}
]},
{"comment": "buyer", "active": true, "rules": [
{"rule_type": "buyer", "values": ["X"]}
]}
]`)
tests := []struct {
o *types.Opportunity
comment string
match bool
}{
{opportunity("X", "Road works"), "title", true},
{opportunity("X", "Bridge works"), "buyer", true},
{opportunity("Y", "Road works"), "title", true},
{opportunity("Y", "Bridge works"), "", false},
}
for _, tt := range tests {
comment, matched := rules.Match(tt.o)
if comment != tt.comment || matched != tt.match {
t.Errorf(
"%s/%s: %q, %v; want %q, %v",
tt.o.BuyerID, tt.o.Title, comment, matched, tt.comment, tt.match,
)
}
}
}
// The example in README.md is a valid rules file.
func TestREADMEExample(t *testing.T) {
readme, err := os.ReadFile("README.md")
if err != nil {
t.Fatal(err)
}
_, example, found := strings.Cut(string(readme), "```json\n")
example, _, _ = strings.Cut(example, "```")
if !found {
t.Fatal("README.md has no JSON example")
}
filters, active := mustParse(t, example).Filters()
if filters != 4 || active != 3 {
t.Errorf("example: %d filters, %d active; want 4, 3", filters, active)
}
}