diff --git a/.gitignore b/.gitignore index 22d2d00..5f549c3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ src/config/config.go .vscode vendor/ dbclones/ +coverage.out diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 0652494..b7c778d 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -35,6 +35,14 @@ func TestProjectIndex(t *testing.T) { AssertRegexMatch(t, BuildProjectIndex(), RegexProjectIndex, nil) } +func TestShowcase(t *testing.T) { + AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil) +} + +func TestStreams(t *testing.T) { + AssertRegexMatch(t, BuildStreams(), RegexStreams, nil) +} + func TestSiteMap(t *testing.T) { AssertRegexMatch(t, BuildSiteMap(), RegexSiteMap, nil) } @@ -79,6 +87,13 @@ func TestFeed(t *testing.T) { assert.Panics(t, func() { BuildFeedWithPage(0) }) } +func TestPodcast(t *testing.T) { + AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil) + AssertSubdomain(t, BuildPodcast(""), "") + AssertSubdomain(t, BuildPodcast("hmn"), "") + AssertSubdomain(t, BuildPodcast("hero"), "hero") +} + func TestForumCategory(t *testing.T) { AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil) AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"}) @@ -329,6 +344,8 @@ func TestMarkRead(t *testing.T) { } func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { + t.Helper() + parsed, err := url.Parse(fullUrl) ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) if !ok { @@ -344,6 +361,8 @@ func AssertSubdomain(t *testing.T, fullUrl string, expectedSubdomain string) { } func AssertRegexMatch(t *testing.T, fullUrl string, regex *regexp.Regexp, paramsToVerify map[string]string) { + t.Helper() + parsed, err := url.Parse(fullUrl) ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) if !ok { @@ -378,6 +397,8 @@ func AssertRegexMatch(t *testing.T, fullUrl string, regex *regexp.Regexp, params } func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) { + t.Helper() + parsed, err := url.Parse(fullUrl) ok := assert.Nilf(t, err, "Full url could not be parsed: %s", fullUrl) if !ok { diff --git a/src/hmnurl/test/coverage_test.go b/src/hmnurl/test/coverage_test.go new file mode 100644 index 0000000..dc6069d --- /dev/null +++ b/src/hmnurl/test/coverage_test.go @@ -0,0 +1,84 @@ +package test + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + + "git.handmade.network/hmn/hmn/src/ansicolor" + "github.com/stretchr/testify/assert" +) + +/* +We have these tests in a separate package so that we can run the hmnurl package tests without +recursively invoking ourselves. +*/ + +// Test that all hmnurl functions starting with Build are covered by tests. +func TestRouteCoverage(t *testing.T) { + tmp := t.TempDir() + covFilePath := filepath.Join(tmp, "coverage.out") + + outputAndAssert(t, exec.Command("go", "test", "./..", "-coverprofile="+covFilePath), "failed to run hmnurl tests") + coverageOutput := outputAndAssert(t, exec.Command("go", "tool", "cover", "-func="+covFilePath), "failed to run coverage tool") + + coverLineRe := regexp.MustCompile("(?P\\w+)\\t+(?P[\\d.]+)%$") + var uncoveredBuildFuncs []string + for i, line := range bytes.Split(coverageOutput, []byte("\n")) { + line := string(line) + if line == "" || strings.HasPrefix(line, "total") { + continue + } + + matches := coverLineRe.FindStringSubmatch(line) + if matches == nil { + panic(fmt.Sprintf("line %d of coverage data could not be parsed (\"%s\")", i+1, line)) + } + + funcName := matches[coverLineRe.SubexpIndex("name")] + coverPercentStr := matches[coverLineRe.SubexpIndex("percent")] + + if strings.HasPrefix(funcName, "Build") { + coverPercent, err := strconv.ParseFloat(coverPercentStr, 64) + if err != nil { + panic(err) + } + + if coverPercent == 0 { + uncoveredBuildFuncs = append(uncoveredBuildFuncs, funcName) + } + } + } + + if len(uncoveredBuildFuncs) > 0 { + t.Logf("The following url Build functions were not covered by tests:\n") + for _, funcName := range uncoveredBuildFuncs { + t.Logf("%s\n", funcName) + } + t.FailNow() + } +} + +func outputAndAssert(t *testing.T, cmd *exec.Cmd, args ...interface{}) []byte { + t.Helper() + + var stdout bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &stdout) + cmd.Stderr = os.Stderr + + fmt.Println(ansicolor.Gray + ansicolor.Italic + cmd.String() + ansicolor.Reset) + + fmt.Print(ansicolor.Gray) + err := cmd.Run() + fmt.Print(ansicolor.Reset) + assert.Nil(t, err, args...) + + return stdout.Bytes() +} diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 7786a7a..869b644 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -9,6 +9,11 @@ import ( "git.handmade.network/hmn/hmn/src/oops" ) +/* +Any function in this package whose name starts with Build is required to be covered by a test. +This helps ensure that we don't generate URLs that can't be routed. +*/ + // TODO(asaf): Make this whole file only crash in Dev var RegexHomepage = regexp.MustCompile("^/$") @@ -51,6 +56,8 @@ func BuildAtomFeed() string { return Url("/atom", nil) } +// QUESTION(ben): Can we change these routes? + var RegexLoginAction = regexp.MustCompile("^/login$") func BuildLoginAction(redirectTo string) string { @@ -163,7 +170,7 @@ func BuildPodcast(projectSlug string) string { */ // TODO(asaf): This also matches urls generated by BuildForumThread (/t/ is identified as a cat, and the threadid as a page) -// This shouldn't be a problem since we will match Thread before Category in the router, but should be enforce it here? +// This shouldn't be a problem since we will match Thread before Category in the router, but should we enforce it here? var RegexForumCategory = regexp.MustCompile(`^/forums(/(?P[^\d/]+(/[^\d]+)*))?(/(?P\d+))?$`) func BuildForumCategory(projectSlug string, subforums []string, page int) string {