github.com/rothskeller/wppsvr@v1.8.9/analyze/analyze_test.go (about)

     1  package analyze
     2  
     3  // This file defines a framework for testing the analysis package.  Every *.yaml
     4  // file in the testdata tree, except config.yaml, describes a test case,
     5  // including configuration and session setup, the message to be analyzed, the
     6  // expected results of the analysis, and the expected response messages that
     7  // should be generated.  The code in this file runs the analysis and tests the
     8  // result.
     9  
    10  import (
    11  	"fmt"
    12  	"io"
    13  	"io/fs"
    14  	"log"
    15  	"os"
    16  	"path/filepath"
    17  	"regexp"
    18  	"strings"
    19  	"testing"
    20  	"time"
    21  
    22  	"github.com/davecgh/go-spew/spew"
    23  	"github.com/go-test/deep"
    24  	"gopkg.in/yaml.v3"
    25  
    26  	"github.com/rothskeller/packet/envelope"
    27  	"github.com/rothskeller/packet/message"
    28  	"github.com/rothskeller/packet/xscmsg"
    29  	"github.com/rothskeller/wppsvr/config"
    30  	"github.com/rothskeller/wppsvr/store"
    31  )
    32  
    33  type testdata struct {
    34  	Now         time.Time        `yaml:"now"`
    35  	Config      *config.Config   `yaml:"config"`
    36  	SeenHash    string           `yaml:"seenHash"`
    37  	Session     *store.Session   `yaml:"session"`
    38  	ToBBS       string           `yaml:"toBBS"`
    39  	Message     string           `yaml:"message"`
    40  	Stored      *store.Message   `yaml:"stored"`
    41  	AnalysisREs []string         `yaml:"analysisREs"`
    42  	Responses   []*responseCheck `yaml:"responses"`
    43  }
    44  type responseCheck struct {
    45  	store.Response `yaml:",inline"`
    46  	BodyREs        []string `yaml:"bodyREs"`
    47  }
    48  
    49  func TestAnalyze(t *testing.T) {
    50  	var testfiles []string
    51  	log.SetOutput(io.Discard)
    52  	TestForceJurisdiction = "SNY"
    53  	xscmsg.Register()
    54  	filepath.WalkDir("testdata", func(path string, info fs.DirEntry, err error) error {
    55  		if strings.HasSuffix(path, ".yaml") && path != "testdata/config.yaml" {
    56  			testfiles = append(testfiles, path)
    57  		}
    58  		return nil
    59  	})
    60  	for _, testfile := range testfiles {
    61  		t.Run(testfile[:len(testfile)-5], func(t *testing.T) {
    62  			testAnalyze(t, testfile)
    63  		})
    64  	}
    65  }
    66  
    67  func testAnalyze(t *testing.T, testfile string) {
    68  	var testdata testdata
    69  
    70  	// First, we need a partial configuration.  We'll read that from
    71  	// testdata/config.yaml, and then allow it to be modified by the test's
    72  	// yaml file.
    73  	os.Chdir("testdata")
    74  	if err := config.Read(); err != nil {
    75  		t.Fatal(err)
    76  	}
    77  	os.Chdir("..")
    78  	testdata.Config = config.Get()
    79  	// We also need a session definition, which again, the test's yaml file
    80  	// can modify.
    81  	testdata.Session = &store.Session{
    82  		ID:           42,
    83  		CallSign:     "PKTTUE",
    84  		Name:         "SVECS Net",
    85  		Prefix:       "TUE",
    86  		Start:        time.Date(2022, 1, 5, 0, 0, 0, 0, time.Local),
    87  		End:          time.Date(2022, 1, 11, 20, 0, 0, 0, time.Local),
    88  		ToBBSes:      []string{"W4XSC"},
    89  		DownBBSes:    []string{"W2XSC"},
    90  		MessageTypes: []string{"plain"},
    91  	}
    92  	// By default, we'll assume that "now" is a moment after the end of the
    93  	// session.
    94  	testdata.Now = time.Date(2022, 1, 11, 20, 0, 1, 0, time.Local)
    95  	now = func() time.Time { return testdata.Now }
    96  	// By default, we'll assume the test message is sent to the correct BBS.
    97  	testdata.ToBBS = "W4XSC"
    98  	// Now, read the test data file, allowing it to override any of the
    99  	// above as well as defining the test message and expected output.
   100  	fh, _ := os.Open(testfile)
   101  	defer fh.Close()
   102  	dec := yaml.NewDecoder(fh)
   103  	dec.KnownFields(true)
   104  	if err := dec.Decode(&testdata); err != nil {
   105  		t.Fatal(err)
   106  	}
   107  	testdata.Config.Validate()
   108  	// If the test data file included a model message, we need to parse it.
   109  	if testdata.Session.ModelMessage != "" {
   110  		env, body, _ := envelope.ParseSaved(testdata.Session.ModelMessage)
   111  		testdata.Session.ModelMsg = message.Decode(env.SubjectLine, body)
   112  	}
   113  	// We'll need a fake store for the analyzer to use.
   114  	store := &fakeStore{seenHash: testdata.SeenHash, nextID: 100}
   115  	// Run the analysis.
   116  	a := Analyze(store, testdata.Session, testdata.ToBBS, testdata.Message)
   117  	responses := a.Responses(store)
   118  	a.Commit(store)
   119  	// First of all, did the analysis store the expected number of analyzed
   120  	// messages (zero or one)?
   121  	if testdata.Stored != nil && len(store.saved) == 0 {
   122  		t.Error("no analysis saved to store")
   123  	}
   124  	if testdata.Stored == nil && len(store.saved) != 0 {
   125  		t.Errorf("unexpected analysis saved to store: %s", spew.Sdump(store.saved[0]))
   126  	}
   127  	if len(store.saved) > 1 {
   128  		t.Error("multiple analyses saved to store")
   129  	}
   130  	// If we both expected and got an analysis, is it correct?
   131  	if testdata.Stored != nil && len(store.saved) != 0 {
   132  		// The raw message in the analysis should be the same as the
   133  		// input message.  We'll assign that here so it doesn't have to
   134  		// be redundantly provided in every test file.
   135  		testdata.Stored.Message = testdata.Message
   136  		// We'll simply assume that the hash is correct, rather than
   137  		// need to compute hashes for each test.
   138  		testdata.Stored.Hash = store.saved[0].Hash
   139  		// Fill in some defaults for the expected analysis.
   140  		if testdata.Stored.LocalID == "" {
   141  			testdata.Stored.LocalID = "TUE-100P"
   142  		}
   143  		if testdata.Stored.Session == 0 {
   144  			testdata.Stored.Session = 42
   145  		}
   146  		if testdata.Stored.ToBBS == "" {
   147  			testdata.Stored.ToBBS = "W4XSC"
   148  		}
   149  		// For the purpose of testing, all scores are either 0, 50, or
   150  		// 100.
   151  		if store.saved[0].Score != 0 && store.saved[0].Score != 100 {
   152  			store.saved[0].Score = 50
   153  		}
   154  		// We don't want to do byte-for-byte compare of the analysis
   155  		// HTML.  Save it, and then copy it so it compares OK.
   156  		testdata.Stored.Analysis = store.saved[0].Analysis
   157  		// With those changes made, the expected and actual analysis
   158  		// should compare identically.
   159  		for _, diff := range deep.Equal(testdata.Stored, store.saved[0]) {
   160  			t.Errorf("analysis mismatch: %s", diff)
   161  		}
   162  		// The test file can specify regular expressions that should be
   163  		// matched by the analysis HTML (an easier way to write the test).  Check
   164  		// those.
   165  		for _, restr := range testdata.AnalysisREs {
   166  			re, err := regexp.Compile(restr)
   167  			if err != nil {
   168  				t.Errorf("invalid RE in test: %s", err)
   169  				return
   170  			}
   171  			if !re.MatchString(testdata.Stored.Analysis) {
   172  				t.Errorf("analysis HTML does not match RE %q: %s", restr, spew.Sdump(testdata.Stored.Analysis))
   173  			}
   174  		}
   175  	}
   176  	for i := 0; i < len(testdata.Responses) || i < len(responses); i++ {
   177  		if i >= len(testdata.Responses) {
   178  			t.Errorf("unexpected response: %s", spew.Sdump(responses[i]))
   179  		} else if i >= len(responses) {
   180  			t.Errorf("missing expected response: %s", spew.Sdump(testdata.Responses[i]))
   181  		} else {
   182  			checkResponse(t, testdata.Responses[i], responses[i])
   183  		}
   184  	}
   185  }
   186  
   187  func checkResponse(t *testing.T, want *responseCheck, have *store.Response) {
   188  	// Fill in a few defaults so the test file doesn't have to specify
   189  	// common stuff.
   190  	if want.ResponseTo == "" {
   191  		want.ResponseTo = "TUE-100P"
   192  	}
   193  	if want.SenderBBS == "" {
   194  		want.SenderBBS = "W4XSC"
   195  	}
   196  	if want.SenderCall == "" {
   197  		want.SenderCall = "PKTTUE"
   198  	}
   199  	// If the test file didn't specify a body for the response, copy over
   200  	// the one we got so that they'll compare identically.
   201  	if want.Body == "" {
   202  		want.Body = have.Body
   203  	}
   204  	// Now compare the responses.
   205  	for _, diff := range deep.Equal(&want.Response, have) {
   206  		t.Errorf("response mismatch: %s", diff)
   207  	}
   208  	// The test file can also specify regular expressions that should be
   209  	// matched by the body (often an easier way to write the test).  Check
   210  	// those.
   211  	for _, restr := range want.BodyREs {
   212  		re, err := regexp.Compile(restr)
   213  		if err != nil {
   214  			t.Errorf("invalid RE in test: %s", err)
   215  			return
   216  		}
   217  		if !re.MatchString(have.Body) {
   218  			t.Errorf("response body does not match RE %q: %s", restr, spew.Sdump(have))
   219  		}
   220  	}
   221  }
   222  
   223  type fakeStore struct {
   224  	seenHash string
   225  	nextID   int
   226  	saved    []*store.Message
   227  }
   228  
   229  func (f *fakeStore) HasMessageHash(hash string) string {
   230  	if f.seenHash == hash {
   231  		return "XXX-000P"
   232  	}
   233  	return ""
   234  }
   235  
   236  func (f *fakeStore) NextMessageID(prefix string) string {
   237  	f.nextID++
   238  	return fmt.Sprintf("%s-%03dP", prefix, f.nextID-1)
   239  }
   240  
   241  func (f *fakeStore) SaveMessage(m *store.Message) {
   242  	f.saved = append(f.saved, m)
   243  }