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  }