Implement local DB migrations and archived configuration lifecycle

This commit is contained in:
Mikhail Chusavitin
2026-02-04 18:52:56 +03:00
parent f4f92dea66
commit 41c0a47f54
15 changed files with 2072 additions and 172 deletions

View File

@@ -629,8 +629,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.GET("", func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
status := c.DefaultQuery("status", "active")
if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
cfgs, total, err := configService.ListAll(page, perPage)
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -641,6 +646,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"total": total,
"page": page,
"per_page": perPage,
"status": status,
})
})
@@ -706,7 +712,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
c.JSON(http.StatusOK, gin.H{"message": "archived"})
})
configs.POST("/:uuid/reactivate", func(c *gin.Context) {
uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "reactivated",
"config": config,
})
})
configs.PATCH("/:uuid/rename", func(c *gin.Context) {
@@ -756,6 +775,110 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
c.JSON(http.StatusOK, config)
})
configs.GET("/:uuid/versions", func(c *gin.Context) {
uuid := c.Param("uuid")
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
if err != nil || limit <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
if err != nil || offset < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
return
}
versions, err := configService.ListVersions(uuid, limit, offset)
if err != nil {
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{
"versions": versions,
"limit": limit,
"offset": offset,
})
})
configs.GET("/:uuid/versions/:version", func(c *gin.Context) {
uuid := c.Param("uuid")
versionNo, err := strconv.Atoi(c.Param("version"))
if err != nil || versionNo <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
return
}
version, err := configService.GetVersion(uuid, versionNo)
if err != nil {
switch {
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, version)
})
configs.POST("/:uuid/rollback", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
TargetVersion int `json:"target_version"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TargetVersion <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
return
}
config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note)
if err != nil {
switch {
case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
currentVersion, err := configService.GetCurrentVersion(uuid)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "rollback applied",
"config": config,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "rollback applied",
"config": config,
"current_version": currentVersion,
})
})
}
// Pricing admin (public - RBAC disabled)

View File

@@ -0,0 +1,173 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestConfigurationVersioningAPI(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, configService := newAPITestStack(t)
_ = local
created, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "api-v1",
Items: models.ConfigItems{{LotName: "CPU_API", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if _, err := configService.RenameNoAuth(created.UUID, "api-v2"); err != nil {
t.Fatalf("rename config: %v", err)
}
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester")
if err != nil {
t.Fatalf("setup router: %v", err)
}
// list versions happy path
listReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions?limit=10&offset=0", nil)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list versions status=%d body=%s", listRec.Code, listRec.Body.String())
}
// get version happy path
getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/1", nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get version status=%d body=%s", getRec.Code, getRec.Body.String())
}
// rollback happy path
body := []byte(`{"target_version":1,"note":"api rollback"}`)
rbReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader(body))
rbReq.Header.Set("Content-Type", "application/json")
rbRec := httptest.NewRecorder()
router.ServeHTTP(rbRec, rbReq)
if rbRec.Code != http.StatusOK {
t.Fatalf("rollback status=%d body=%s", rbRec.Code, rbRec.Body.String())
}
var rbResp struct {
Message string `json:"message"`
CurrentVersion struct {
VersionNo int `json:"version_no"`
} `json:"current_version"`
}
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
t.Fatalf("unmarshal rollback response: %v", err)
}
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
t.Fatalf("unexpected rollback response: %+v", rbResp)
}
// 404: version missing
notFoundReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/999", nil)
notFoundRec := httptest.NewRecorder()
router.ServeHTTP(notFoundRec, notFoundReq)
if notFoundRec.Code != http.StatusNotFound {
t.Fatalf("expected 404 for missing version, got %d", notFoundRec.Code)
}
// 400: invalid version number
invalidReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID+"/versions/abc", nil)
invalidRec := httptest.NewRecorder()
router.ServeHTTP(invalidRec, invalidReq)
if invalidRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid version, got %d", invalidRec.Code)
}
// 400: rollback invalid target_version
badRollbackReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/rollback", bytes.NewReader([]byte(`{"target_version":0}`)))
badRollbackReq.Header.Set("Content-Type", "application/json")
badRollbackRec := httptest.NewRecorder()
router.ServeHTTP(badRollbackRec, badRollbackReq)
if badRollbackRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid rollback target, got %d", badRollbackRec.Code)
}
// archive + reactivate flow
delReq := httptest.NewRequest(http.MethodDelete, "/api/configs/"+created.UUID, nil)
delRec := httptest.NewRecorder()
router.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusOK {
t.Fatalf("archive status=%d body=%s", delRec.Code, delRec.Body.String())
}
archivedListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=archived&page=1&per_page=20", nil)
archivedListRec := httptest.NewRecorder()
router.ServeHTTP(archivedListRec, archivedListReq)
if archivedListRec.Code != http.StatusOK {
t.Fatalf("archived list status=%d body=%s", archivedListRec.Code, archivedListRec.Body.String())
}
reactivateReq := httptest.NewRequest(http.MethodPost, "/api/configs/"+created.UUID+"/reactivate", nil)
reactivateRec := httptest.NewRecorder()
router.ServeHTTP(reactivateRec, reactivateReq)
if reactivateRec.Code != http.StatusOK {
t.Fatalf("reactivate status=%d body=%s", reactivateRec.Code, reactivateRec.Body.String())
}
activeListReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil)
activeListRec := httptest.NewRecorder()
router.ServeHTTP(activeListRec, activeListReq)
if activeListRec.Code != http.StatusOK {
t.Fatalf("active list status=%d body=%s", activeListRec.Code, activeListRec.Body.String())
}
}
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper()
localPath := filepath.Join(t.TempDir(), "api.db")
local, err := localdb.New(localPath)
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
connMgr := db.NewConnectionManager(local)
syncService := syncsvc.NewService(connMgr, local)
configService := services.NewLocalConfigurationService(
local,
syncService,
&services.QuoteService{},
func() bool { return false },
)
return local, connMgr, configService
}
func moveToRepoRoot(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
root := filepath.Clean(filepath.Join(wd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir repo root: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
}