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", nil) 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 TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) { moveToRepoRoot(t) local, connMgr, configService := newAPITestStack(t) _ = configService cfg := &config.Config{} setConfigDefaults(cfg) router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) if err != nil { t.Fatalf("setup router: %v", err) } createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`))) createProjectReq.Header.Set("Content-Type", "application/json") createProjectRec := httptest.NewRecorder() router.ServeHTTP(createProjectRec, createProjectReq) if createProjectRec.Code != http.StatusCreated { t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String()) } var project models.Project if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil { t.Fatalf("unmarshal project: %v", err) } createCfgBody := []byte(`{"name":"Cfg A","items":[{"lot_name":"CPU","quantity":1,"unit_price":100}],"server_count":1}`) createCfgReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs", bytes.NewReader(createCfgBody)) createCfgReq.Header.Set("Content-Type", "application/json") createCfgRec := httptest.NewRecorder() router.ServeHTTP(createCfgRec, createCfgReq) if createCfgRec.Code != http.StatusCreated { t.Fatalf("create project config status=%d body=%s", createCfgRec.Code, createCfgRec.Body.String()) } var createdCfg models.Configuration if err := json.Unmarshal(createCfgRec.Body.Bytes(), &createdCfg); err != nil { t.Fatalf("unmarshal project config: %v", err) } if createdCfg.ProjectUUID == nil || *createdCfg.ProjectUUID != project.UUID { t.Fatalf("expected config project_uuid=%s got=%v", project.UUID, createdCfg.ProjectUUID) } cloneReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/configs/"+createdCfg.UUID+"/clone", bytes.NewReader([]byte(`{"name":"Cfg A Clone"}`))) cloneReq.Header.Set("Content-Type", "application/json") cloneRec := httptest.NewRecorder() router.ServeHTTP(cloneRec, cloneReq) if cloneRec.Code != http.StatusCreated { t.Fatalf("clone in project status=%d body=%s", cloneRec.Code, cloneRec.Body.String()) } var cloneCfg models.Configuration if err := json.Unmarshal(cloneRec.Body.Bytes(), &cloneCfg); err != nil { t.Fatalf("unmarshal clone config: %v", err) } if cloneCfg.ProjectUUID == nil || *cloneCfg.ProjectUUID != project.UUID { t.Fatalf("expected clone project_uuid=%s got=%v", project.UUID, cloneCfg.ProjectUUID) } projectConfigsReq := httptest.NewRequest(http.MethodGet, "/api/projects/"+project.UUID+"/configs", nil) projectConfigsRec := httptest.NewRecorder() router.ServeHTTP(projectConfigsRec, projectConfigsReq) if projectConfigsRec.Code != http.StatusOK { t.Fatalf("project configs status=%d body=%s", projectConfigsRec.Code, projectConfigsRec.Body.String()) } var projectConfigsResp struct { Configurations []models.Configuration `json:"configurations"` } if err := json.Unmarshal(projectConfigsRec.Body.Bytes(), &projectConfigsResp); err != nil { t.Fatalf("unmarshal project configs response: %v", err) } if len(projectConfigsResp.Configurations) != 2 { t.Fatalf("expected 2 project configs after clone, got %d", len(projectConfigsResp.Configurations)) } archiveReq := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/archive", nil) archiveRec := httptest.NewRecorder() router.ServeHTTP(archiveRec, archiveReq) if archiveRec.Code != http.StatusOK { t.Fatalf("archive project status=%d body=%s", archiveRec.Code, archiveRec.Body.String()) } activeReq := httptest.NewRequest(http.MethodGet, "/api/configs?status=active&page=1&per_page=20", nil) activeRec := httptest.NewRecorder() router.ServeHTTP(activeRec, activeReq) if activeRec.Code != http.StatusOK { t.Fatalf("active configs status=%d body=%s", activeRec.Code, activeRec.Body.String()) } var activeResp struct { Configurations []models.Configuration `json:"configurations"` } if err := json.Unmarshal(activeRec.Body.Bytes(), &activeResp); err != nil { t.Fatalf("unmarshal active configs response: %v", err) } if len(activeResp.Configurations) != 0 { t.Fatalf("expected no active configs after project archive, got %d", len(activeResp.Configurations)) } } func TestConfigMoveToProjectEndpoint(t *testing.T) { moveToRepoRoot(t) local, connMgr, _ := newAPITestStack(t) cfg := &config.Config{} setConfigDefaults(cfg) router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) if err != nil { t.Fatalf("setup router: %v", err) } createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`))) createProjectReq.Header.Set("Content-Type", "application/json") createProjectRec := httptest.NewRecorder() router.ServeHTTP(createProjectRec, createProjectReq) if createProjectRec.Code != http.StatusCreated { t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String()) } var project models.Project if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil { t.Fatalf("unmarshal project: %v", err) } createConfigReq := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":"Move Me","items":[],"notes":"","server_count":1}`))) createConfigReq.Header.Set("Content-Type", "application/json") createConfigRec := httptest.NewRecorder() router.ServeHTTP(createConfigRec, createConfigReq) if createConfigRec.Code != http.StatusCreated { t.Fatalf("create config status=%d body=%s", createConfigRec.Code, createConfigRec.Body.String()) } var created models.Configuration if err := json.Unmarshal(createConfigRec.Body.Bytes(), &created); err != nil { t.Fatalf("unmarshal config: %v", err) } moveReq := httptest.NewRequest(http.MethodPatch, "/api/configs/"+created.UUID+"/project", bytes.NewReader([]byte(`{"project_uuid":"`+project.UUID+`"}`))) moveReq.Header.Set("Content-Type", "application/json") moveRec := httptest.NewRecorder() router.ServeHTTP(moveRec, moveReq) if moveRec.Code != http.StatusOK { t.Fatalf("move config status=%d body=%s", moveRec.Code, moveRec.Body.String()) } getReq := httptest.NewRequest(http.MethodGet, "/api/configs/"+created.UUID, nil) getRec := httptest.NewRecorder() router.ServeHTTP(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("get config status=%d body=%s", getRec.Code, getRec.Body.String()) } var updated models.Configuration if err := json.Unmarshal(getRec.Body.Bytes(), &updated); err != nil { t.Fatalf("unmarshal updated config: %v", err) } if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID { t.Fatalf("expected moved project_uuid=%s, got %v", project.UUID, updated.ProjectUUID) } } 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) }) }