Add domain states and event runtime (T-002)

This commit is contained in:
ila
2026-07-16 15:31:41 +08:00
parent cf9fc01c68
commit 0e20dd76b2
8 changed files with 494 additions and 6 deletions
+134
View File
@@ -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
}
+77
View File
@@ -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)
}
})
}
}