From d03c6bb208c600ee3bdabfdcf92fc46961b260f7 Mon Sep 17 00:00:00 2001 From: chayandass Date: Fri, 11 Jul 2025 15:01:51 +0530 Subject: [PATCH] fix(obligation-create):fix obligation creation wit shortname and add test for obligation crud --- pkg/api/api_test.go | 338 +++++++++++++++++++++++++++++++++++++++++ pkg/api/obligations.go | 12 ++ pkg/models/types.go | 33 ++-- pkg/utils/util.go | 25 +++ 4 files changed, 391 insertions(+), 17 deletions(-) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 2e63ed2c..033b7944 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -23,6 +23,7 @@ import ( "github.com/fossology/LicenseDb/pkg/db" "github.com/fossology/LicenseDb/pkg/models" + "github.com/fossology/LicenseDb/pkg/utils" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/stretchr/testify/assert" @@ -34,6 +35,7 @@ import ( var baseURL string // global variable var AuthToken string // Global reusable token +const testDataFile = "licenseRef.json" func TestMain(m *testing.M) { gin.SetMode(gin.TestMode) @@ -57,6 +59,10 @@ func TestMain(m *testing.M) { log.Fatalf(" Failed to seed user: %v", err) } log.Println("First user created") + if err := createEmptyDataFile(testDataFile); err != nil { + panic("failed to create test data file: " + err.Error()) + } + utils.Populatedb(testDataFile) // populate the nessesary tables with data from the file serverPort := os.Getenv("PORT") if serverPort == "" { @@ -412,6 +418,232 @@ func TestUpdateLicense(t *testing.T) { } +func TestCreateObligation(t *testing.T) { + t.Run("SuccessWithoutShortnames", func(t *testing.T) { + dto := models.ObligationDTO{ + Topic: ptr("test-topic-1"), + Type: ptr("RIGHT"), + Text: ptr("some text"), + Modifications: ptr(false), + Classification: ptr("GREEN"), + Comment: ptr("unit test no shortnames"), + Active: ptr(true), + TextUpdatable: ptr(false), + Shortnames: []string{}, + Category: ptr("GENERAL"), + } + + assertObligationCreated(t, dto) + }) + + t.Run("SuccessWithShortnames", func(t *testing.T) { + dto := models.ObligationDTO{ + Topic: ptr("test-topic-2"), + Type: ptr("RIGHT"), + Text: ptr("another text"), + Modifications: ptr(true), + Classification: ptr("YELLOW"), + Comment: ptr("unit test with shortnames"), + Active: ptr(true), + TextUpdatable: ptr(true), + Shortnames: []string{"MIT"}, + Category: ptr("DISTRIBUTION"), + } + + assertObligationCreated(t, dto) + }) + t.Run("shortnamenotexist", func(t *testing.T) { + dto := models.ObligationDTO{ + Topic: ptr("test-topic-3"), + Type: ptr("RIGHT"), + Text: ptr("text with non-existing shortname"), + Modifications: ptr(false), + Classification: ptr("RED"), + Comment: ptr("unit test with non-existing shortname"), + Active: ptr(true), + TextUpdatable: ptr(false), + Shortnames: []string{"NonExistentShortname"}, + Category: ptr("GENERAL"), + } + + w := makeRequest("POST", "/obligations", dto, true) + assert.Equal(t, http.StatusBadRequest, w.Code) + + var res models.LicenseError + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Errorf("Error unmarshalling JSON: %v", err) + return + } + + expectedError := "license 'NonExistentShortname' not found: record not found" + assert.Equal(t, expectedError, res.Error) + assert.Equal(t, http.StatusBadRequest, res.Status) + }) + + t.Run("DuplicateTopic", func(t *testing.T) { + dto := models.ObligationDTO{ + Topic: ptr("duplicate-topic"), + Type: ptr("RIGHT"), + Text: ptr("text for duplicate topic"), + Modifications: ptr(false), + Classification: ptr("GREEN"), + Comment: ptr("first insert"), + Active: ptr(true), + TextUpdatable: ptr(false), + Shortnames: []string{}, + Category: ptr("GENERAL"), + } + assertObligationCreated(t, dto) + + // Try again with same topic + w := makeRequest("POST", "/obligations", dto, true) + if w.Code == http.StatusCreated { + t.Errorf("Expected error for duplicate topic, got 201") + } + }) + + t.Run("MissingRequiredField", func(t *testing.T) { + dto := models.ObligationDTO{ + // Topic missing + Type: ptr("RIGHT"), + Text: ptr("text"), + Modifications: ptr(false), + Classification: ptr("GREEN"), + Comment: ptr("missing topic"), + Active: ptr(true), + TextUpdatable: ptr(false), + Shortnames: []string{}, + Category: ptr("GENERAL"), + } + + w := makeRequest("POST", "/obligations", dto, true) + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing required field, got %d", w.Code) + } + }) +} + +func TestUpdateObligation(t *testing.T) { + t.Run("CreateObligation", func(t *testing.T) { + dto := models.ObligationDTO{ + Topic: ptr("test-update-topic"), + Type: ptr("RIGHT"), + Text: ptr("test text for update"), + Modifications: ptr(false), + Classification: ptr("GREEN"), + Comment: ptr("unit test comment"), + Active: ptr(true), + TextUpdatable: ptr(false), + Shortnames: []string{}, + Category: ptr("GENERAL"), + } + assertObligationCreated(t, dto) + }) + + t.Run("UpdateObligation", func(t *testing.T) { + updateDTO := models.ObligationUpdateDTO{ + Type: ptr("RIGHT"), + Text: ptr("test text for update"), + Classification: ptr("GREEN"), + Modifications: ptr(true), + Comment: ptr("updated comment"), + Active: ptr(false), + TextUpdatable: ptr(false), + Category: ptr("GENERAL"), + } + + assertObligationUpdated(t, "test-update-topic", updateDTO) + }) + t.Run("UpdateTextUpdatableFalse", func(t *testing.T) { + updateDTO := models.ObligationUpdateDTO{ + TextUpdatable: ptr(false), + } + assertObligationUpdated(t, "test-update-topic", updateDTO) + textupdate := models.ObligationUpdateDTO{ + Text: ptr("Trying to update text when TextUpdatable is false"), + } + w := makeRequest("PATCH", "/obligations/test-update-topic", textupdate, true) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("UpdateTextUpdatableTrue", func(t *testing.T) { + updateDTO := models.ObligationUpdateDTO{ + TextUpdatable: ptr(true), + } + assertObligationUpdated(t, "test-update-topic", updateDTO) + + textupdate := models.ObligationUpdateDTO{ + Text: ptr("Successfully updated text when TextUpdatable is true"), + } + w := makeRequest("PATCH", "/obligations/test-update-topic", textupdate, true) + assert.Equal(t, http.StatusOK, w.Code) + + var res models.ObligationResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Errorf("Error unmarshalling JSON: %v", err) + return + } + + assert.Equal(t, *textupdate.Text, *res.Data[0].Text) + }) + t.Run("UpdateNonExistingObligation", func(t *testing.T) { + updateDTO := models.ObligationUpdateDTO{ + Type: ptr("RIGHT"), + Text: ptr("text for non-existing obligation"), + Classification: ptr("GREEN"), + Modifications: ptr(false), + Comment: ptr("non-existing obligation comment"), + Active: ptr(true), + TextUpdatable: ptr(false), + Category: ptr("GENERAL"), + } + + w := makeRequest("PATCH", "/obligations/non-existing-topic", updateDTO, true) + assert.Equal(t, http.StatusNotFound, w.Code) + + var res models.LicenseError + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Errorf("Error unmarshalling JSON: %v", err) + return + } + + assert.Equal(t, "record not found", res.Error) + assert.Equal(t, http.StatusNotFound, res.Status) + }) + +} + +func TestDeleteObligation(t *testing.T) { + topic := "delete-test-topic" + dto := models.ObligationDTO{ + Topic: ptr(topic), + Type: ptr("RISK"), + Text: ptr("To be deleted"), + Classification: ptr("GREEN"), + Modifications: ptr(false), + Comment: ptr("delete comment"), + Active: ptr(true), + TextUpdatable: ptr(true), + Shortnames: []string{}, + Category: ptr("GENERAL"), + } + assertObligationCreated(t, dto) + + t.Run("DeleteExistingObligation", func(t *testing.T) { + w := makeRequest("DELETE", "/obligations/"+topic, nil, true) + if w.Code != http.StatusNoContent { + t.Fatalf("Expected status 204 No Content, got %d", w.Code) + } + }) + + t.Run("DeleteNonExistentObligation", func(t *testing.T) { + w := makeRequest("DELETE", "/obligations/"+topic, nil, true) + if w.Code != http.StatusNoContent { + t.Fatalf("Expected status 204 Not Found, got %d", w.Code) + } + }) +} + // func TestSearchInLicense(t *testing.T) { // expectLicense := models.LicenseDB{ // Shortname: func(s string) *string { return &s }("PostgreSQL"), @@ -587,3 +819,109 @@ func dropTestDB(user, password, port, host, dbname string) { log.Println(" Dropped test DB:", dbname) } } + +func createEmptyDataFile(path string) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + empty := []interface{}{} + return json.NewEncoder(file).Encode(empty) +} + +func assertObligationCreated(t *testing.T, dto models.ObligationDTO) { + w := makeRequest("POST", "/obligations", dto, true) + if w.Code != http.StatusCreated { + t.Fatalf("Expected 201 Created, got %d", w.Code) + } + + var resp struct { + Status int `json:"status"` + Data []models.ObligationDTO `json:"data"` + Meta any `json:"paginationmeta"` + } + + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if len(resp.Data) == 0 { + t.Fatal("No obligation returned in response") + } + + ob := resp.Data[0] + + // Assertions + assertField := func(fieldName string, expected, actual any) { + if expected != actual { + t.Errorf("Expected %s = %v, got %v", fieldName, expected, actual) + } + } + + assertField("Topic", *dto.Topic, *ob.Topic) + assertField("Type", *dto.Type, *ob.Type) + assertField("Text", *dto.Text, *ob.Text) + assertField("Comment", *dto.Comment, *ob.Comment) + assertField("Category", *dto.Category, *ob.Category) + assertField("Classification", *dto.Classification, *ob.Classification) + assertField("Modifications", *dto.Modifications, *ob.Modifications) + assertField("Active", *dto.Active, *ob.Active) + assertField("TextUpdatable", *dto.TextUpdatable, *ob.TextUpdatable) + assertField("Shortnames count", len(dto.Shortnames), len(ob.Shortnames)) +} +func assertObligationUpdated(t *testing.T, topic string, dto models.ObligationUpdateDTO) { + w := makeRequest("PATCH", "/obligations/"+topic, dto, true) + if w.Code != http.StatusOK { + t.Fatalf("Expected 200 OK, got %d", w.Code) + } + + var resp struct { + Status int `json:"status"` + Data []models.ObligationDTO `json:"data"` + Meta any `json:"paginationmeta"` + } + + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse update response: %v", err) + } + + if len(resp.Data) == 0 { + t.Fatal("No obligation returned in update response") + } + + ob := resp.Data[0] + + // Field-wise assertions (only check non-nil fields from dto) + assertField := func(fieldName string, expected, actual any) { + if expected != actual { + t.Errorf("Expected %s = %v, got %v", fieldName, expected, actual) + } + } + + if dto.Type != nil { + assertField("Type", *dto.Type, *ob.Type) + } + if dto.Text != nil { + assertField("Text", *dto.Text, *ob.Text) + } + if dto.Comment != nil { + assertField("Comment", *dto.Comment, *ob.Comment) + } + if dto.Category != nil { + assertField("Category", *dto.Category, *ob.Category) + } + if dto.Classification != nil { + assertField("Classification", *dto.Classification, *ob.Classification) + } + if dto.Modifications != nil { + assertField("Modifications", *dto.Modifications, *ob.Modifications) + } + if dto.Active != nil { + assertField("Active", *dto.Active, *ob.Active) + } + if dto.TextUpdatable != nil { + assertField("TextUpdatable", *dto.TextUpdatable, *ob.TextUpdatable) + } +} diff --git a/pkg/api/obligations.go b/pkg/api/obligations.go index 7e8a0209..684e7fba 100644 --- a/pkg/api/obligations.go +++ b/pkg/api/obligations.go @@ -201,6 +201,18 @@ func CreateObligation(c *gin.Context) { return errors.New("can not create obligation with same topic or text") } + if err := utils.MapLicensesToObligation(tx, &obligation, obligation.Shortnames); err != nil { + er := models.LicenseError{ + Status: http.StatusBadRequest, + Message: "Failed to associate licenses", + Error: err.Error(), + Path: c.Request.URL.Path, + Timestamp: time.Now().Format(time.RFC3339), + } + c.JSON(http.StatusBadRequest, er) + return err + } + if err := addChangelogsForObligation(tx, username, &obligation, &models.Obligation{}); err != nil { er := models.LicenseError{ Status: http.StatusBadRequest, diff --git a/pkg/models/types.go b/pkg/models/types.go index e235dbd2..75d49b06 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2023 Siemens AG // SPDX-FileContributor: Gaurav Mishra // SPDX-FileContributor: Dearsh Oberoi +// SPDX-FileContributor: 2025 Chayan Das <01chayandas@gmail.com> // // SPDX-License-Identifier: GPL-2.0-only @@ -449,6 +450,7 @@ type Obligation struct { Type *ObligationType `gorm:"foreignKey:ObligationTypeId; references:Id"` Classification *ObligationClassification `gorm:"foreignKey:ObligationClassificationId ;references:Id"` Category *string `json:"category" gorm:"default:GENERAL" enums:"DISTRIBUTION,PATENT,INTERNAL,CONTRACTUAL,EXPORT_CONTROL,GENERAL" example:"DISTRIBUTION"` + Shortnames []string `gorm:"-" json:"-"` // This field is not stored in the database, but used for JSON marshalling } func (Obligation) TableName() string { @@ -532,14 +534,6 @@ func (o *Obligation) BeforeCreate(tx *gorm.DB) (err error) { return err } - for i := 0; i < len(o.Licenses); i++ { - var license LicenseDB - if err := tx.Where(LicenseDB{Shortname: o.Licenses[i].Shortname}).First(&license).Error; err != nil { - return fmt.Errorf("license with shortname %s not found", *o.Licenses[i].Shortname) - } - o.Licenses[i] = &license - } - return nil } @@ -642,9 +636,16 @@ func (o *Obligation) MarshalJSON() ([]byte, error) { ob.Category = &defaultCategory } - for i := 0; i < len(o.Licenses); i++ { - ob.Shortnames = append(ob.Shortnames, *o.Licenses[i].Shortname) + if len(o.Shortnames) > 0 { + ob.Shortnames = append(ob.Shortnames, o.Shortnames...) + } else { + for _, lic := range o.Licenses { + if lic.Shortname != nil { + ob.Shortnames = append(ob.Shortnames, *lic.Shortname) + } + } } + return json.Marshal(ob) } @@ -658,7 +659,10 @@ func (o *Obligation) UnmarshalJSON(data []byte) error { validate := validator.New(validator.WithRequiredStructEnabled()) if err := validate.Struct(&dto); err != nil { - return fmt.Errorf("field '%s' failed validation: %s", err.(validator.ValidationErrors)[0].Field(), err.(validator.ValidationErrors)[0].Tag()) + if valErrs, ok := err.(validator.ValidationErrors); ok && len(valErrs) > 0 { + return fmt.Errorf("ObligationDTO validation failed: field '%s' violated '%s'", valErrs[0].Field(), valErrs[0].Tag()) + } + return err } o.Topic = dto.Topic @@ -681,12 +685,7 @@ func (o *Obligation) UnmarshalJSON(data []byte) error { } } - o.Licenses = []*LicenseDB{} - for i := 0; i < len(dto.Shortnames); i++ { - o.Licenses = append(o.Licenses, &LicenseDB{ - Shortname: &dto.Shortnames[i], - }) - } + o.Shortnames = append(o.Shortnames, dto.Shortnames...) return nil } diff --git a/pkg/utils/util.go b/pkg/utils/util.go index b0fbc3e8..a5c2fc56 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -385,6 +385,31 @@ func PerformObligationMapActions(username string, obligation *models.Obligation, return newLicenseAssociations, errs } +// MapLicensesToObligation associates a given list of license shortnames with the provided obligation. +// It fetches each license by its shortname from the database and appends the corresponding LicenseDB +// entries to the obligation using GORM's association mode. +func MapLicensesToObligation(tx *gorm.DB, obligation *models.Obligation, shortnames []string) error { + var licensesToAssociate []models.LicenseDB + + for _, short := range shortnames { + var license models.LicenseDB + if err := tx.Where(&models.LicenseDB{Shortname: &short}).First(&license).Error; err != nil { + return fmt.Errorf("license '%s' not found: %v", short, err) + } + licensesToAssociate = append(licensesToAssociate, license) + } + + // Associate licenses + if err := tx.Session(&gorm.Session{SkipHooks: true}). + Model(obligation). + Association("Licenses"). + Append(licensesToAssociate); err != nil { + return fmt.Errorf("failed to associate licenses: %w", err) + } + + return nil +} + // createObligationMapChangelog creates the changelog for the obligation map changes. func createObligationMapChangelog(tx *gorm.DB, username string, newLicenseAssociations, oldLicenseAssociations []string, obligation *models.Obligation) error {