diff --git a/backend/internal/handler/admin/system_handler.go b/backend/internal/handler/admin/system_handler.go index 3e2022c7..fb6c0ef7 100644 --- a/backend/internal/handler/admin/system_handler.go +++ b/backend/internal/handler/admin/system_handler.go @@ -2,6 +2,7 @@ package admin import ( "context" + "errors" "net/http" "strconv" "strings" @@ -17,12 +18,18 @@ import ( // SystemHandler handles system-related operations type SystemHandler struct { - updateSvc *service.UpdateService + updateSvc systemUpdateService lockSvc *service.SystemOperationLockService } +type systemUpdateService interface { + CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error) + PerformUpdate(ctx context.Context) error + Rollback() error +} + // NewSystemHandler creates a new SystemHandler -func NewSystemHandler(updateSvc *service.UpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler { +func NewSystemHandler(updateSvc systemUpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler { return &SystemHandler{ updateSvc: updateSvc, lockSvc: lockSvc, @@ -67,6 +74,21 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) { }() if err := h.updateSvc.PerformUpdate(ctx); err != nil { + if errors.Is(err, service.ErrNoUpdateAvailable) { + info, checkErr := h.updateSvc.CheckUpdate(ctx, false) + if checkErr != nil { + releaseReason = "SYSTEM_UPDATE_FAILED" + return nil, checkErr + } + succeeded = true + return gin.H{ + "message": "Already up to date", + "already_up_to_date": true, + "current_version": info.CurrentVersion, + "latest_version": info.LatestVersion, + "operation_id": lock.OperationID(), + }, nil + } releaseReason = "SYSTEM_UPDATE_FAILED" return nil, err } diff --git a/backend/internal/handler/admin/system_handler_test.go b/backend/internal/handler/admin/system_handler_test.go new file mode 100644 index 00000000..0f33a452 --- /dev/null +++ b/backend/internal/handler/admin/system_handler_test.go @@ -0,0 +1,144 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type systemHandlerUpdateServiceStub struct { + performErr error + updateInfo *service.UpdateInfo + checkErr error + checkForces []bool + performCall int +} + +func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) { + s.checkForces = append(s.checkForces, force) + return s.updateInfo, s.checkErr +} + +func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error { + s.performCall++ + return s.performErr +} + +func (s *systemHandlerUpdateServiceStub) Rollback() error { + return nil +} + +type systemUpdateResponseEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Message string `json:"message"` + AlreadyUpToDate bool `json:"already_up_to_date"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + OperationID string `json:"operation_id"` + } `json:"data"` +} + +type systemUpdateErrorEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServiceStub, repo *memoryIdempotencyRepoStub) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + service.SetDefaultIdempotencyCoordinator(nil) + t.Cleanup(func() { + service.SetDefaultIdempotencyCoordinator(nil) + }) + + lockSvc := service.NewSystemOperationLockService(repo, service.IdempotencyConfig{ + ProcessingTimeout: time.Second, + SystemOperationTTL: time.Minute, + }) + handler := NewSystemHandler(updateSvc, lockSvc) + + router := gin.New() + router.POST("/api/v1/admin/system/update", handler.PerformUpdate) + return router +} + +func requireSystemLockStatus(t *testing.T, repo *memoryIdempotencyRepoStub, wantStatus string) { + t.Helper() + repo.mu.Lock() + defer repo.mu.Unlock() + + for _, record := range repo.data { + if record.Status == wantStatus { + return + } + } + t.Fatalf("system lock status %q not found in records: %#v", wantStatus, repo.data) +} + +func TestSystemHandlerPerformUpdateAlreadyUpToDateReturnsOK(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{ + performErr: service.ErrNoUpdateAvailable, + updateInfo: &service.UpdateInfo{ + CurrentVersion: "0.1.132", + LatestVersion: "0.1.132", + HasUpdate: false, + }, + } + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil) + req.Header.Set("Idempotency-Key", "already-up-to-date") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 1, updateSvc.performCall) + require.Equal(t, []bool{false}, updateSvc.checkForces) + requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded) + + var body systemUpdateResponseEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 0, body.Code) + require.Equal(t, "success", body.Message) + require.Equal(t, "Already up to date", body.Data.Message) + require.True(t, body.Data.AlreadyUpToDate) + require.Equal(t, "0.1.132", body.Data.CurrentVersion) + require.Equal(t, "0.1.132", body.Data.LatestVersion) + require.NotEmpty(t, body.Data.OperationID) +} + +func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T) { + updateSvc := &systemHandlerUpdateServiceStub{ + performErr: errors.New("download failed"), + } + repo := newMemoryIdempotencyRepoStub() + router := newSystemHandlerTestRouter(t, updateSvc, repo) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/update", nil) + req.Header.Set("Idempotency-Key", "real-failure") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.Equal(t, 1, updateSvc.performCall) + require.Empty(t, updateSvc.checkForces) + requireSystemLockStatus(t, repo, service.IdempotencyStatusFailedRetryable) + + var body systemUpdateErrorEnvelope + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, http.StatusInternalServerError, body.Code) + require.Equal(t, "internal error", body.Message) +} diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go index 34ad4610..de8c5e16 100644 --- a/backend/internal/service/update_service.go +++ b/backend/internal/service/update_service.go @@ -17,6 +17,12 @@ import ( "strconv" "strings" "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +var ( + ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest") ) const ( @@ -146,7 +152,7 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error { } if !info.HasUpdate { - return fmt.Errorf("no update available") + return ErrNoUpdateAvailable } // Find matching archive and checksum for current platform diff --git a/backend/internal/service/update_service_test.go b/backend/internal/service/update_service_test.go new file mode 100644 index 00000000..8d8310d4 --- /dev/null +++ b/backend/internal/service/update_service_test.go @@ -0,0 +1,64 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type updateServiceCacheStub struct { + data string +} + +func (s *updateServiceCacheStub) GetUpdateInfo(context.Context) (string, error) { + if s.data == "" { + return "", errors.New("cache miss") + } + return s.data, nil +} + +func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _ time.Duration) error { + s.data = data + return nil +} + +type updateServiceGitHubClientStub struct { + release *GitHubRelease +} + +func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) { + return s.release, nil +} + +func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error { + panic("DownloadFile should not be called when no update is available") +} + +func (s *updateServiceGitHubClientStub) FetchChecksumFile(context.Context, string) ([]byte, error) { + panic("FetchChecksumFile should not be called when no update is available") +} + +func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) { + svc := NewUpdateService( + &updateServiceCacheStub{}, + &updateServiceGitHubClientStub{ + release: &GitHubRelease{ + TagName: "v0.1.132", + Name: "v0.1.132", + }, + }, + "0.1.132", + "release", + ) + + err := svc.PerformUpdate(context.Background()) + + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoUpdateAvailable)) + require.ErrorIs(t, err, ErrNoUpdateAvailable) +}