Add domain states and event runtime (T-002)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package application
|
||||
|
||||
// EventType identifies an application event consumed by UI adapters.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventCatalogRefreshed EventType = "CatalogRefreshed"
|
||||
EventCatalogRejected EventType = "CatalogRejected"
|
||||
EventDownloadStarted EventType = "DownloadStarted"
|
||||
EventDownloadProgress EventType = "DownloadProgress"
|
||||
EventDownloadPaused EventType = "DownloadPaused"
|
||||
EventDownloadCompleted EventType = "DownloadCompleted"
|
||||
EventDownloadFailed EventType = "DownloadFailed"
|
||||
EventInstallCompleted EventType = "InstallCompleted"
|
||||
EventInstallRolledBack EventType = "InstallRolledBack"
|
||||
EventAppStarted EventType = "AppStarted"
|
||||
EventAppExited EventType = "AppExited"
|
||||
EventLicenseChanged EventType = "LicenseChanged"
|
||||
)
|
||||
|
||||
var validEventTypes = map[EventType]struct{}{
|
||||
EventCatalogRefreshed: {},
|
||||
EventCatalogRejected: {},
|
||||
EventDownloadStarted: {},
|
||||
EventDownloadProgress: {},
|
||||
EventDownloadPaused: {},
|
||||
EventDownloadCompleted: {},
|
||||
EventDownloadFailed: {},
|
||||
EventInstallCompleted: {},
|
||||
EventInstallRolledBack: {},
|
||||
EventAppStarted: {},
|
||||
EventAppExited: {},
|
||||
EventLicenseChanged: {},
|
||||
}
|
||||
|
||||
// Valid reports whether eventType is part of the documented event contract.
|
||||
func (eventType EventType) Valid() bool {
|
||||
_, ok := validEventTypes[eventType]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Event is the common envelope delivered from background use cases to adapters.
|
||||
//
|
||||
// Payload is event-specific and will be replaced by concrete payload types as
|
||||
// the corresponding use cases are implemented.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
RequestID string
|
||||
AppID string
|
||||
Payload any
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package application
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypeValid(t *testing.T) {
|
||||
eventTypes := []EventType{
|
||||
EventCatalogRefreshed,
|
||||
EventCatalogRejected,
|
||||
EventDownloadStarted,
|
||||
EventDownloadProgress,
|
||||
EventDownloadPaused,
|
||||
EventDownloadCompleted,
|
||||
EventDownloadFailed,
|
||||
EventInstallCompleted,
|
||||
EventInstallRolledBack,
|
||||
EventAppStarted,
|
||||
EventAppExited,
|
||||
EventLicenseChanged,
|
||||
}
|
||||
|
||||
for _, eventType := range eventTypes {
|
||||
if !eventType.Valid() {
|
||||
t.Errorf("event type %q should be valid", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
if EventType("Unknown").Valid() {
|
||||
t.Fatal("unknown event type should be invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidEvent indicates that an event is not part of the event contract.
|
||||
ErrInvalidEvent = errors.New("invalid application event")
|
||||
// ErrRuntimeClosed indicates that the runtime no longer accepts events.
|
||||
ErrRuntimeClosed = errors.New("application runtime closed")
|
||||
)
|
||||
|
||||
// Runtime is the minimal event bus shared by background use cases and adapters.
|
||||
type Runtime struct {
|
||||
events chan Event
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewRuntime creates an event runtime with the requested queue capacity.
|
||||
func NewRuntime(buffer int) *Runtime {
|
||||
if buffer < 0 {
|
||||
panic("application runtime buffer must not be negative")
|
||||
}
|
||||
return &Runtime{
|
||||
events: make(chan Event, buffer),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Publish queues an event or returns when the context/runtime is closed.
|
||||
func (runtime *Runtime) Publish(ctx context.Context, event Event) error {
|
||||
if !event.Type.Valid() {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, event.Type)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtime.done:
|
||||
return ErrRuntimeClosed
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtime.done:
|
||||
return ErrRuntimeClosed
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case runtime.events <- event:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Events exposes the read-only event stream to adapters.
|
||||
func (runtime *Runtime) Events() <-chan Event {
|
||||
return runtime.events
|
||||
}
|
||||
|
||||
// Done is closed when the runtime stops accepting events.
|
||||
func (runtime *Runtime) Done() <-chan struct{} {
|
||||
return runtime.done
|
||||
}
|
||||
|
||||
// Close stops future publishes. It is safe to call more than once.
|
||||
func (runtime *Runtime) Close() {
|
||||
runtime.closeOnce.Do(func() {
|
||||
close(runtime.done)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimePublish(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
event := Event{
|
||||
Type: EventDownloadStarted,
|
||||
RequestID: "request-1",
|
||||
AppID: "json-parser",
|
||||
}
|
||||
|
||||
if err := runtime.Publish(context.Background(), event); err != nil {
|
||||
t.Fatalf("Publish() error = %v", err)
|
||||
}
|
||||
|
||||
got := <-runtime.Events()
|
||||
if got != event {
|
||||
t.Fatalf("Events() got %#v, want %#v", got, event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRejectsInvalidEvent(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
|
||||
err := runtime.Publish(context.Background(), Event{Type: EventType("Unknown")})
|
||||
if !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Publish() error = %v, want %v", err, ErrInvalidEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePublishHonorsContextCancellation(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
if err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted}); err != nil {
|
||||
t.Fatalf("first Publish() error = %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := runtime.Publish(ctx, Event{Type: EventDownloadProgress})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("blocked Publish() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeClose(t *testing.T) {
|
||||
runtime := NewRuntime(1)
|
||||
runtime.Close()
|
||||
runtime.Close()
|
||||
|
||||
select {
|
||||
case <-runtime.Done():
|
||||
default:
|
||||
t.Fatal("Done() should be closed")
|
||||
}
|
||||
|
||||
err := runtime.Publish(context.Background(), Event{Type: EventDownloadStarted})
|
||||
if !errors.Is(err, ErrRuntimeClosed) {
|
||||
t.Fatalf("Publish() error = %v, want %v", err, ErrRuntimeClosed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AppStatus describes the user-visible lifecycle state of one catalog app.
|
||||
type AppStatus string
|
||||
|
||||
const (
|
||||
StatusNotInstalled AppStatus = "not_installed"
|
||||
StatusQueued AppStatus = "queued"
|
||||
StatusDownloading AppStatus = "downloading"
|
||||
StatusVerifying AppStatus = "verifying"
|
||||
StatusExtracting AppStatus = "extracting"
|
||||
StatusInstalling AppStatus = "installing"
|
||||
StatusInstalled AppStatus = "installed"
|
||||
StatusUpdateAvailable AppStatus = "update_available"
|
||||
StatusRunning AppStatus = "running"
|
||||
StatusFailed AppStatus = "failed"
|
||||
StatusRollbackPending AppStatus = "rollback_pending"
|
||||
StatusIncompatible AppStatus = "incompatible"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidStatus indicates that a transition contains an unknown status.
|
||||
ErrInvalidStatus = errors.New("invalid app status")
|
||||
// ErrInvalidTransition indicates that two valid states cannot transition directly.
|
||||
ErrInvalidTransition = errors.New("invalid app status transition")
|
||||
)
|
||||
|
||||
var validStatuses = map[AppStatus]struct{}{
|
||||
StatusNotInstalled: {},
|
||||
StatusQueued: {},
|
||||
StatusDownloading: {},
|
||||
StatusVerifying: {},
|
||||
StatusExtracting: {},
|
||||
StatusInstalling: {},
|
||||
StatusInstalled: {},
|
||||
StatusUpdateAvailable: {},
|
||||
StatusRunning: {},
|
||||
StatusFailed: {},
|
||||
StatusRollbackPending: {},
|
||||
StatusIncompatible: {},
|
||||
}
|
||||
|
||||
type statusTransition struct {
|
||||
from AppStatus
|
||||
to AppStatus
|
||||
}
|
||||
|
||||
var allowedTransitions = map[statusTransition]struct{}{
|
||||
{StatusNotInstalled, StatusQueued}: {},
|
||||
{StatusNotInstalled, StatusIncompatible}: {},
|
||||
{StatusQueued, StatusDownloading}: {},
|
||||
{StatusQueued, StatusNotInstalled}: {},
|
||||
{StatusQueued, StatusInstalled}: {},
|
||||
{StatusQueued, StatusUpdateAvailable}: {},
|
||||
{StatusQueued, StatusFailed}: {},
|
||||
{StatusDownloading, StatusQueued}: {},
|
||||
{StatusDownloading, StatusVerifying}: {},
|
||||
{StatusDownloading, StatusNotInstalled}: {},
|
||||
{StatusDownloading, StatusInstalled}: {},
|
||||
{StatusDownloading, StatusUpdateAvailable}: {},
|
||||
{StatusDownloading, StatusFailed}: {},
|
||||
{StatusVerifying, StatusExtracting}: {},
|
||||
{StatusVerifying, StatusFailed}: {},
|
||||
{StatusExtracting, StatusInstalling}: {},
|
||||
{StatusExtracting, StatusFailed}: {},
|
||||
{StatusInstalling, StatusInstalled}: {},
|
||||
{StatusInstalling, StatusRollbackPending}: {},
|
||||
{StatusInstalling, StatusFailed}: {},
|
||||
{StatusInstalled, StatusUpdateAvailable}: {},
|
||||
{StatusInstalled, StatusRunning}: {},
|
||||
{StatusInstalled, StatusQueued}: {},
|
||||
{StatusInstalled, StatusIncompatible}: {},
|
||||
{StatusUpdateAvailable, StatusQueued}: {},
|
||||
{StatusUpdateAvailable, StatusRunning}: {},
|
||||
{StatusUpdateAvailable, StatusInstalled}: {},
|
||||
{StatusUpdateAvailable, StatusIncompatible}: {},
|
||||
{StatusRunning, StatusInstalled}: {},
|
||||
{StatusRunning, StatusUpdateAvailable}: {},
|
||||
{StatusFailed, StatusQueued}: {},
|
||||
{StatusFailed, StatusNotInstalled}: {},
|
||||
{StatusFailed, StatusInstalled}: {},
|
||||
{StatusFailed, StatusUpdateAvailable}: {},
|
||||
{StatusFailed, StatusRollbackPending}: {},
|
||||
{StatusRollbackPending, StatusInstalled}: {},
|
||||
{StatusRollbackPending, StatusFailed}: {},
|
||||
{StatusIncompatible, StatusNotInstalled}: {},
|
||||
{StatusIncompatible, StatusInstalled}: {},
|
||||
{StatusIncompatible, StatusUpdateAvailable}: {},
|
||||
}
|
||||
|
||||
// Valid reports whether status is one of the documented domain states.
|
||||
func (status AppStatus) Valid() bool {
|
||||
_, ok := validStatuses[status]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CanTransition reports whether from can move directly to to.
|
||||
func CanTransition(from, to AppStatus) bool {
|
||||
return ValidateTransition(from, to) == nil
|
||||
}
|
||||
|
||||
// ValidateTransition verifies a direct state change.
|
||||
func ValidateTransition(from, to AppStatus) error {
|
||||
if !from.Valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidStatus, from)
|
||||
}
|
||||
if !to.Valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidStatus, to)
|
||||
}
|
||||
if _, ok := allowedTransitions[statusTransition{from: from, to: to}]; !ok {
|
||||
return &TransitionError{From: from, To: to}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransitionError describes a rejected direct state change.
|
||||
type TransitionError struct {
|
||||
From AppStatus
|
||||
To AppStatus
|
||||
}
|
||||
|
||||
func (err *TransitionError) Error() string {
|
||||
return fmt.Sprintf("%s: %s -> %s", ErrInvalidTransition, err.From, err.To)
|
||||
}
|
||||
|
||||
// Unwrap allows callers to use errors.Is with ErrInvalidTransition.
|
||||
func (err *TransitionError) Unwrap() error {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppStatusValid(t *testing.T) {
|
||||
statuses := []AppStatus{
|
||||
StatusNotInstalled,
|
||||
StatusQueued,
|
||||
StatusDownloading,
|
||||
StatusVerifying,
|
||||
StatusExtracting,
|
||||
StatusInstalling,
|
||||
StatusInstalled,
|
||||
StatusUpdateAvailable,
|
||||
StatusRunning,
|
||||
StatusFailed,
|
||||
StatusRollbackPending,
|
||||
StatusIncompatible,
|
||||
}
|
||||
|
||||
for _, status := range statuses {
|
||||
if !status.Valid() {
|
||||
t.Errorf("status %q should be valid", status)
|
||||
}
|
||||
}
|
||||
|
||||
if AppStatus("unknown").Valid() {
|
||||
t.Fatal("unknown status should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from AppStatus
|
||||
to AppStatus
|
||||
want error
|
||||
}{
|
||||
{name: "queue install", from: StatusNotInstalled, to: StatusQueued},
|
||||
{name: "start download", from: StatusQueued, to: StatusDownloading},
|
||||
{name: "pause download", from: StatusDownloading, to: StatusQueued},
|
||||
{name: "finish install", from: StatusInstalling, to: StatusInstalled},
|
||||
{name: "start app", from: StatusInstalled, to: StatusRunning},
|
||||
{name: "recover rollback", from: StatusRollbackPending, to: StatusInstalled},
|
||||
{name: "skip install pipeline", from: StatusNotInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||
{name: "download while running", from: StatusRunning, to: StatusDownloading, want: ErrInvalidTransition},
|
||||
{name: "extract incompatible app", from: StatusIncompatible, to: StatusExtracting, want: ErrInvalidTransition},
|
||||
{name: "same status", from: StatusInstalled, to: StatusInstalled, want: ErrInvalidTransition},
|
||||
{name: "unknown source", from: AppStatus("unknown"), to: StatusQueued, want: ErrInvalidStatus},
|
||||
{name: "unknown target", from: StatusQueued, to: AppStatus("unknown"), want: ErrInvalidStatus},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateTransition(test.from, test.to)
|
||||
if test.want == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTransition(%q, %q) error = %v", test.from, test.to, err)
|
||||
}
|
||||
if !CanTransition(test.from, test.to) {
|
||||
t.Fatalf("CanTransition(%q, %q) = false, want true", test.from, test.to)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("ValidateTransition(%q, %q) error = %v, want %v", test.from, test.to, err, test.want)
|
||||
}
|
||||
if CanTransition(test.from, test.to) {
|
||||
t.Fatalf("CanTransition(%q, %q) = true, want false", test.from, test.to)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user