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) } }