Files
spa-server/compliance_test.go
Rene Nochebuena e978d60851 feat(spa-server): initial implementation — SPA/PWA container server (v1.0.0)
Introduces code.nochebuena.dev/einherjar/spa-server — a container-first HTTP
server for single-page applications and progressive web apps. Produces a Docker
base image; downstream SPA Dockerfiles require only a single COPY instruction.

Interfaces and types (CT-6: one TypeSpec per file):
- Config — Port (EINHERJAR_SPA_PORT, default 8080) and StaticDir
  (EINHERJAR_SPA_STATIC_DIR, default /srv/www); parsed via os.Getenv
- Server — implements lifecycle.Component; registered with launcher.New

Implementation:
- NewServer(logger, cfg) *Server — returns a lifecycle-managed HTTP server
- OnInit: builds http.NewServeMux with GET /health and /* routes
- OnStart: binds TCP listener synchronously via net.Listen (port conflicts
  surface immediately), then serves in a background goroutine
- OnStop: graceful shutdown via http.Server.Shutdown with 10s timeout

spa.NewHandler(logger, staticDir) http.Handler:
- Uses http.Dir.Open for path sanitisation (prevents directory traversal)
- File exists and is not a directory → served by http.FileServer
- File missing or is a directory → serves index.html (SPA router owns all routes)
- Directory listing is disabled

health.NewHandler(logger) http.Handler:
- Always responds 200 {"status":"UP","components":{}}
- Wire format matches web/health.Response without importing the web module
- No external checks — process reachability is the only meaningful health signal

Config (EINHERJAR_SPA_* env vars):
  Port(8080), StaticDir(/srv/www)

Docker:
- Multi-stage: golang:1.26-alpine builder → alpine:3.21 runtime
- Image published as:
    code.nochebuena.dev/einherjar/spa-server:v1.0.0
    code.nochebuena.dev/einherjar/spa-server:latest
- VOLUME ["/srv/www"] declared; consumer overrides via COPY or volume mount

No external dependencies beyond contracts, core, and the Go standard library.
2026-06-02 18:49:05 +00:00

108 lines
2.9 KiB
Go

package spaserver
import (
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"code.nochebuena.dev/einherjar/contracts/lifecycle"
"code.nochebuena.dev/einherjar/contracts/logging"
)
// Compile-time interface check (CT-5 / I-8).
var _ lifecycle.Component = (*Server)(nil)
// TestAtMostOneExportedTypePerFile enforces CT-6: at most one exported TypeSpec
// per non-test, non-doc .go file in the root package.
func TestAtMostOneExportedTypePerFile(t *testing.T) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
name := fi.Name()
return !strings.HasSuffix(name, "_test.go") && name != "doc.go"
}, 0)
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, pkg := range pkgs {
for path, file := range pkg.Files {
base := filepath.Base(path)
count := 0
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if ok && ts.Name.IsExported() {
count++
}
}
}
if count > 1 {
t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count)
}
}
}
}
// TestDefaultConfig_Defaults verifies that DefaultConfig returns non-zero values (S-4).
func TestDefaultConfig_Defaults(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port == 0 {
t.Error("Port must have a non-zero default")
}
if cfg.StaticDir == "" {
t.Error("StaticDir must have a non-empty default")
}
}
// TestDefaultConfig_EnvOverride verifies that environment variables are respected.
func TestDefaultConfig_EnvOverride(t *testing.T) {
t.Setenv("EINHERJAR_SPA_PORT", "9090")
t.Setenv("EINHERJAR_SPA_STATIC_DIR", "/tmp/www")
cfg := DefaultConfig()
if cfg.Port != 9090 {
t.Errorf("Port = %d, want 9090", cfg.Port)
}
if cfg.StaticDir != "/tmp/www" {
t.Errorf("StaticDir = %q, want /tmp/www", cfg.StaticDir)
}
}
// TestServer_Lifecycle verifies that OnInit/OnStart/OnStop complete without error
// on a free port.
func TestServer_Lifecycle(t *testing.T) {
cfg := Config{Port: 0, StaticDir: t.TempDir()}
srv := NewServer(newStubLogger(), cfg)
if err := srv.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := srv.OnStart(); err != nil {
t.Fatalf("OnStart: %v", err)
}
if err := srv.OnStop(); err != nil {
t.Fatalf("OnStop: %v", err)
}
}
// --- helpers ---
type stubLogger struct{}
func newStubLogger() *stubLogger { return &stubLogger{} }
func (s *stubLogger) Debug(msg string, args ...any) {}
func (s *stubLogger) Info(msg string, args ...any) {}
func (s *stubLogger) Warn(msg string, args ...any) {}
func (s *stubLogger) Error(msg string, err error, args ...any) {}
func (s *stubLogger) With(args ...any) logging.Logger { return s }
func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }