fix(admin): handle already up-to-date updates
This commit is contained in:
parent
89d96f4b25
commit
b15375dfb4
@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -17,12 +18,18 @@ import (
|
|||||||
|
|
||||||
// SystemHandler handles system-related operations
|
// SystemHandler handles system-related operations
|
||||||
type SystemHandler struct {
|
type SystemHandler struct {
|
||||||
updateSvc *service.UpdateService
|
updateSvc systemUpdateService
|
||||||
lockSvc *service.SystemOperationLockService
|
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
|
// NewSystemHandler creates a new SystemHandler
|
||||||
func NewSystemHandler(updateSvc *service.UpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler {
|
func NewSystemHandler(updateSvc systemUpdateService, lockSvc *service.SystemOperationLockService) *SystemHandler {
|
||||||
return &SystemHandler{
|
return &SystemHandler{
|
||||||
updateSvc: updateSvc,
|
updateSvc: updateSvc,
|
||||||
lockSvc: lockSvc,
|
lockSvc: lockSvc,
|
||||||
@ -67,6 +74,21 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if err := h.updateSvc.PerformUpdate(ctx); err != nil {
|
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"
|
releaseReason = "SYSTEM_UPDATE_FAILED"
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
144
backend/internal/handler/admin/system_handler_test.go
Normal file
144
backend/internal/handler/admin/system_handler_test.go
Normal 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)
|
||||||
|
}
|
||||||
@ -17,6 +17,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 (
|
const (
|
||||||
@ -146,7 +152,7 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !info.HasUpdate {
|
if !info.HasUpdate {
|
||||||
return fmt.Errorf("no update available")
|
return ErrNoUpdateAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching archive and checksum for current platform
|
// Find matching archive and checksum for current platform
|
||||||
|
|||||||
64
backend/internal/service/update_service_test.go
Normal file
64
backend/internal/service/update_service_test.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user