package memoriatest import ( "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" "testing" ) // Every plain-text error answer left in the app is here, by file and // function, with why it is not a page (shared chrome §3.3). A new // http.Error or http.NotFound anywhere else fails this gate: it is either // a page (errorPage) or it is classified here with its reason. // // Keyed by function, not line, so an edit above a site does not churn the // list; the count per function catches a second site added beside one. var plainTextErrors = map[string]struct { n int why string }{ "app.go:requireSession": {1, "a boot misconfiguration (no sign-in wired): every request is refused, and there is no viewer to draw chrome for"}, "auth.go:beginGuard": {1, "the same misconfiguration on the sign-in POST (no owners list)"}, "errors.go:plainError": {1, "the plain answer itself, for a request that did not ask for a page"}, "errors.go:methodNotAllowed": {1, "405 to a method no route takes, with Allow set: never a navigation (a GET of a POST-only path gets the 404 page)"}, "handlers.go:writeOutcome": {1, "the upload tray's JSON path: a bare non-200 is what the tray reads as \"no answer\" (spec §3.3 keeps it)"}, "native.go:nativeSearch": {4, "the native client's JSON API; it shows these strings itself"}, "native.go:nativeNote": {3, "the native client's JSON API; it shows these strings itself"}, "render.go:renderStatus": {1, "a page template failed to execute: the error page is built from the same layout, so the plainest answer is the one left"}, "render.go:renderFragment": {1, "search.js's results fragment: a fetch, not a navigation, and search.js reads a non-200 as a search that did not run"}, } func TestEveryPlainTextErrorIsClassified(t *testing.T) { dir := filepath.Join("..", "memoria") entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } found := map[string]int{} seen := 0 for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } fset := token.NewFileSet() file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution) if err != nil { t.Fatal(err) } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Body == nil { continue } ast.Inspect(fn.Body, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } sel, ok := call.Fun.(*ast.SelectorExpr) if !ok { return true } pkg, ok := sel.X.(*ast.Ident) if !ok || pkg.Name != "http" || (sel.Sel.Name != "Error" && sel.Sel.Name != "NotFound") { return true } seen++ found[name+":"+fn.Name.Name]++ return true }) } } if seen == 0 { t.Fatal("found no http.Error at all: the walk is broken, not the app clean") } var problems []string for key, n := range found { want, ok := plainTextErrors[key] switch { case !ok: problems = append(problems, fmt.Sprintf("%s: %d unclassified plain-text error(s) — render errorPage, or classify it here with its reason", key, n)) case want.n != n: problems = append(problems, fmt.Sprintf("%s: %d plain-text error(s), classified %d", key, n, want.n)) case want.why == "": problems = append(problems, key+": classified with no reason") } } for key := range plainTextErrors { if found[key] == 0 { problems = append(problems, key+": classified, but no longer there — remove it from the list") } } sort.Strings(problems) for _, p := range problems { t.Error(p) } }