Add options type to Config service and improve type assignment error handling; add unit tests for Config functionality

This commit is contained in:
Snider 2025-10-28 11:09:38 +00:00
parent 536ce7b6cd
commit bb94bdf061
4 changed files with 191 additions and 1 deletions

View file

@ -13,6 +13,9 @@ import (
// to the underlying implementation, making it transparent to the user.
type Service = impl.Service
// Options is the public type for the Config service options.
type Options = impl.Options
// New is the public constructor for the Config service. It is a variable
// that points to the real constructor in the implementation package.
var New = impl.New

62
config/config_test.go Normal file
View file

@ -0,0 +1,62 @@
package config_test
import (
"testing"
"github.com/Snider/Core/config"
"github.com/Snider/Core/pkg/core"
)
func TestInterfaceCompliance(t *testing.T) {
var _ config.Config = (*config.Service)(nil)
}
func TestRegister(t *testing.T) {
if config.Register == nil {
t.Fatal("config.Register factory is nil")
}
}
// TestGet_NonExistentKey validates that getting a non-existent key returns an error.
func TestGet_NonExistentKey(t *testing.T) {
coreImpl, err := core.New(core.WithService(config.Register))
if err != nil {
t.Fatalf("core.New() failed: %v", err)
}
var value string
err = coreImpl.Config().Get("nonexistent.key", &value)
if err == nil {
t.Fatal("expected an error when getting a nonexistent key, but got nil")
}
}
// TestSetAndGet verifies that a value can be set and then retrieved correctly.
func TestSetAndGet(t *testing.T) {
coreImpl, err := core.New(core.WithService(config.Register))
if err != nil {
t.Fatalf("core.New() failed: %v", err)
}
cfg := coreImpl.Config()
// 1. Set a value for an existing key
key := "language"
expectedValue := "fr"
err = cfg.Set(key, expectedValue)
if err != nil {
t.Fatalf("Set(%q, %q) failed: %v", key, expectedValue, err)
}
// 2. Get the value back
var actualValue string
err = cfg.Get(key, &actualValue)
if err != nil {
t.Fatalf("Get(%q) failed: %v", key, err)
}
// 3. Compare the values
if actualValue != expectedValue {
t.Errorf("Get(%q) returned %q, want %q", key, actualValue, expectedValue)
}
}

125
core_test.go Normal file
View file

@ -0,0 +1,125 @@
package core_test
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// TestPublicAPICompleteness dynamically discovers all public services and ensures
// their top-level API packages are in sync with their internal implementations.
func TestPublicAPICompleteness(t *testing.T) {
pkgDir := "pkg"
// 1. Discover all potential service packages in the pkg/ directory.
internalDirs, err := os.ReadDir(pkgDir)
if err != nil {
t.Fatalf("Failed to read pkg directory: %v", err)
}
var allMissingSymbols []string
for _, dir := range internalDirs {
if !dir.IsDir() || dir.Name() == "core" {
continue // Skip files and the core package itself
}
serviceName := dir.Name()
topLevelDir := serviceName
// 2. Check if a corresponding top-level public API directory exists.
if _, err := os.Stat(topLevelDir); os.IsNotExist(err) {
continue // Not a public service, so we skip it.
}
// 3. Define paths for public and internal Go files.
publicFile := filepath.Join(topLevelDir, serviceName+".go")
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
// Ensure both files exist before trying to parse them.
if _, err := os.Stat(publicFile); os.IsNotExist(err) {
t.Logf("Skipping service '%s': public API file not found at %s", serviceName, publicFile)
continue
}
if _, err := os.Stat(internalFile); os.IsNotExist(err) {
t.Logf("Skipping service '%s': internal implementation file not found at %s", serviceName, internalFile)
continue
}
// 4. Compare the exported symbols.
missing, err := compareExports(publicFile, internalFile)
if err != nil {
t.Errorf("Error comparing exports for service '%s': %v", serviceName, err)
continue
}
if len(missing) > 0 {
msg := "- Service: " + serviceName + "\n - Missing: " + strings.Join(missing, ", ")
allMissingSymbols = append(allMissingSymbols, msg)
}
}
// 5. Report all discrepancies at the end.
if len(allMissingSymbols) > 0 {
t.Errorf("Public APIs are out of sync with internal implementations:\n\n%s",
strings.Join(allMissingSymbols, "\n"))
}
}
// compareExports takes two file paths, parses them, and returns a list of
// symbols that are exported in the internal file but not the public one.
func compareExports(publicFile, internalFile string) ([]string, error) {
publicAPI, err := getExportedSymbols(publicFile)
if err != nil {
return nil, err
}
internalImpl, err := getExportedSymbols(internalFile)
if err != nil {
return nil, err
}
publicSymbols := make(map[string]bool)
for _, sym := range publicAPI {
publicSymbols[sym] = true
}
var missingSymbols []string
for _, internalSym := range internalImpl {
// The public API re-exports the interface from core, so we don't expect it here.
if internalSym == "Config" {
continue
}
if !publicSymbols[internalSym] {
missingSymbols = append(missingSymbols, internalSym)
}
}
return missingSymbols, nil
}
// getExportedSymbols parses a Go file and returns a slice of its exported symbol names.
func getExportedSymbols(path string) ([]string, error) {
absPath, err := filepath.Abs(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, absPath, nil, parser.AllErrors)
if err != nil {
return nil, err
}
var symbols []string
for name, obj := range node.Scope.Objects {
if token.IsExported(name) {
symbols = append(symbols, obj.Name)
}
}
return symbols, nil
}

View file

@ -147,7 +147,7 @@ func (s *Service) Get(key string, out any) error {
targetVal := outVal.Elem()
srcVal := val.Field(i)
if !targetVal.Type().AssignableTo(srcVal.Type()) {
if !srcVal.Type().AssignableTo(targetVal.Type()) {
return fmt.Errorf("cannot assign config value of type %s to output of type %s", srcVal.Type(), targetVal.Type())
}
targetVal.Set(srcVal)