diff --git a/apis.rest b/apis.rest index b48f01a..1c99398 100644 --- a/apis.rest +++ b/apis.rest @@ -1,16 +1,64 @@ @base_url = http://localhost:8080 - -### Create a contact +### Create Contact – Valid Request POST {{base_url}}/api/contacts Content-Type: application/json { "first_name": "Rakesh", - "last_name": "K", - "email": "rakesh@codersgyan.com", + "last_name": "K", + "email": "rakesh@codersgyan.com", + "phone": "+919876543210", "tags": [ { "text": "purchased:mern" }, - {"text": "purchased:devops"} + { "text": "purchased:devops" } ] -} \ No newline at end of file +} + +> {% + client.test("Request executed successfully", function() { + client.assert(response.status === 201, "Expected 201 Created"); + client.assert(response.body.includes('"id":'), "Response should contain created contact ID"); + }); +%} + +### Create Contact – Validation Errors (400) +POST {{base_url}}/api/contacts +Content-Type: application/json + +{ + "first_name": "", + "last_name": "D", + "email": "not-an-email", + "phone": "12345", + "tags": [] +} + +> {% + client.test("Returns 400 with validation errors", function() { + client.assert(response.status === 400, "Expected 400 Bad Request"); + const body = response.body; + client.assert(body.includes("first_name") && body.includes("required"), "Missing first_name required error"); + client.assert(body.includes("invalid email format"), "Missing email format error"); + client.assert(body.includes("phone"), "Missing phone validation error"); + }); +%} + +### POST /api/contacts – Validation Rules +# Required fields: +# - first_name → max 100 characters +# - last_name → max 100 characters +# - email → valid format, max 255 characters +# +# Optional: +# - phone → if provided, must be valid international number +# → automatically normalized to E.164 (e.g. +919876543210) +# - tags → array of { "text": "your-tag" } +# +# Error response format (400): +# { +# "errors": [ +# { "field": "email", "message": "required" }, +# { "field": "email", "message": "invalid email format" } +# ] +# } diff --git a/internal/contact/handler.go b/internal/contact/handler.go index 7aa31c9..2c96c1f 100644 --- a/internal/contact/handler.go +++ b/internal/contact/handler.go @@ -15,19 +15,38 @@ func NewHandler(repo *Repository) *Handler { } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var payload ContactCreateRequest + var repoTags []Tag - // todo: request validation - var contactBody Contact - - if err := json.NewDecoder(r.Body).Decode(&contactBody); err != nil { + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { fmt.Println(err) http.Error(w, "invalid json", http.StatusBadRequest) return } + if errs := payload.Validate(); len(errs) > 0 { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string][]ValidationError{"errors": errs}) + return + } + // conversion from request type to model type + for _, t := range payload.Tags { + repoTags = append(repoTags, Tag{ + Text: t.Text, + }) + } + + contact := Contact{ + FirstName: payload.FirstName, + LastName: payload.LastName, + Email: payload.Email, + Phone: payload.Phone, + Tags: repoTags, + } + w.Header().Set("Content-Type", "application/json") - createdId, err := h.repo.CreateContactOrUpsertTags(&contactBody) + createdId, err := h.repo.CreateContactOrUpsertTags(&contact) if err != nil { resp := map[string]string{ "message": "Internal server error", diff --git a/internal/contact/handler_test.go b/internal/contact/handler_test.go new file mode 100644 index 0000000..daabcbf --- /dev/null +++ b/internal/contact/handler_test.go @@ -0,0 +1,103 @@ +package contact + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/codersgyan/camp/internal/database" +) + +func TestCreateContactHandler_Validation(t *testing.T) { + // Real in-memory DB + migrations — exactly like production + db, _ := database.Connect(":memory:") + if err := database.RunMigration(db); err != nil { + t.Fatalf("migration failed: %v", err) + } + defer db.Close() + + repo := NewRepository(db) + handler := NewHandler(repo) + + tests := []struct { + name string + payload map[string]any + wantStatus int + wantBody string // substring check + }{ + // Success case — we only check that we get 201 and a valid JSON with "id" + { + name: "valid request → 201 with id", + payload: map[string]any{ + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + }, + wantStatus: http.StatusCreated, + wantBody: `"id":`, + }, + { + name: "valid with phone → 201", + payload: map[string]any{ + "first_name": "Alice", + "last_name": "Smith", + "email": "alice@test.com", + "phone": "+911472583695", + }, + wantStatus: http.StatusCreated, + wantBody: `"id":`, + }, + // Validation failures + { + name: "missing email → 400", + payload: map[string]any{ + "first_name": "John", + "last_name": "Doe", + }, + wantStatus: http.StatusBadRequest, + wantBody: `"field":"email","message":"required"`, + }, + { + name: "invalid email → 400", + payload: map[string]any{ + "first_name": "John", + "last_name": "Doe", + "email": "bademail", + }, + wantStatus: http.StatusBadRequest, + wantBody: `"invalid email format"`, + }, + { + name: "invalid phone → 400", + payload: map[string]any{ + "first_name": "A", + "last_name": "B", + "email": "a@b.com", + "phone": "12345", + }, + wantStatus: http.StatusBadRequest, + wantBody: `"field":"phone"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.payload) + req := httptest.NewRequest("POST", "/api/contacts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.Create(w, req) + + if w.Code != tt.wantStatus { + t.Fatalf("status: got %d, want %d\nbody: %s", w.Code, tt.wantStatus, w.Body.String()) + } + + if tt.wantBody != "" && !bytes.Contains(w.Body.Bytes(), []byte(tt.wantBody)) { + t.Errorf("body missing expected substring %q\ngot: %s", tt.wantBody, w.Body.String()) + } + }) + } +} diff --git a/internal/contact/types.go b/internal/contact/types.go new file mode 100644 index 0000000..4b5011a --- /dev/null +++ b/internal/contact/types.go @@ -0,0 +1,17 @@ +package contact + +// Payload struture for contact create +type ContactCreateRequest struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Phone string `json:"phone,omitempty"` + Tags []struct { + Text string `json:"text"` + } `json:"tags,omitempty"` +} + +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} diff --git a/internal/contact/validator.go b/internal/contact/validator.go new file mode 100644 index 0000000..fb3b1d3 --- /dev/null +++ b/internal/contact/validator.go @@ -0,0 +1,81 @@ +package contact + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + phoneRegex = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) + // allowed max lengths for fields + emailMaxLength = 255 + // used for both first name and last name + NameMaxLength = 100 +) + +func (r *ContactCreateRequest) Validate() []ValidationError { + var errs []ValidationError + + r.trimFields() + + errs = append(errs, r.validateFirstName()...) + errs = append(errs, r.validateLastName()...) + errs = append(errs, r.validateEmail()...) + errs = append(errs, r.validatePhone()...) + + return errs +} + +// Private helper methods +func (r *ContactCreateRequest) trimFields() { + r.FirstName = strings.TrimSpace(r.FirstName) + r.LastName = strings.TrimSpace(r.LastName) + r.Email = strings.TrimSpace(r.Email) + r.Phone = strings.TrimSpace(r.Phone) +} + +func (r *ContactCreateRequest) validateFirstName() []ValidationError { + if r.FirstName == "" { + return []ValidationError{{Field: "first_name", Message: "required"}} + } + if len(r.FirstName) > NameMaxLength { + return []ValidationError{{Field: "first_name", Message: fmt.Sprintf("max %d characters", NameMaxLength)}} + } + return nil +} + +func (r *ContactCreateRequest) validateLastName() []ValidationError { + if r.LastName == "" { + return []ValidationError{{Field: "last_name", Message: "required"}} + } + if len(r.LastName) > NameMaxLength { + return []ValidationError{{Field: "last_name", Message: fmt.Sprintf("max %d characters", NameMaxLength)}} + } + return nil +} + +func (r *ContactCreateRequest) validateEmail() []ValidationError { + if r.Email == "" { + return []ValidationError{{Field: "email", Message: "required"}} + } + if len(r.Email) > emailMaxLength || !emailRegex.MatchString(r.Email) { + return []ValidationError{{Field: "email", Message: "invalid email format"}} + } + return nil +} + +func (r *ContactCreateRequest) validatePhone() []ValidationError { + if r.Phone == "" { + // optional field + return nil + } + r.Phone = strings.TrimSpace(r.Phone) + + if !phoneRegex.MatchString(r.Phone) { + return []ValidationError{{Field: "phone", Message: "must be valid E.164 format (e.g. +919876543210)"}} + } + + return nil +} diff --git a/internal/contact/validator_test.go b/internal/contact/validator_test.go new file mode 100644 index 0000000..6781bef --- /dev/null +++ b/internal/contact/validator_test.go @@ -0,0 +1,124 @@ +package contact + +import ( + "fmt" + "testing" +) + +func TestContactCreateRequest_Validate(t *testing.T) { + t.Run("valid minimal request", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "John", + LastName: "Doe", + Email: "john@example.com", + } + + errs := req.Validate() + if len(errs) != 0 { + t.Fatalf("expected no errors, got %d: %+v", len(errs), errs) + } + }) + + t.Run("valid phone passes lightweight check", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "A", LastName: "B", Email: "a@b.com", + Phone: "+919876543210", + } + errs := req.Validate() + if len(errs) != 0 { + t.Fatalf("valid E.164 phone failed: %+v", errs) + } + }) + t.Run("missing first_name", func(t *testing.T) { + req := ContactCreateRequest{LastName: "Doe", Email: "x@x.com"} + errs := req.Validate() + if len(errs) != 1 || errs[0].Field != "first_name" { + t.Fatalf("expected first_name required error, got %+v", errs) + } + }) + + t.Run("missing last_name", func(t *testing.T) { + req := ContactCreateRequest{FirstName: "John", Email: "x@x.com"} + errs := req.Validate() + if len(errs) != 1 || errs[0].Field != "last_name" { + t.Fatalf("expected first_name required error, got %+v", errs) + } + }) + + t.Run("invalid email", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "John", + LastName: "Doe", + Email: "this-is-not-an-email", + } + errs := req.Validate() + if len(errs) != 1 || errs[0].Field != "email" { + t.Fatalf("expected email error, got %+v", errs) + } + }) + + t.Run("invalid phone", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "John", + LastName: "Doe", + Email: "ok@x.com", + Phone: "12345", + } + errs := req.Validate() + if len(errs) != 1 || errs[0].Field != "phone" { + t.Fatalf("expected phone error, got %+v", errs) + } + }) + + t.Run("first_name too long uses dynamic message", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: stringWithLength(NameMaxLength + 1), + LastName: "Doe", + Email: "x@x.com", + } + errs := req.Validate() + if len(errs) != 1 || errs[0].Message != fmt.Sprintf("max %d characters", NameMaxLength) { + t.Fatalf("wrong dynamic message: %+v", errs[0]) + } + }) + + t.Run("last_name too long uses dynamic message", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "John", + LastName: stringWithLength(NameMaxLength + 1), + Email: "x@x.com", + } + errs := req.Validate() + if len(errs) != 1 || errs[0].Message != fmt.Sprintf("max %d characters", NameMaxLength) { + t.Fatalf("wrong dynamic message: %+v", errs[0]) + } + }) + + t.Run("valid request with tags", func(t *testing.T) { + req := ContactCreateRequest{ + FirstName: "Rakesh", + LastName: "K", + Email: "rakesh@codersgyan.com", + Tags: []struct { + Text string `json:"text"` + }{ + {Text: "purchased:mern"}, + {Text: "purchased:devops"}, + }, + } + errs := req.Validate() + + if len(errs) != 0 { + t.Fatalf("expected no errors with valid tags: %+v", errs) + } + }) + +} + +func stringWithLength(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = 'a' + } + return string(b) +}