github.com/tilotech/tilores-cli@v0.28.0/cmd/rulestest.go (about) 1 package cmd 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "os" 7 "path/filepath" 8 "strings" 9 "sync" 10 11 "github.com/spf13/cobra" 12 ) 13 14 // rulesTestCmd represents rules test command 15 var rulesTestCmd = &cobra.Command{ 16 Use: "test", 17 Short: "Tests the rules in rule-config.json and checks if results match the expectations.", 18 Long: `Tests the rules in rule-config.json and checks if results match the expectations. 19 Reads the rule configuration from "./rule-config.json" file. 20 Test cases are read from "./test/rules/*.json" where each file represents a test case and 21 includes a pair of records and an expectation. 22 Each expectation is a list of rule sets with their expected satisfied and unsatisfied rules. 23 24 Usage example: 25 tilores-cli rules test 26 Where ./test/rules/case1.json contains the following: 27 { 28 "recordA": { 29 "myCustomField": "same value" 30 }, 31 "recordB": { 32 "myCustomField": "same value" 33 }, 34 "expectation": { 35 "searchRuleSets": [ 36 { 37 "name": "default", 38 "ruleSet": { 39 "ruleSetID": "common", 40 "satisfiedRules": [ 41 "R1EXACT" 42 ], 43 "unsatisfiedRules": [] 44 } 45 } 46 ], 47 "mutationRuleSetGroups": [ 48 { 49 "name": "default", 50 "linkRuleSet": { 51 "ruleSetID": "common", 52 "satisfiedRules": [ 53 "R1EXACT" 54 ], 55 "unsatisfiedRules": [] 56 }, 57 "deduplicateRuleSet": {} 58 } 59 ] 60 } 61 } 62 `, 63 Run: func(_ *cobra.Command, _ []string) { 64 err := testRules() 65 cobra.CheckErr(err) 66 }, 67 } 68 69 func init() { 70 rulesCmd.AddCommand(rulesTestCmd) 71 } 72 73 func testRules() error { 74 caseFiles, err := filepath.Glob("./test/rules/*.json") 75 if err != nil { 76 return err 77 } 78 if len(caseFiles) == 0 { 79 fmt.Println("no test case files found in \"./test/rules/*.json\"") 80 return nil 81 } 82 ruleConfigFile, err := os.Open("./rule-config.json") 83 if err != nil { 84 return fmt.Errorf("unable to open rule-config.json: %v", err) 85 } 86 var ruleConfig map[string]interface{} 87 err = json.NewDecoder(ruleConfigFile).Decode(&ruleConfig) 88 if err != nil { 89 return err 90 } 91 92 wg := &sync.WaitGroup{} 93 errCh := make(chan error) 94 for _, caseFile := range caseFiles { 95 wg.Add(1) 96 go runCase(caseFile, ruleConfig, wg, errCh) 97 } 98 go func() { 99 wg.Wait() 100 close(errCh) 101 }() 102 for err = range errCh { 103 fmt.Println(err) 104 } 105 if err != nil { 106 return fmt.Errorf("%vsome test cases did not pass%v", colorRed, colorReset) 107 } 108 fmt.Printf("%vall tests passed%v\n", colorGreen, colorReset) 109 return nil 110 } 111 112 type ruleTestCase struct { 113 *RulesSimulateInput 114 Expectation struct { 115 SearchRuleSets []responseSearchRuleSet `json:"searchRuleSets"` 116 MutationRuleSetGroups []responseMutationRuleSetGroup `json:"mutationRuleSetGroups"` 117 } `json:"expectation"` 118 } 119 120 func runCase(caseFile string, ruleConfig map[string]interface{}, wg *sync.WaitGroup, errCh chan error) { 121 defer wg.Done() 122 file, err := os.ReadFile(caseFile) //nolint:gosec 123 if err != nil { 124 errCh <- err 125 return 126 } 127 128 ruleTestCase := &ruleTestCase{} 129 err = json.Unmarshal(file, ruleTestCase) 130 if err != nil { 131 errCh <- err 132 return 133 } 134 ruleTestCase.RuleConfig = ruleConfig 135 actual, err := callTiloTechAPI(ruleTestCase.RulesSimulateInput) 136 if err != nil { 137 errCh <- err 138 return 139 } 140 141 errors := compareSearchRuleSets(ruleTestCase.Expectation.SearchRuleSets, actual.TiloRes.SimulateRules.SearchRuleSets) 142 mutationRuleErrors := compareMutationRuleSetGroups(ruleTestCase.Expectation.MutationRuleSetGroups, actual.TiloRes.SimulateRules.MutationRuleSetGroups) 143 errors = append(errors, mutationRuleErrors...) 144 145 if len(errors) != 0 { 146 errCh <- fmt.Errorf("case %v failed, errors:\n%v", caseFile, strings.Join(errors, "\n")) 147 return 148 } 149 } 150 151 func compareSearchRuleSets(expected, actual []responseSearchRuleSet) []string { 152 expectedMap, errors := searchToMap(expected, "invalid expectation") 153 actualMap, actualErrors := searchToMap(actual, "invalid actual") 154 errors = append(errors, actualErrors...) 155 errors = append(errors, compareRuleSets(expectedMap, actualMap)...) 156 return errors 157 } 158 159 func compareMutationRuleSetGroups(expected, actual []responseMutationRuleSetGroup) []string { 160 expectedMap, errors := mutationToMap(expected, "invalid expectation") 161 actualMap, actualErrors := mutationToMap(actual, "invalid actual") 162 errors = append(errors, actualErrors...) 163 errors = append(errors, compareRuleSets(expectedMap, actualMap)...) 164 return errors 165 } 166 167 func compareRuleSets(expectedMap, actualMap map[string]map[string]bool) []string { 168 errors := make([]string, 0) 169 for ruleSetID, expectedRules := range expectedMap { 170 actualRules, ok := actualMap[ruleSetID] 171 if !ok { 172 errors = append(errors, fmt.Sprintf("rule set %v expected but not found", ruleSetID)) 173 continue 174 } 175 for rule, expectedIsSatisfied := range expectedRules { 176 actualIsSatisfied, ok := actualRules[rule] 177 if !ok { 178 errors = append(errors, fmt.Sprintf("%v: rule %v expected but not found", ruleSetID, rule)) 179 continue 180 } 181 if actualIsSatisfied != expectedIsSatisfied { 182 errors = append(errors, fmt.Sprintf("%v: rule %v expected to be %v but was %v", ruleSetID, rule, isSatisfiedString(expectedIsSatisfied), isSatisfiedString(actualIsSatisfied))) 183 continue 184 } 185 } 186 for rule := range actualRules { 187 if _, ok := expectedRules[rule]; !ok { 188 errors = append(errors, fmt.Sprintf("%v: rule %v found but not expected", ruleSetID, rule)) 189 } 190 } 191 } 192 for ruleSetID := range actualMap { 193 if _, ok := expectedMap[ruleSetID]; !ok { 194 errors = append(errors, fmt.Sprintf("rule set %v found but not expected", ruleSetID)) 195 } 196 } 197 198 return errors 199 } 200 201 func isSatisfiedString(isSatisfied bool) string { 202 if isSatisfied { 203 return fmt.Sprintf("%vsatisfied%v", colorGreen, colorReset) 204 } 205 return fmt.Sprintf("%vunsatisfied%v", colorRed, colorReset) 206 } 207 208 func searchToMap(searchRuleSets []responseSearchRuleSet, errorPrefix string) (map[string]map[string]bool, []string) { 209 errors := make([]string, 0) 210 ruleSetMap := map[string]map[string]bool{} 211 for _, ruleSet := range searchRuleSets { 212 identifier := fmt.Sprintf("%s-%s", ruleSet.Name, ruleSet.RuleSet.RuleSetID) 213 _, ok := ruleSetMap[identifier] 214 if ok { 215 errors = append(errors, fmt.Sprintf("%v, rule set %v only allowed once", errorPrefix, identifier)) 216 } 217 m, rulesErrors := mapRules(ruleSet.RuleSet, errorPrefix) 218 errors = append(errors, rulesErrors...) 219 ruleSetMap[identifier] = m 220 } 221 return ruleSetMap, errors 222 } 223 224 func mutationToMap(mutationGroups []responseMutationRuleSetGroup, errorPrefix string) (map[string]map[string]bool, []string) { 225 errors := make([]string, 0) 226 ruleSetMap := map[string]map[string]bool{} 227 for _, group := range mutationGroups { 228 identifier := fmt.Sprintf("%s-link-%s", group.Name, group.LinkRuleSet.RuleSetID) 229 _, ok := ruleSetMap[identifier] 230 if ok { 231 errors = append(errors, fmt.Sprintf("%v, rule set %v only allowed once", errorPrefix, identifier)) 232 } 233 m, rulesErrors := mapRules(group.LinkRuleSet, errorPrefix) 234 errors = append(errors, rulesErrors...) 235 ruleSetMap[identifier] = m 236 237 identifier = fmt.Sprintf("%s-deduplicate-%s", group.Name, group.DeduplicateRuleSet.RuleSetID) 238 _, ok = ruleSetMap[identifier] 239 if ok { 240 errors = append(errors, fmt.Sprintf("%v, rule set %v only allowed once", errorPrefix, identifier)) 241 } 242 m, rulesErrors = mapRules(group.DeduplicateRuleSet, errorPrefix) 243 errors = append(errors, rulesErrors...) 244 ruleSetMap[identifier] = m 245 } 246 return ruleSetMap, errors 247 } 248 249 func mapRules(rs ruleSet, errorPrefix string) (map[string]bool, []string) { 250 errors := make([]string, 0) 251 m := map[string]bool{} 252 for _, satisfiedRule := range rs.SatisfiedRules { 253 _, ok := m[satisfiedRule] 254 if ok { 255 errors = append(errors, fmt.Sprintf("%v, rule %v only allowed once", errorPrefix, satisfiedRule)) 256 } 257 m[satisfiedRule] = true 258 } 259 for _, unsatisfiedRule := range rs.UnsatisfiedRules { 260 _, ok := m[unsatisfiedRule] 261 if ok { 262 errors = append(errors, fmt.Sprintf("%v, rule %v only allowed once", errorPrefix, unsatisfiedRule)) 263 } 264 m[unsatisfiedRule] = false 265 } 266 return m, errors 267 }