289 lines
9.4 KiB
Go
289 lines
9.4 KiB
Go
package modules_test
|
|
|
|
import (
|
|
"fmt"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const moduleImportPrefix = "gitea.maximumdirect.net/eric/notarius/internal/modules/"
|
|
|
|
func TestProductionImportBoundaries(t *testing.T) {
|
|
repositoryRoot := testRepositoryRoot(t)
|
|
err := filepath.WalkDir(repositoryRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
if entry.Name() == ".git" || entry.Name() == "testdata" || entry.Name() == "vendor" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if filepath.Ext(path) != ".go" {
|
|
return nil
|
|
}
|
|
return checkImportBoundaries(repositoryRoot, path)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestImportBoundaryFixtureIsRejected(t *testing.T) {
|
|
repositoryRoot := testRepositoryRoot(t)
|
|
fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go")
|
|
err := checkImportBoundaries(repositoryRoot, fixture)
|
|
if err == nil {
|
|
t.Fatal("fixture import was accepted, want generic-to-concrete violation")
|
|
}
|
|
if !strings.Contains(err.Error(), "generic family must not import concrete family") {
|
|
t.Fatalf("fixture error = %q, want generic-to-concrete violation", err)
|
|
}
|
|
}
|
|
|
|
func TestImportBoundaryRules(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
sourcePackage string
|
|
importPath string
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "concrete implementation cannot import peer concrete family",
|
|
filename: "internal/modules/dnd/extract/example/extractor.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "seriatim/input/transcript",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "future concrete family cannot import current concrete family",
|
|
filename: "internal/modules/almanac/extract/example/extractor.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "dnd/shared",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "current concrete family cannot import future concrete family",
|
|
filename: "internal/modules/seriatim/input/example/adapter.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "almanac/shared",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "generic implementation cannot import current concrete family",
|
|
filename: "internal/modules/generic/merge/example/merger.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "dnd/shared",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "generic implementation cannot import future concrete family",
|
|
filename: "internal/modules/generic/merge/example/merger.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "almanac/shared",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "concrete implementation cannot import generic implementation",
|
|
filename: "internal/modules/dnd/extract/example/extractor.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "generic/normalize/noop",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "concrete white-box test follows production rules",
|
|
filename: "internal/modules/dnd/extract/example/extractor_test.go",
|
|
sourcePackage: "example",
|
|
importPath: moduleImportPrefix + "generic/normalize/noop",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "concrete registrar may compose generic implementation",
|
|
filename: "internal/modules/almanac/register/register.go",
|
|
sourcePackage: "register",
|
|
importPath: moduleImportPrefix + "generic/normalize/noop",
|
|
},
|
|
{
|
|
name: "family root cannot import child implementation",
|
|
filename: "internal/modules/almanac/types.go",
|
|
sourcePackage: "almanac",
|
|
importPath: moduleImportPrefix + "almanac/extract/events",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "family root cannot import registrar",
|
|
filename: "internal/modules/almanac/types.go",
|
|
sourcePackage: "almanac",
|
|
importPath: moduleImportPrefix + "almanac/register",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "child may import family root",
|
|
filename: "internal/modules/almanac/extract/events/extractor.go",
|
|
sourcePackage: "events",
|
|
importPath: moduleImportPrefix + "almanac",
|
|
},
|
|
{
|
|
name: "child may import same-family sibling",
|
|
filename: "internal/modules/almanac/extract/events/extractor.go",
|
|
sourcePackage: "events",
|
|
importPath: moduleImportPrefix + "almanac/shared",
|
|
},
|
|
{
|
|
name: "concrete registrar may compose own child",
|
|
filename: "internal/modules/almanac/register/register.go",
|
|
sourcePackage: "register",
|
|
importPath: moduleImportPrefix + "almanac/extract/events",
|
|
},
|
|
{
|
|
name: "generic registrar may compose generic child",
|
|
filename: "internal/modules/generic/register/register.go",
|
|
sourcePackage: "register",
|
|
importPath: moduleImportPrefix + "generic/output/json",
|
|
},
|
|
{
|
|
name: "application composition root may compose registrars",
|
|
filename: "internal/cli/catalog.go",
|
|
sourcePackage: "cli",
|
|
importPath: moduleImportPrefix + "almanac/register",
|
|
},
|
|
{
|
|
name: "black-box integration test may compose families",
|
|
filename: "internal/modules/integration/example_test.go",
|
|
sourcePackage: "integration_test",
|
|
importPath: moduleImportPrefix + "almanac/extract/events",
|
|
},
|
|
{
|
|
name: "white-box integration test is not exempt",
|
|
filename: "internal/modules/integration/example_test.go",
|
|
sourcePackage: "integration",
|
|
importPath: moduleImportPrefix + "almanac/extract/events",
|
|
wantError: true,
|
|
},
|
|
{
|
|
name: "integration production file is not exempt",
|
|
filename: "internal/modules/integration/example.go",
|
|
sourcePackage: "integration",
|
|
importPath: moduleImportPrefix + "almanac/extract/events",
|
|
wantError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateImport(tt.filename, tt.sourcePackage, tt.importPath)
|
|
if tt.wantError && err == nil {
|
|
t.Fatal("validateImport() error = nil, want boundary violation")
|
|
}
|
|
if !tt.wantError && err != nil {
|
|
t.Fatalf("validateImport() error = %v, want nil", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func checkImportBoundaries(repositoryRoot string, filename string) error {
|
|
parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.ImportsOnly)
|
|
if err != nil {
|
|
return fmt.Errorf("parse %s: %w", filename, err)
|
|
}
|
|
relative, err := filepath.Rel(repositoryRoot, filename)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve relative path for %s: %w", filename, err)
|
|
}
|
|
relative = filepath.ToSlash(relative)
|
|
for _, imported := range parsed.Imports {
|
|
importPath, err := strconv.Unquote(imported.Path.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("parse import in %s: %w", relative, err)
|
|
}
|
|
if err := validateImport(relative, parsed.Name.Name, importPath); err != nil {
|
|
return fmt.Errorf("%s imports %s: %w", relative, importPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateImport(filename string, sourcePackage string, importPath string) error {
|
|
if isBlackBoxIntegrationTest(filename, sourcePackage) {
|
|
return nil
|
|
}
|
|
targetFamily, targetChild := moduleFamilyForImport(importPath)
|
|
if targetFamily == "" {
|
|
return nil
|
|
}
|
|
if isIntegrationFile(filename) {
|
|
return fmt.Errorf("module integration composition is allowed only in black-box tests")
|
|
}
|
|
sourceFamily, sourceRoot, sourceRegistrar := moduleFamilyForFile(filename)
|
|
if sourceFamily == "" {
|
|
return nil
|
|
}
|
|
if sourceRoot && sourceFamily == targetFamily && targetChild {
|
|
return fmt.Errorf("family root must not import child packages")
|
|
}
|
|
if sourceFamily == targetFamily {
|
|
return nil
|
|
}
|
|
if sourceFamily == "generic" {
|
|
return fmt.Errorf("generic family must not import concrete family %q", targetFamily)
|
|
}
|
|
if targetFamily == "generic" {
|
|
if sourceRegistrar {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("concrete family %q may import generic implementations only from its registrar", sourceFamily)
|
|
}
|
|
return fmt.Errorf("concrete family %q must not import concrete family %q", sourceFamily, targetFamily)
|
|
}
|
|
|
|
func moduleFamilyForFile(filename string) (family string, root bool, registrar bool) {
|
|
const prefix = "internal/modules/"
|
|
if !strings.HasPrefix(filename, prefix) {
|
|
return "", false, false
|
|
}
|
|
remainder := strings.TrimPrefix(filename, prefix)
|
|
parts := strings.Split(remainder, "/")
|
|
if len(parts) < 2 || parts[0] == "integration" {
|
|
return "", false, false
|
|
}
|
|
return parts[0], len(parts) == 2, len(parts) > 2 && parts[1] == "register"
|
|
}
|
|
|
|
func moduleFamilyForImport(importPath string) (family string, child bool) {
|
|
if !strings.HasPrefix(importPath, moduleImportPrefix) {
|
|
return "", false
|
|
}
|
|
remainder := strings.TrimPrefix(importPath, moduleImportPrefix)
|
|
parts := strings.Split(remainder, "/")
|
|
if len(parts) == 0 || parts[0] == "" || parts[0] == "integration" {
|
|
return "", false
|
|
}
|
|
return parts[0], len(parts) > 1
|
|
}
|
|
|
|
func isIntegrationFile(filename string) bool {
|
|
return strings.HasPrefix(filename, "internal/modules/integration/")
|
|
}
|
|
|
|
func isBlackBoxIntegrationTest(filename string, sourcePackage string) bool {
|
|
return isIntegrationFile(filename) && strings.HasSuffix(filename, "_test.go") && sourcePackage == "integration_test"
|
|
}
|
|
|
|
func testRepositoryRoot(t *testing.T) string {
|
|
t.Helper()
|
|
_, filename, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("resolve import-boundary test location")
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
|
}
|