Initial Go learning scaffold
This commit is contained in:
commit
dddf5f8db9
|
|
@ -0,0 +1,7 @@
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
bin/
|
||||||
|
coverage.out
|
||||||
|
tmp/
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
.PHONY: tidy
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# mini-platform-control-plane
|
||||||
|
|
||||||
|
Learning project for building a small Go control plane for local environments
|
||||||
|
and workloads.
|
||||||
|
|
||||||
|
The project is intentionally local-first. It starts with in-memory components
|
||||||
|
and fake providers, then adds gRPC, Temporal, MongoDB, Helm rendering,
|
||||||
|
Kubernetes integration, and observability in incremental milestones.
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
- Idiomatic package design.
|
||||||
|
- Small interfaces.
|
||||||
|
- `context.Context` cancellation.
|
||||||
|
- Wrapped errors with `errors.Is` and `errors.As`.
|
||||||
|
- Table-driven tests.
|
||||||
|
- Concurrency safety.
|
||||||
|
- Idempotent operations.
|
||||||
|
- gRPC and protobuf APIs.
|
||||||
|
- Temporal workflows and activities.
|
||||||
|
- Clean separation between API, domain, storage, provider, orchestration, and
|
||||||
|
infrastructure adapters.
|
||||||
|
|
||||||
|
## Local Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make fmt
|
||||||
|
make tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Scope
|
||||||
|
|
||||||
|
Milestone 1 defines the domain model:
|
||||||
|
|
||||||
|
- `Environment`
|
||||||
|
- `Workload`
|
||||||
|
- `Operation`
|
||||||
|
- typed validation errors
|
||||||
|
- basic status transitions
|
||||||
|
|
||||||
|
No private packages or internal infrastructure are required.
|
||||||
|
|
||||||
|
See:
|
||||||
|
|
||||||
|
- [Architecture](docs/architecture.md)
|
||||||
|
- [Milestones](docs/milestones.md)
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
`mini-platform-control-plane` models a small local management plane.
|
||||||
|
|
||||||
|
The terminology follows this shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fleet -> Environment -> Workload
|
||||||
|
```
|
||||||
|
|
||||||
|
For this project, `Fleet` is mostly conceptual at first. The first concrete
|
||||||
|
resources are environments, workloads, and operations.
|
||||||
|
|
||||||
|
## Recommended Start
|
||||||
|
|
||||||
|
Start local-first:
|
||||||
|
|
||||||
|
1. gRPC-only API.
|
||||||
|
2. In-memory storage.
|
||||||
|
3. Fake cloud provider.
|
||||||
|
4. Fake Kubernetes applier.
|
||||||
|
5. Temporal, MongoDB, Helm, and real Kubernetes adapters added later.
|
||||||
|
|
||||||
|
This keeps the first milestones focused on Go rather than infrastructure.
|
||||||
|
|
||||||
|
## Package Boundaries
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/mpctl CLI entrypoint
|
||||||
|
cmd/control-plane gRPC API server
|
||||||
|
cmd/worker Temporal worker
|
||||||
|
|
||||||
|
api/proto protobuf contracts
|
||||||
|
internal/domain core model and validation
|
||||||
|
internal/service application use cases
|
||||||
|
internal/storage storage interfaces and implementations
|
||||||
|
internal/provider cloud provider abstraction
|
||||||
|
internal/orchestration Temporal workflows and activities
|
||||||
|
internal/transport/grpc gRPC handlers
|
||||||
|
internal/helm Helm rendering adapter
|
||||||
|
internal/kube Kubernetes apply adapter
|
||||||
|
internal/observability logging, metrics, and tracing
|
||||||
|
internal/config configuration loading
|
||||||
|
test/e2e local end-to-end tests
|
||||||
|
deploy Docker, Compose, and Kubernetes assets
|
||||||
|
```
|
||||||
|
|
||||||
|
The domain and service packages must not depend on Cobra, gRPC, Temporal,
|
||||||
|
MongoDB, Helm, or Kubernetes packages. Those dependencies belong in adapter
|
||||||
|
packages.
|
||||||
|
|
||||||
|
## Initial Architecture Decision
|
||||||
|
|
||||||
|
Use gRPC first and add an HTTP gateway later only if it teaches a useful lesson.
|
||||||
|
|
||||||
|
Use in-memory storage first. Add MongoDB after the storage contract is stable.
|
||||||
|
A file-backed store is optional, but it is not needed for the first learning
|
||||||
|
path.
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
# Milestones
|
||||||
|
|
||||||
|
## 1. Domain Model
|
||||||
|
|
||||||
|
Objective: define environments, workloads, operations, statuses, and typed
|
||||||
|
validation errors.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- packages
|
||||||
|
- structs
|
||||||
|
- methods
|
||||||
|
- constants
|
||||||
|
- wrapped errors
|
||||||
|
- table-driven tests
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `internal/domain/environment.go`
|
||||||
|
- `internal/domain/workload.go`
|
||||||
|
- `internal/domain/operation.go`
|
||||||
|
- `internal/domain/errors.go`
|
||||||
|
- `internal/domain/*_test.go`
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- environment validation
|
||||||
|
- workload validation
|
||||||
|
- operation status validation
|
||||||
|
- `errors.Is` behavior for sentinel errors
|
||||||
|
|
||||||
|
Done when:
|
||||||
|
|
||||||
|
- `go test ./...` passes
|
||||||
|
- invalid domain objects return useful wrapped errors
|
||||||
|
|
||||||
|
## 2. In-Memory Storage
|
||||||
|
|
||||||
|
Objective: add storage interfaces and a concurrency-safe memory implementation.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- interfaces
|
||||||
|
- `sync.RWMutex`
|
||||||
|
- context propagation
|
||||||
|
- idempotent create/delete
|
||||||
|
- storage contract tests
|
||||||
|
|
||||||
|
## 3. Application Services
|
||||||
|
|
||||||
|
Objective: add use cases that coordinate storage and provider interfaces.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- dependency injection
|
||||||
|
- small interfaces
|
||||||
|
- context cancellation
|
||||||
|
- business error mapping
|
||||||
|
|
||||||
|
## 4. gRPC API
|
||||||
|
|
||||||
|
Objective: expose environment and workload APIs over gRPC.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- protobuf contracts
|
||||||
|
- generated Go code
|
||||||
|
- gRPC handlers
|
||||||
|
- gRPC status errors
|
||||||
|
- bufconn tests
|
||||||
|
|
||||||
|
## 5. CLI `mpctl`
|
||||||
|
|
||||||
|
Objective: add Cobra commands backed by the gRPC client.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- command construction
|
||||||
|
- Viper config loading
|
||||||
|
- command tests
|
||||||
|
- output formatting
|
||||||
|
|
||||||
|
## 6. Workload Deployment Path
|
||||||
|
|
||||||
|
Objective: deploy workloads using a fake cloud provider and idempotency keys.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- retries
|
||||||
|
- cancellation
|
||||||
|
- interface mocks
|
||||||
|
- retryable vs permanent errors
|
||||||
|
|
||||||
|
## 7. Temporal Workflows
|
||||||
|
|
||||||
|
Objective: move long-running create/delete/deploy operations into workflows.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- workflow determinism
|
||||||
|
- activities
|
||||||
|
- Temporal test environment
|
||||||
|
- cancellation
|
||||||
|
|
||||||
|
## 8. Helm And Kubernetes Adapters
|
||||||
|
|
||||||
|
Objective: render workload manifests and apply them through a fake Kubernetes
|
||||||
|
client, then later a real one.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- adapter boundaries
|
||||||
|
- YAML/object validation
|
||||||
|
- interface-driven tests
|
||||||
|
|
||||||
|
## 9. Observability
|
||||||
|
|
||||||
|
Objective: add structured logs, Prometheus metrics, and OpenTelemetry traces.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- `log/slog`
|
||||||
|
- metrics labels
|
||||||
|
- trace/span propagation
|
||||||
|
- observability tests
|
||||||
|
|
||||||
|
## 10. MongoDB Storage
|
||||||
|
|
||||||
|
Objective: add a MongoDB implementation behind the same storage interfaces.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- integration tests
|
||||||
|
- serialization
|
||||||
|
- repository contract reuse
|
||||||
|
|
||||||
|
## 11. Local End-to-End Test
|
||||||
|
|
||||||
|
Objective: run the API, worker, and local dependencies together.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- black-box tests
|
||||||
|
- process orchestration
|
||||||
|
- test fixtures
|
||||||
|
|
||||||
|
## 12. Kubernetes Deployment
|
||||||
|
|
||||||
|
Objective: package and deploy into `kind` or `k3d`, then later the homelab
|
||||||
|
cluster.
|
||||||
|
|
||||||
|
Go concepts:
|
||||||
|
|
||||||
|
- container builds
|
||||||
|
- runtime config
|
||||||
|
- readiness checks
|
||||||
|
- Helm chart values
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/juvdiaz/mini-platform-control-plane
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnvironmentStatusCreating EnvironmentStatus = "CREATING"
|
||||||
|
EnvironmentStatusActive EnvironmentStatus = "ACTIVE"
|
||||||
|
EnvironmentStatusDeleting EnvironmentStatus = "DELETING"
|
||||||
|
EnvironmentStatusDeleted EnvironmentStatus = "DELETED"
|
||||||
|
EnvironmentStatusFailed EnvironmentStatus = "FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Environment struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Status EnvironmentStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Environment) Validate() error {
|
||||||
|
if strings.TrimSpace(e.ID) == "" {
|
||||||
|
return fmt.Errorf("%w: environment id is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(e.Name) == "" {
|
||||||
|
return fmt.Errorf("%w: environment name is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if !e.Status.Valid() {
|
||||||
|
return fmt.Errorf("%w: environment status %q", ErrInvalidStatus, e.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s EnvironmentStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case EnvironmentStatusCreating,
|
||||||
|
EnvironmentStatusActive,
|
||||||
|
EnvironmentStatusDeleting,
|
||||||
|
EnvironmentStatusDeleted,
|
||||||
|
EnvironmentStatusFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnvironmentValidate(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env Environment
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid environment",
|
||||||
|
env: Environment{
|
||||||
|
ID: "env-1",
|
||||||
|
Name: "dev",
|
||||||
|
Status: EnvironmentStatusActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing id",
|
||||||
|
env: Environment{
|
||||||
|
Name: "dev",
|
||||||
|
Status: EnvironmentStatusActive,
|
||||||
|
},
|
||||||
|
want: ErrInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid status",
|
||||||
|
env: Environment{
|
||||||
|
ID: "env-1",
|
||||||
|
Name: "dev",
|
||||||
|
Status: EnvironmentStatus("UNKNOWN"),
|
||||||
|
},
|
||||||
|
want: ErrInvalidStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.env.Validate()
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
|
ErrInvalidStatus = errors.New("invalid status")
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OperationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationStatusRunning OperationStatus = "RUNNING"
|
||||||
|
OperationStatusSucceeded OperationStatus = "SUCCEEDED"
|
||||||
|
OperationStatusFailed OperationStatus = "FAILED"
|
||||||
|
OperationStatusCanceled OperationStatus = "CANCELED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Operation struct {
|
||||||
|
ID string
|
||||||
|
TargetID string
|
||||||
|
Type string
|
||||||
|
Status OperationStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Operation) Validate() error {
|
||||||
|
if strings.TrimSpace(o.ID) == "" {
|
||||||
|
return fmt.Errorf("%w: operation id is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(o.TargetID) == "" {
|
||||||
|
return fmt.Errorf("%w: operation target id is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(o.Type) == "" {
|
||||||
|
return fmt.Errorf("%w: operation type is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if !o.Status.Valid() {
|
||||||
|
return fmt.Errorf("%w: operation status %q", ErrInvalidStatus, o.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s OperationStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case OperationStatusRunning,
|
||||||
|
OperationStatusSucceeded,
|
||||||
|
OperationStatusFailed,
|
||||||
|
OperationStatusCanceled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOperationValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
op Operation
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid operation",
|
||||||
|
op: Operation{
|
||||||
|
ID: "op-1",
|
||||||
|
TargetID: "env-1",
|
||||||
|
Type: "create-environment",
|
||||||
|
Status: OperationStatusRunning,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing target",
|
||||||
|
op: Operation{
|
||||||
|
ID: "op-1",
|
||||||
|
Type: "create-environment",
|
||||||
|
Status: OperationStatusRunning,
|
||||||
|
},
|
||||||
|
want: ErrInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid status",
|
||||||
|
op: Operation{
|
||||||
|
ID: "op-1",
|
||||||
|
TargetID: "env-1",
|
||||||
|
Type: "create-environment",
|
||||||
|
Status: OperationStatus("UNKNOWN"),
|
||||||
|
},
|
||||||
|
want: ErrInvalidStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.op.Validate()
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkloadStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WorkloadStatusPending WorkloadStatus = "PENDING"
|
||||||
|
WorkloadStatusDeploying WorkloadStatus = "DEPLOYING"
|
||||||
|
WorkloadStatusRunning WorkloadStatus = "RUNNING"
|
||||||
|
WorkloadStatusFailed WorkloadStatus = "FAILED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Workload struct {
|
||||||
|
ID string
|
||||||
|
EnvironmentID string
|
||||||
|
Name string
|
||||||
|
Image string
|
||||||
|
Status WorkloadStatus
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w Workload) Validate() error {
|
||||||
|
if strings.TrimSpace(w.ID) == "" {
|
||||||
|
return fmt.Errorf("%w: workload id is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(w.EnvironmentID) == "" {
|
||||||
|
return fmt.Errorf("%w: environment id is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(w.Name) == "" {
|
||||||
|
return fmt.Errorf("%w: workload name is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(w.Image) == "" {
|
||||||
|
return fmt.Errorf("%w: workload image is required", ErrInvalidArgument)
|
||||||
|
}
|
||||||
|
if !w.Status.Valid() {
|
||||||
|
return fmt.Errorf("%w: workload status %q", ErrInvalidStatus, w.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s WorkloadStatus) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case WorkloadStatusPending,
|
||||||
|
WorkloadStatusDeploying,
|
||||||
|
WorkloadStatusRunning,
|
||||||
|
WorkloadStatusFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkloadValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
w Workload
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid workload",
|
||||||
|
w: Workload{
|
||||||
|
ID: "workload-1",
|
||||||
|
EnvironmentID: "env-1",
|
||||||
|
Name: "api",
|
||||||
|
Image: "example/api:v1",
|
||||||
|
Status: WorkloadStatusPending,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing image",
|
||||||
|
w: Workload{
|
||||||
|
ID: "workload-1",
|
||||||
|
EnvironmentID: "env-1",
|
||||||
|
Name: "api",
|
||||||
|
Status: WorkloadStatusPending,
|
||||||
|
},
|
||||||
|
want: ErrInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid status",
|
||||||
|
w: Workload{
|
||||||
|
ID: "workload-1",
|
||||||
|
EnvironmentID: "env-1",
|
||||||
|
Name: "api",
|
||||||
|
Image: "example/api:v1",
|
||||||
|
Status: WorkloadStatus("UNKNOWN"),
|
||||||
|
},
|
||||||
|
want: ErrInvalidStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.w.Validate()
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("Validate() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue