Refactoring validation to belong to model

This commit is contained in:
2026-03-19 00:44:37 +00:00
parent a33cedf09e
commit de6c9d6ae5
6 changed files with 224 additions and 57 deletions

View File

@@ -19,3 +19,22 @@ type Note struct {
// Content is the actual text content of the note. // Content is the actual text content of the note.
Content string `json:"content"` Content string `json:"content"`
} }
func (n *Note) Validate() error {
errs := []error{}
if n.Title == "" {
errs = append(errs, &ErrEmptyNoteTitle{})
}
if n.Content == "" {
errs = append(errs, &ErrEmptyNoteContent{})
}
if len(errs) != 0 {
return &ErrNoteIncomplete{why: errs}
}
if titleLen := len(n.Title); titleLen > NoteTitleMaxLength {
return &ErrNoteTitleOverflow{
length: titleLen,
}
}
return nil
}

View File

@@ -0,0 +1,42 @@
package models
import (
"fmt"
"strings"
)
type ErrEmptyNoteContent struct{}
type ErrEmptyNoteTitle struct{}
type ErrNoteIncomplete struct {
why []error
}
type ErrNoteTitleOverflow struct {
length int
}
func (e *ErrEmptyNoteContent) Error() string {
return "content cannot be empty"
}
func (e *ErrEmptyNoteTitle) Error() string {
return "title cannot be empty"
}
func (e *ErrNoteIncomplete) Error() string {
if len(e.why) == 0 {
panic("ok so if we use this we need to know why it is incomplete")
}
s := make([]string, len(e.why))
for i, err := range e.why {
s[i] = err.Error()
}
return "note incomplete: " + strings.Join(s, " & ")
}
func (e *ErrNoteIncomplete) Unwrap() []error {
return e.why
}
func (e *ErrNoteTitleOverflow) Error() string {
return fmt.Sprintf("title max length %d, %d provided", NoteTitleMaxLength, e.length)
}

View File

@@ -0,0 +1,146 @@
package models_test
import (
"errors"
"fmt"
"math/rand/v2"
"reflect"
"strings"
"testing"
"time"
"git.hrafn.xyz/aether/notes/internal/models"
)
func TestContentCompletionNoteValidation(t *testing.T) {
testcases := []struct {
name string
note models.Note
expectedError bool
expectedReasons []error
}{
{
name: "Completely Empty",
note: models.Note{
Title: "",
Content: "",
ID: 1,
LastUpdate: time.Now(),
},
expectedError: true,
expectedReasons: []error{&models.ErrEmptyNoteContent{}, &models.ErrEmptyNoteTitle{}},
},
{
name: "Missing title",
note: models.Note{
Title: "",
Content: "Here we go!",
ID: 1,
LastUpdate: time.Now(),
},
expectedError: true,
expectedReasons: []error{&models.ErrEmptyNoteTitle{}},
},
{
name: "Missing content",
note: models.Note{
Title: "I am not content with this content",
Content: "",
ID: 1,
LastUpdate: time.Now(),
},
expectedError: true,
expectedReasons: []error{&models.ErrEmptyNoteContent{}},
},
{
name: "Good note",
note: models.Note{
Title: "I am content with this content",
Content: "this content",
ID: 1,
LastUpdate: time.Now(),
},
expectedError: false,
expectedReasons: []error{},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
err := tc.note.Validate()
// Early out when not needing to test because we're not expecting an error and we didn't get one
if !tc.expectedError && err == nil {
fmt.Printf("Test case '%s' passed.\n", tc.name)
return
}
var expected *models.ErrNoteIncomplete
if !errors.As(err, &expected) && tc.expectedError {
t.Errorf("expected *ErrNoteIncomplete, got %T", err)
}
for _, reason := range tc.expectedReasons {
if !errors.Is(err, reason) {
t.Errorf(`expected error to contain "%v" but it was missing`, reason)
}
}
rv := reflect.ValueOf(err).Elem()
whyField := rv.FieldByName("why")
if whyField.Len() != len(tc.expectedReasons) {
t.Errorf("expected %d errors, got %d", len(tc.expectedReasons), whyField.Len())
}
fmt.Printf("Test case '%s' passed.\n", tc.name)
})
}
}
func TestTitleLengthNoteValidation(t *testing.T) {
testcases := []struct {
name string
title string
expectedError bool
}{
{
name: "Max valid title",
title: strings.Repeat("0", models.NoteTitleMaxLength),
expectedError: false,
},
{
name: "Just over max valid title",
title: strings.Repeat("1", models.NoteTitleMaxLength + 1),
expectedError: true,
},
{
name: "Double max valid title",
title: strings.Repeat("Bd", models.NoteTitleMaxLength),
expectedError: true,
},
{
name: "Random overmax valid title length",
title: strings.Repeat("1", models.NoteTitleMaxLength + rand.IntN(150)),
expectedError: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
note := models.Note{
Title: tc.title,
Content: "Perfectly valid content " + tc.title,
LastUpdate: time.Now(),
ID: 1,
}
err := note.Validate()
if !tc.expectedError {
if err != nil {
t.Errorf("expecting no error, got %v", err)
}
} else {
var expected *models.ErrNoteTitleOverflow
if !errors.As(err, &expected) {
t.Errorf("expected *ErrNoteTitleOverflow, got %T", err)
}
}
fmt.Printf("Test case '%s' passed.\n", tc.name)
})
}
}

View File

@@ -1,35 +0,0 @@
package repository
import (
"fmt"
"git.hrafn.xyz/aether/notes/internal/models"
)
type EmptyContent struct {}
type EmptyTitle struct {}
type NoOP struct {}
type EmptyUpdate struct{}
type TitleOverflow struct {
length int
}
func (e *EmptyContent) Error() string {
return "content cannot be empty"
}
func (e *EmptyTitle) Error() string {
return "title cannot be empty"
}
func (e *NoOP) Error() string {
return "no operation required"
}
func (e *EmptyUpdate) Error() string {
return "update cannot be empty"
}
func (e *TitleOverflow) Error() string {
return fmt.Sprintf("title max length %d, %d provided", models.NoteTitleMaxLength, e.length)
}

View File

@@ -29,7 +29,7 @@ func (r *NoteRepository) CreateNote(ctx context.Context, title string, content s
Content: content, Content: content,
LastUpdate: time.Now(), LastUpdate: time.Now(),
} }
if err := isNoteVaid(note); err != nil { if err := note.Validate(); err != nil {
return models.Note{}, err return models.Note{}, err
} }
return r.store.SaveNote(ctx, note) return r.store.SaveNote(ctx, note)
@@ -48,7 +48,7 @@ func (r *NoteRepository) ListNotes(ctx context.Context) ([]models.Note, error) {
// UpdateNote updates the content of an existing note. // UpdateNote updates the content of an existing note.
func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpdate) (models.Note, error) { func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpdate) (models.Note, error) {
if update.Content == "" && update.Title == "" { if update.Content == "" && update.Title == "" {
return models.Note{}, &EmptyUpdate{} return models.Note{}, &ErrEmptyUpdate{}
} }
note, err := r.store.GetNoteByID(ctx, id) note, err := r.store.GetNoteByID(ctx, id)
if err != nil { if err != nil {
@@ -56,7 +56,7 @@ func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpda
} }
if update.Content == note.Content && update.Title == note.Title { if update.Content == note.Content && update.Title == note.Title {
return models.Note{}, &NoOP{} return models.Note{}, &ErrNoOP{}
} }
if update.Content != "" { if update.Content != "" {
@@ -65,8 +65,8 @@ func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpda
if update.Title != "" { if update.Title != "" {
note.Title = update.Title note.Title = update.Title
} }
if err := isNoteVaid(note); err != nil { if err := note.Validate(); err != nil {
return models.Note{}, err return models.Note{}, err
} }
@@ -78,20 +78,3 @@ func (r *NoteRepository) UpdateNote(ctx context.Context, id int, update NoteUpda
func (r *NoteRepository) DeleteNote(ctx context.Context, id int) error { func (r *NoteRepository) DeleteNote(ctx context.Context, id int) error {
return r.store.DeleteNoteByID(ctx, id) return r.store.DeleteNoteByID(ctx, id)
} }
func isNoteVaid(note models.Note) error {
if note.Title == "" {
return &EmptyTitle{}
}
if note.Content == "" {
return &EmptyContent{}
}
if titleLen := len(note.Title); titleLen > models.NoteTitleMaxLength {
return &TitleOverflow{
length: titleLen,
}
}
return nil
}

View File

@@ -0,0 +1,12 @@
package repository
type ErrNoOP struct{}
type ErrEmptyUpdate struct{}
func (e *ErrNoOP) Error() string {
return "no operation required"
}
func (e *ErrEmptyUpdate) Error() string {
return "update cannot be empty"
}