Merge pull request #2853 from wucm667/fix/system-update-already-up-to-date-response

fix(admin): system/update 在无更新时返回 200 + already_up_to_date 而非 500
This commit is contained in:
Wesley Liddick 2026-05-29 10:29:49 +08:00 committed by GitHub
commit 6bc1983506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 3 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}