From 05a797be970b81cb2c608f85bf23fe0fa3409889 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 6 May 2026 18:54:53 +0530 Subject: [PATCH 1/5] fix: returns custom error on duplicate source post request Signed-off-by: Amit Singh --- cmd/app/main.go | 5 ++--- pkg/apperrors/apperrors.go | 3 +++ pkg/conf/conf.go | 9 ++++++++- pkg/domain/claim/claim_suite_test.go | 6 +++++- pkg/domain/proof/proof_suite_test.go | 6 +++++- pkg/domain/source/source_repository_test.go | 7 +++++++ pkg/domain/source/source_service.go | 13 ++++++++++++- pkg/domain/source/source_service_test.go | 11 +++++++++++ pkg/domain/source/source_suite_test.go | 6 +++++- 9 files changed, 58 insertions(+), 8 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 236a2ab..1fbb34b 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -8,9 +8,8 @@ import ( "os" embed "source-score" - "github.com/gin-gonic/gin" "github.com/gin-contrib/cors" - "gorm.io/gorm" + "github.com/gin-gonic/gin" "source-score/pkg/api" "source-score/pkg/conf" @@ -57,7 +56,7 @@ func main() { conf.Cfg.AppUserPassword, conf.DbName, ) - dbClient := pgsql.NewClient(context.Background(), dsn, &gorm.Config{}) + dbClient := pgsql.NewClient(context.Background(), dsn, conf.GormConfig) // TODO: wrap this and call it securely dbClient.SetAutoMigration( context.Background(), diff --git a/pkg/apperrors/apperrors.go b/pkg/apperrors/apperrors.go index 71a97bc..9c6bb31 100644 --- a/pkg/apperrors/apperrors.go +++ b/pkg/apperrors/apperrors.go @@ -11,4 +11,7 @@ var ( ErrProofNotFound = errors.New("proof not found") ErrValidationLogic = errors.New("validation logic error") ErrInvalidClaimVerification = errors.New("invalid claim verification body") + ErrDuplicateSource = errors.New("source already exists") + ErrDuplicateClaim = errors.New("claim already exists") + ErrDuplicateProof = errors.New("proof already exists") ) diff --git a/pkg/conf/conf.go b/pkg/conf/conf.go index f8696b0..01c3cc7 100644 --- a/pkg/conf/conf.go +++ b/pkg/conf/conf.go @@ -5,6 +5,7 @@ import ( "os" "github.com/ilyakaznacheev/cleanenv" + "gorm.io/gorm" ) const ( @@ -19,7 +20,13 @@ type conf struct { SuperUserPassword string `env:"SUPER_USER_PASSWORD" yaml:"SUPER_USER_PASSWORD" env-required:"true"` } -var Cfg conf +var ( + Cfg conf + + GormConfig = &gorm.Config{ + TranslateError: true, + } +) func LoadConfig() { if envPath, ok := os.LookupEnv("DOTENV_PATH"); ok { diff --git a/pkg/domain/claim/claim_suite_test.go b/pkg/domain/claim/claim_suite_test.go index 4b821b1..1d3cd0a 100644 --- a/pkg/domain/claim/claim_suite_test.go +++ b/pkg/domain/claim/claim_suite_test.go @@ -3,6 +3,7 @@ package claim_test import ( "context" "source-score/pkg/api" + "source-score/pkg/conf" "source-score/pkg/db/pgsql" "source-score/pkg/domain/claim" "source-score/pkg/helpers" @@ -59,7 +60,10 @@ var ( func TestClaim(t *testing.T) { var _ = BeforeSuite(func() { - testDB, err = gorm.Open(sqlite.Open(testDBFile)) + testDB, err = gorm.Open( + sqlite.Open(testDBFile), + conf.GormConfig, + ) Expect(err).ToNot(HaveOccurred()) err = testDB.AutoMigrate(&api.Source{}, &api.Claim{}, &api.Proof{}) diff --git a/pkg/domain/proof/proof_suite_test.go b/pkg/domain/proof/proof_suite_test.go index bc12531..0011e13 100644 --- a/pkg/domain/proof/proof_suite_test.go +++ b/pkg/domain/proof/proof_suite_test.go @@ -3,6 +3,7 @@ package proof_test import ( "context" "source-score/pkg/api" + "source-score/pkg/conf" "source-score/pkg/db/pgsql" "source-score/pkg/domain/proof" "source-score/pkg/helpers" @@ -55,7 +56,10 @@ var ( func TestProof(t *testing.T) { var _ = BeforeSuite(func() { - testDB, err = gorm.Open(sqlite.Open(testDBFile)) + testDB, err = gorm.Open( + sqlite.Open(testDBFile), + conf.GormConfig, + ) Expect(err).ToNot(HaveOccurred()) err = testDB.AutoMigrate(&api.Source{}, &api.Claim{}, &api.Proof{}) diff --git a/pkg/domain/source/source_repository_test.go b/pkg/domain/source/source_repository_test.go index 8157263..9e4d73f 100644 --- a/pkg/domain/source/source_repository_test.go +++ b/pkg/domain/source/source_repository_test.go @@ -175,6 +175,13 @@ var _ = Describe("Source model repository layer unit tests", Ordered, func() { }) Context("Validation tests", func() { + When("Creating a source that already exists", func() { + It("Should return a duplicate record error", func() { + _, err := sourceRepo.PostSource(context.TODO(), &sampleSourceInput2) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, gorm.ErrDuplicatedKey)).To(BeTrue()) + }) + }) When("Patching a source that does not exist", func() { It("Should return record not found error", func() { name := "Twice Updated Sample Source 1" diff --git a/pkg/domain/source/source_service.go b/pkg/domain/source/source_service.go index 3971d17..d0f3790 100644 --- a/pkg/domain/source/source_service.go +++ b/pkg/domain/source/source_service.go @@ -94,7 +94,18 @@ func (svc *sourceService) PostSource(ctx context.Context, sourceInput *api.Sourc combinedErrs = strings.TrimSpace(combinedErrs) return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidSource, combinedErrs) } - return svc.sourceRepo.PostSource(ctx, sourceInput) + + digest, err := svc.sourceRepo.PostSource(ctx, sourceInput) + if err != nil { + switch { + case errors.Is(err, gorm.ErrDuplicatedKey): + return "", apperrors.ErrDuplicateSource + default: + return "", err + } + } + + return digest, nil } func (svc *sourceService) PatchSourceByUriDigest(ctx context.Context, sourceInput *api.SourcePatchInput, uriDigest string) error { diff --git a/pkg/domain/source/source_service_test.go b/pkg/domain/source/source_service_test.go index a0bd87e..b2f5f0d 100644 --- a/pkg/domain/source/source_service_test.go +++ b/pkg/domain/source/source_service_test.go @@ -160,6 +160,17 @@ var _ = Describe("Source model service layer unit test", Ordered, func() { }) Context("Source POST validation tests", func() { + When("Creating a source that already exists", func() { + It("Should return duplicate source error", func() { + callCount := fakeSourceRepo.PostSourceCallCount() + fakeSourceRepo.PostSourceReturnsOnCall(callCount, "", gorm.ErrDuplicatedKey) + + _, err := sourceSvc.PostSource(context.TODO(), &sampleSourceInput1) + Expect(err).ToNot(BeNil()) + Expect(errors.Is(err, apperrors.ErrDuplicateSource)).To(BeTrue()) + }) + }) + When("Creating a source with tags containing spaces", func() { It("Should return invalid source error with nospace validation message", func() { invalidInput := &api.SourceInput{ diff --git a/pkg/domain/source/source_suite_test.go b/pkg/domain/source/source_suite_test.go index 3ac3261..5c31eb3 100644 --- a/pkg/domain/source/source_suite_test.go +++ b/pkg/domain/source/source_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "log" "source-score/pkg/api" + "source-score/pkg/conf" "source-score/pkg/db/pgsql" "source-score/pkg/domain/claim/claimfakes" "source-score/pkg/domain/source" @@ -42,7 +43,10 @@ var ( func TestSource(t *testing.T) { var _ = BeforeSuite(func() { - testDB, err = gorm.Open(sqlite.Open(testDBFile)) + testDB, err = gorm.Open( + sqlite.Open(testDBFile), + conf.GormConfig, + ) Expect(err).ToNot(HaveOccurred()) err = testDB.AutoMigrate(&api.Source{}, &api.SourceInput{}) From 89ff03ba772dc7bc747439f17d45280f509c1419 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 6 May 2026 19:27:47 +0530 Subject: [PATCH 2/5] fix: returns custom error on duplicate claim post request Signed-off-by: Amit Singh --- pkg/domain/claim/claim_repository_test.go | 15 +++++++++++++++ pkg/domain/claim/claim_service.go | 11 ++++++++++- pkg/domain/claim/claim_service_test.go | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/domain/claim/claim_repository_test.go b/pkg/domain/claim/claim_repository_test.go index d459f25..3bc7b11 100644 --- a/pkg/domain/claim/claim_repository_test.go +++ b/pkg/domain/claim/claim_repository_test.go @@ -307,6 +307,21 @@ var _ = Describe("Claim repository layer unit tests", func() { }) Context("Validation tests", func() { + When("Creating a claim that already exists", func() { + It("Should return gorm.ErrDuplicatedKey", func() { + input := api.ClaimInput{ + SourceUriDigest: sampleClaim2.SourceUriDigest, + Summary: sampleClaim2.Summary, + Title: sampleClaim2.Title, + Uri: sampleClaim2.Uri, + } + + _, err := claimRepo.PostClaim(context.TODO(), &input) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, gorm.ErrDuplicatedKey)).To(BeTrue()) + }) + }) + When("Retrieving a non-existent claim by uri digest", func() { It("Should return gorm.ErrRecordNotFound", func() { _, err := claimRepo.GetClaimByUriDigest(context.TODO(), "doesnotexist") diff --git a/pkg/domain/claim/claim_service.go b/pkg/domain/claim/claim_service.go index df95985..34c905d 100644 --- a/pkg/domain/claim/claim_service.go +++ b/pkg/domain/claim/claim_service.go @@ -117,7 +117,16 @@ func (svc *claimService) PostClaim(ctx context.Context, claimInput *api.ClaimInp return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidClaim, combinedErrs) } - return svc.claimRepo.PostClaim(ctx, claimInput) + digest, err := svc.claimRepo.PostClaim(ctx, claimInput) + if err != nil { + switch { + case errors.Is(err, gorm.ErrDuplicatedKey): + return "", apperrors.ErrDuplicateClaim + default: + return "", err + } + } + return digest, nil } func (svc *claimService) VerifyClaimByUriDigest(ctx context.Context, claimVerification *api.ClaimVerification, uriDigest string) error { diff --git a/pkg/domain/claim/claim_service_test.go b/pkg/domain/claim/claim_service_test.go index ce7c1f1..6160941 100644 --- a/pkg/domain/claim/claim_service_test.go +++ b/pkg/domain/claim/claim_service_test.go @@ -262,6 +262,24 @@ var _ = Describe("Claim model service layer unit tests", Ordered, func() { }) Context("Validation tests", func() { + When("Posting a claim that already exists", func() { + It("Should return ErrDuplicateClaim", func() { + callCount := fakeClaimRepo.PostClaimCallCount() + fakeClaimRepo.PostClaimReturnsOnCall(callCount, "", gorm.ErrDuplicatedKey) + + input := api.ClaimInput{ + SourceUriDigest: sampleClaim2.SourceUriDigest, + Summary: sampleClaim2.Summary, + Title: sampleClaim2.Title, + Uri: sampleClaim2.Uri, + } + + _, err := claimSvc.PostClaim(context.TODO(), &input) + Expect(err).ToNot(BeNil()) + Expect(errors.Is(err, apperrors.ErrDuplicateClaim)).To(BeTrue()) + }) + }) + When("Posting a claim with empty source uri digest", func() { It("Should return ErrInvalidClaim", func() { input := api.ClaimInput{ From 214b57010201becf762c50f11069e02134c7ca50 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 6 May 2026 20:36:21 +0530 Subject: [PATCH 3/5] fix: returns custom error on duplicate proof post request Signed-off-by: Amit Singh --- pkg/domain/proof/proof_repository_test.go | 14 ++++++++++++++ pkg/domain/proof/proof_service.go | 12 +++++++++++- pkg/domain/proof/proof_service_test.go | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/domain/proof/proof_repository_test.go b/pkg/domain/proof/proof_repository_test.go index 334a42b..a32cb4a 100644 --- a/pkg/domain/proof/proof_repository_test.go +++ b/pkg/domain/proof/proof_repository_test.go @@ -107,6 +107,20 @@ var _ = Describe("Proof repository layer unit tests", Ordered, func() { }) Context("Validation tests", Ordered, func() { + When("Creating a proof that already exists", func() { + It("Should return gorm.ErrDuplicatedKey", func() { + proofInput := &api.ProofInput{ + ClaimUriDigest: sampleProof2.ClaimUriDigest, + Uri: sampleProof2.Uri, + SupportsClaim: &sampleProof2.SupportsClaim, + ReviewedBy: sampleProof2.ReviewedBy, + } + _, err := proofRepo.PostProof(context.TODO(), proofInput) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, gorm.ErrDuplicatedKey)).To(BeTrue()) + }) + }) + When("Retrieving a non-existent proof by uri digest", func() { It("Should return gorm.ErrRecordNotFound", func() { _, err := proofRepo.GetProofByUriDigest(context.TODO(), "doesnotexist") diff --git a/pkg/domain/proof/proof_service.go b/pkg/domain/proof/proof_service.go index 8397aec..78f0f61 100644 --- a/pkg/domain/proof/proof_service.go +++ b/pkg/domain/proof/proof_service.go @@ -106,7 +106,17 @@ func (svc *proofService) PostProof(ctx context.Context, proofInput *api.ProofInp return "", fmt.Errorf("%w: %s", apperrors.ErrInvalidProof, combinedErrs) } - return svc.proofRepo.PostProof(ctx, proofInput) + digest, err := svc.proofRepo.PostProof(ctx, proofInput) + if err != nil { + switch { + case errors.Is(err, gorm.ErrDuplicatedKey): + return "", apperrors.ErrDuplicateProof + default: + return "", err + } + } + + return digest, nil } func (svc *proofService) GetProofsByClaims(ctx context.Context) (map[string][]api.Proof, error) { diff --git a/pkg/domain/proof/proof_service_test.go b/pkg/domain/proof/proof_service_test.go index 96ff858..8f0f677 100644 --- a/pkg/domain/proof/proof_service_test.go +++ b/pkg/domain/proof/proof_service_test.go @@ -164,6 +164,24 @@ var _ = Describe("Proof model service layer unit tests", Ordered, func() { }) Context("Proof POST validation tests", func() { + When("Posting a proof that already exists", func() { + It("Should return ErrDuplicateProof", func() { + callCount := fakeProofRepo.PostProofCallCount() + fakeProofRepo.PostProofReturnsOnCall(callCount, "", gorm.ErrDuplicatedKey) + + input := api.ProofInput{ + ClaimUriDigest: sampleProof2.ClaimUriDigest, + ReviewedBy: sampleProof2.ReviewedBy, + SupportsClaim: &sampleProof2.SupportsClaim, + Uri: sampleProof2.Uri, + } + + _, err := proofSvc.PostProof(context.TODO(), &input) + Expect(err).ToNot(BeNil()) + Expect(errors.Is(err, apperrors.ErrDuplicateProof)).To(BeTrue()) + }) + }) + When("Posting a proof with space in ClaimUriDigest", func() { It("Should return invalid proof error with nospace validation message", func() { supports := true From be11741856f382576b65fcabd2c0ec057d4aa14c Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 6 May 2026 20:46:01 +0530 Subject: [PATCH 4/5] fix: updates handler to handle duplicate model post requests Signed-off-by: Amit Singh --- pkg/handlers/claim.go | 5 +++++ pkg/handlers/proof.go | 2 ++ pkg/handlers/source.go | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/pkg/handlers/claim.go b/pkg/handlers/claim.go index 0d3115f..d742635 100644 --- a/pkg/handlers/claim.go +++ b/pkg/handlers/claim.go @@ -62,6 +62,11 @@ func (ch *ClaimHandler) PostClaim(ctx *gin.Context) { http.StatusBadRequest, gin.H{"error": err.Error()}, ) + case errors.Is(err, apperrors.ErrDuplicateClaim): + ctx.JSON( + http.StatusConflict, + gin.H{"error": err.Error()}, + ) default: slog.Error("failed to create claim", "error", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) diff --git a/pkg/handlers/proof.go b/pkg/handlers/proof.go index 32769ad..fabdbcb 100644 --- a/pkg/handlers/proof.go +++ b/pkg/handlers/proof.go @@ -44,6 +44,8 @@ func (ph *ProofHandler) PostProof(ctx *gin.Context) { switch { case errors.Is(err, apperrors.ErrInvalidProof): ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, apperrors.ErrDuplicateProof): + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: slog.Error("failed to create proof", "error", err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) diff --git a/pkg/handlers/source.go b/pkg/handlers/source.go index 4c88ff6..3488805 100644 --- a/pkg/handlers/source.go +++ b/pkg/handlers/source.go @@ -112,6 +112,11 @@ func (sh *SourceHandler) PostSource(ctx *gin.Context) { http.StatusBadRequest, gin.H{"error": err.Error()}, ) + case errors.Is(err, apperrors.ErrDuplicateSource): + ctx.JSON( + http.StatusConflict, + gin.H{"error": err.Error()}, + ) default: slog.Error("failed to create source", "error", err) ctx.JSON( From 27246b4bcfd25e33151b12eb71cf1c821d31ee0f Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 6 May 2026 21:04:45 +0530 Subject: [PATCH 5/5] test: adds acceptance tests for duplicated model post requests Signed-off-by: Amit Singh --- acceptance/claim_test.go | 23 +++++++++++++++++++++++ acceptance/proof_test.go | 23 +++++++++++++++++++++++ acceptance/source_test.go | 19 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/acceptance/claim_test.go b/acceptance/claim_test.go index ccf4121..865390f 100644 --- a/acceptance/claim_test.go +++ b/acceptance/claim_test.go @@ -471,6 +471,29 @@ var _ = Describe("Claim model tests", func() { }) Context("Validation tests", func() { + When("posting a claim that already exists", func() { + It("should return 409 Conflict with error message", func() { + dupClaim := api.ClaimInput{ + SourceUriDigest: uriDigest3, + Summary: sampleClaim2.Summary, + Title: sampleClaim2.Title, + Uri: sampleClaim2.Uri, + } + body, err := json.Marshal(dupClaim) + Expect(err).To(BeNil()) + + resp, err := doRequest(http.MethodPost, endpoint, body) + Expect(err).To(BeNil()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusConflict)) + + var errResp map[string]any + err = json.NewDecoder(resp.Body).Decode(&errResp) + Expect(err).To(BeNil()) + Expect(errResp["error"]).ToNot(BeNil()) + Expect(strings.Contains(strings.ToLower(errResp["error"].(string)), "claim already exists")).To(BeTrue()) + }) + }) When("POST request with empty sourceUriDigest is sent", func() { It("should return 400 with validation error", func() { claim := api.ClaimInput{ diff --git a/acceptance/proof_test.go b/acceptance/proof_test.go index d973ac4..c71fdab 100644 --- a/acceptance/proof_test.go +++ b/acceptance/proof_test.go @@ -211,6 +211,29 @@ var _ = Describe("Proof model tests", func() { }) Context("Validation tests", func() { + When("posting a proof that already exists", func() { + It("should return 409 Conflict with error message", func() { + dupProof := api.ProofInput{ + ClaimUriDigest: claim3Digest, + ReviewedBy: sampleProof2.ReviewedBy, + SupportsClaim: &sampleProof2.SupportsClaim, + Uri: sampleProof2.Uri, + } + body, err := json.Marshal(dupProof) + Expect(err).To(BeNil()) + + resp, err := doRequest(http.MethodPost, endpoint, body) + Expect(err).To(BeNil()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusConflict)) + + var errResp map[string]any + err = json.NewDecoder(resp.Body).Decode(&errResp) + Expect(err).To(BeNil()) + Expect(errResp["error"]).ToNot(BeNil()) + Expect(strings.Contains(strings.ToLower(errResp["error"].(string)), "proof already exists")).To(BeTrue()) + }) + }) When("POST request with space in ClaimUriDigest is sent", func() { It("should return 400 with validation error mentioning nospace", func() { supports := true diff --git a/acceptance/source_test.go b/acceptance/source_test.go index 450dea2..6456b42 100644 --- a/acceptance/source_test.go +++ b/acceptance/source_test.go @@ -343,6 +343,25 @@ var _ = Describe("Source model tests", func() { }) Context("Validation tests", func() { + When("posting a source that already exists", func() { + It("should return 409 Conflict with error message", func() { + // Try to create sourceInput2 again (already created above) + dupBody, err := json.Marshal(sourceInput2) + Expect(err).To(BeNil()) + + resp, err := doRequest(http.MethodPost, endpoint, dupBody) + Expect(err).To(BeNil()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusConflict)) + + var errResp map[string]any + err = json.NewDecoder(resp.Body).Decode(&errResp) + Expect(err).To(BeNil()) + Expect(errResp["error"]).ToNot(BeNil()) + Expect(strings.Contains(strings.ToLower(errResp["error"].(string)), "source already exists")).To(BeTrue()) + }) + }) + When("GET request is sent for an invalid source", func() { It("should return 404 error", func() { srcUrl, err := url.JoinPath(endpoint, "invalid-digest")