diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 37c4ce0..203690c 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1079,7 +1079,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect config, err := configService.UpdateNoAuth(uuid, &req) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + switch { + case errors.Is(err, services.ErrConfigNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrProjectForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } return } @@ -1747,11 +1756,37 @@ func requestLogger() gin.HandlerFunc { path := c.Request.URL.Path query := c.Request.URL.RawQuery + blw := &captureResponseWriter{ + ResponseWriter: c.Writer, + body: bytes.NewBuffer(nil), + } + c.Writer = blw + c.Next() latency := time.Since(start) status := c.Writer.Status() + if status >= http.StatusBadRequest { + responseBody := strings.TrimSpace(blw.body.String()) + if len(responseBody) > 2048 { + responseBody = responseBody[:2048] + "...(truncated)" + } + errText := strings.TrimSpace(c.Errors.String()) + + slog.Error("request failed", + "method", c.Request.Method, + "path", path, + "query", query, + "status", status, + "latency", latency, + "ip", c.ClientIP(), + "errors", errText, + "response", responseBody, + ) + return + } + slog.Info("request", "method", c.Request.Method, "path", path, @@ -1762,3 +1797,22 @@ func requestLogger() gin.HandlerFunc { ) } } + +type captureResponseWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *captureResponseWriter) Write(b []byte) (int, error) { + if len(b) > 0 { + _, _ = w.body.Write(b) + } + return w.ResponseWriter.Write(b) +} + +func (w *captureResponseWriter) WriteString(s string) (int, error) { + if s != "" { + _, _ = w.body.WriteString(s) + } + return w.ResponseWriter.WriteString(s) +} diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index fc6adc9..275067f 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -461,9 +461,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR projectUUID := localCfg.ProjectUUID if req.ProjectUUID != nil { + requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID) + currentProjectUUID := "" + if localCfg.ProjectUUID != nil { + currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID) + } + projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID) if err != nil { - return nil, err + // Allow save for legacy/orphaned configs when request keeps the same project UUID. + // This can happen for imported configs whose project is not present in local cache. + if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID { + projectUUID = localCfg.ProjectUUID + } else { + return nil, err + } } } pricelistID, err := s.resolvePricelistID(req.PricelistID) @@ -1001,13 +1013,17 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb. return fmt.Errorf("load current version before save: %w", err) } - sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion) - if err != nil { - return fmt.Errorf("compare revision content: %w", err) - } - if sameRevisionContent { - cfg = localdb.LocalToConfiguration(&locked) - return nil + // Legacy/orphaned rows may have empty or stale current_version_id. + // In that case we treat update as content-changing and append a fresh version. + if currentVersion != nil { + sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion) + if err != nil { + return fmt.Errorf("compare revision content: %w", err) + } + if sameRevisionContent { + cfg = localdb.LocalToConfiguration(&locked) + return nil + } } } @@ -1058,6 +1074,9 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg * if err := tx.Where("configuration_uuid = ?", localCfg.UUID). Order("version_no DESC"). First(&version).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } return nil, err } return &version, nil diff --git a/internal/services/local_configuration_versioning_test.go b/internal/services/local_configuration_versioning_test.go index a071378..038cbff 100644 --- a/internal/services/local_configuration_versioning_test.go +++ b/internal/services/local_configuration_versioning_test.go @@ -290,6 +290,99 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) { } } +func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) { + service, local := newLocalConfigServiceForTest(t) + + project := &localdb.LocalProject{ + UUID: "project-orphan", + OwnerUsername: "tester", + Code: "TEST-ORPHAN", + Name: ptrString("Orphan Project"), + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + SyncStatus: "synced", + } + if err := local.SaveProject(project); err != nil { + t.Fatalf("save project: %v", err) + } + + created, err := service.Create("tester", &CreateConfigRequest{ + Name: "cfg", + ProjectUUID: &project.UUID, + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + // Simulate missing project in local cache while config still references its UUID. + if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil { + t.Fatalf("delete project: %v", err) + } + + updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{ + Name: "cfg-updated", + ProjectUUID: &project.UUID, + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("update config with orphan project_uuid: %v", err) + } + if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID { + t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID) + } +} + +func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) { + service, local := newLocalConfigServiceForTest(t) + + created, err := service.Create("tester", &CreateConfigRequest{ + Name: "cfg", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + // Simulate corrupted/legacy versioning state: + // local configuration exists, but all version rows are gone and pointer is stale. + if err := local.DB().Where("configuration_uuid = ?", created.UUID). + Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil { + t.Fatalf("delete versions: %v", err) + } + staleID := "missing-version-id" + if err := local.DB().Model(&localdb.LocalConfiguration{}). + Where("uuid = ?", created.UUID). + Update("current_version_id", staleID).Error; err != nil { + t.Fatalf("set stale current_version_id: %v", err) + } + + updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{ + Name: "cfg-updated", + Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}, + ServerCount: 1, + }) + if err != nil { + t.Fatalf("update config with missing current version: %v", err) + } + + if updated.Name != "cfg-updated" { + t.Fatalf("expected updated name, got %q", updated.Name) + } + + versions := loadVersions(t, local, created.UUID) + if len(versions) != 1 { + t.Fatalf("expected 1 recreated version, got %d", len(versions)) + } + if versions[0].VersionNo != 1 { + t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo) + } +} + func ptrString(value string) *string { return &value }