feat(sketch): add sketch in service and repository

ref: #23
master
Niclas Thobaben 2024-03-02 19:30:18 +01:00
parent 71192c3cfc
commit 693748ee37
8 changed files with 307 additions and 3 deletions

View File

@ -0,0 +1,52 @@
package sketchbook
import (
"context"
"git.l--n.de/nclazz/soundwerft/internal/app/types"
"git.l--n.de/nclazz/soundwerft/pkg/errx"
)
type CreateSketchCommand struct {
Sketchbook string
Title string
Description string
}
func (s *Service) CreateSketch(ctx context.Context, cmd CreateSketchCommand) (*Sketch, error) {
var input struct {
sketchbook types.ID
title Title
description Description
}
{
var errs errx.ErrMap
var err error
input.sketchbook, err = types.ParseId(cmd.Sketchbook)
if err != nil {
errs.Set("sketchbook", err)
}
input.title, err = ParseTitle(cmd.Title)
if err != nil {
errs.Set("title", err)
}
input.description, err = ParseDescription(cmd.Description)
if err != nil {
errs.Set("description", err)
}
if errs != nil {
return nil, errs
}
}
sketchbook, err := s.repo.GetByID(ctx, input.sketchbook)
if err != nil {
return nil, err
}
if sketchbook == nil {
return nil, ErrSketchbookNotFound
}
sketch := NewSketch(sketchbook.ID, input.title, input.description)
if err := s.repo.AddSketch(ctx, sketch); err != nil {
return nil, err
}
return sketch, nil
}

View File

@ -0,0 +1,72 @@
package sketchbook
import (
"context"
"git.l--n.de/nclazz/soundwerft/internal/app/types"
"git.l--n.de/nclazz/soundwerft/internal/app/user"
"git.l--n.de/nclazz/soundwerft/pkg/errx"
"git.l--n.de/nclazz/soundwerft/pkg/utils"
"github.com/stretchr/testify/assert"
"testing"
)
func TestService_CreateSketch(t *testing.T) {
t.Parallel()
ctx := context.TODO()
repo := NewInMemoryRepository()
userRepo := user.NewInMemoryRepository()
svc := NewService(repo, userRepo)
sketchbook := &Sketchbook{
ID: types.GenerateId(),
Owner: types.GenerateId(),
Audit: types.NewAudit(),
}
repo.sketchbooks[sketchbook.ID] = sketchbook
t.Run("returns validation errors for invalid input", func(t *testing.T) {
repo.Reset()
userRepo.Reset()
sk, err := svc.CreateSketch(ctx, CreateSketchCommand{
Sketchbook: "invalid",
Title: "",
Description: utils.RandomString(DescriptionMaxLength + 1),
})
assert.Nil(t, sk)
var errs errx.ErrMap
if assert.ErrorAs(t, err, &errs) {
assert.Error(t, errs.Get("sketchbook"))
assert.Error(t, errs.Get("title"))
assert.Error(t, errs.Get("description"))
}
})
t.Run("returns ErrSketchbookNotFound for non existent sketchbook", func(t *testing.T) {
repo.Reset()
userRepo.Reset()
sk, err := svc.CreateSketch(ctx, CreateSketchCommand{
Sketchbook: types.GenerateId().String(),
Title: "My Sketch",
Description: "The sketch description",
})
assert.Nil(t, sk)
assert.ErrorIs(t, err, ErrSketchbookNotFound)
})
t.Run("creates sketch and adds it to repository", func(t *testing.T) {
repo.Reset()
userRepo.Reset()
repo.sketchbooks[sketchbook.ID] = sketchbook
sk, err := svc.CreateSketch(ctx, CreateSketchCommand{
Sketchbook: sketchbook.ID.String(),
Title: "My Sketch",
Description: "Sketch description",
})
assert.NoError(t, err)
if assert.NotNil(t, sk) {
assert.False(t, sk.ID.IsNil())
assert.Equal(t, Title("My Sketch"), sk.Title)
assert.Equal(t, Description("Sketch description"), sk.Description)
}
assert.Len(t, repo.sketches, 1)
})
}

View File

@ -8,4 +8,8 @@ import (
var (
ErrSketchbookError = errors.New("sketchbook")
ErrOwnerAlreadyHasSketchbook = fmt.Errorf("%w: owner already has sketchbook", ErrSketchbookError)
ErrTitleTooLong = fmt.Errorf("%w: title too long", ErrSketchbookError)
ErrEmptyTitle = fmt.Errorf("%w: title can not be empty", ErrSketchbookError)
ErrDescriptionTooLong = fmt.Errorf("%w: description too long", ErrSketchbookError)
ErrSketchbookNotFound = fmt.Errorf("%w: sketchbook not found", ErrSketchbookError)
)

View File

@ -6,10 +6,12 @@ import (
)
type Reader interface {
GetByID(ctx context.Context, id types.ID) (*Sketchbook, error)
GetByOwnerID(ctx context.Context, owner types.ID) (*Sketchbook, error)
}
type Writer interface {
AddSketchbook(ctx context.Context, sk *Sketchbook) error
AddSketch(ctx context.Context, sketch *Sketch) error
}
type ReadWriter interface {
Reader
@ -20,6 +22,7 @@ var _ ReadWriter = &InMemoryRepository{}
type InMemoryRepository struct {
sketchbooks map[types.ID]*Sketchbook
sketches map[types.ID]*Sketch
}
func NewInMemoryRepository(initialSketchbooks ...Sketchbook) *InMemoryRepository {
@ -46,6 +49,17 @@ func (i *InMemoryRepository) AddSketchbook(ctx context.Context, sk *Sketchbook)
return nil
}
func (i *InMemoryRepository) GetByID(ctx context.Context, id types.ID) (*Sketchbook, error) {
sk := i.sketchbooks[id]
return sk, nil
}
func (i *InMemoryRepository) AddSketch(ctx context.Context, sketch *Sketch) error {
i.sketches[sketch.ID] = sketch
return nil
}
func (i *InMemoryRepository) Reset() {
i.sketchbooks = make(map[types.ID]*Sketchbook)
i.sketches = make(map[types.ID]*Sketch)
}

View File

@ -1,6 +1,15 @@
package sketchbook
import "git.l--n.de/nclazz/soundwerft/internal/app/types"
import (
"git.l--n.de/nclazz/soundwerft/internal/app/types"
"strings"
"unicode/utf8"
)
const (
TitleMaxLength = 128
DescriptionMaxLength = 4069
)
type Sketchbook struct {
ID types.ID
@ -15,3 +24,53 @@ func New(owner types.ID) *Sketchbook {
Audit: types.NewAudit(),
}
}
type Sketch struct {
ID types.ID
Sketchbook types.ID
Title Title
Description Description
types.Audit
}
func NewSketch(sketchbook types.ID, title Title, description Description) *Sketch {
return &Sketch{
ID: types.GenerateId(),
Sketchbook: sketchbook,
Title: title,
Description: description,
Audit: types.NewAudit(),
}
}
type Title string
func ParseTitle(title string) (Title, error) {
trimmed := strings.TrimSpace(title)
length := utf8.RuneCountInString(trimmed)
if length > TitleMaxLength {
return "", ErrTitleTooLong
}
if length < 1 {
return "", ErrEmptyTitle
}
return Title(trimmed), nil
}
func (t Title) String() string {
return string(t)
}
type Description string
func ParseDescription(description string) (Description, error) {
trimmed := strings.TrimSpace(description)
if utf8.RuneCountInString(trimmed) > DescriptionMaxLength {
return "", ErrDescriptionTooLong
}
return Description(trimmed), nil
}
func (d Description) String() string {
return string(d)
}

View File

@ -14,7 +14,6 @@ CREATE INDEX IF NOT EXISTS users_name ON users (name);
CREATE INDEX IF NOT EXISTS users_email ON users (email);
-- sketchbooks
CREATE TABLE IF NOT EXISTS sketchbooks
(
id CHAR(36) NOT NULL PRIMARY KEY,
@ -22,3 +21,15 @@ CREATE TABLE IF NOT EXISTS sketchbooks
created_at TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sketches
(
id CHAR(36) NOT NULL PRIMARY KEY,
sketchbook CHAR(36) NOT NULL
REFERENCES sketchbooks(id)
ON DELETE CASCADE,
title VARCHAR(128) NOT NULL,
description TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP
);

View File

@ -45,6 +45,29 @@ func (s *SketchbookRepository) AddSketchbook(ctx context.Context, sk *sketchbook
return err
}
func (s *SketchbookRepository) GetByID(ctx context.Context, id types.ID) (*sketchbook.Sketchbook, error) {
query := `SELECT * FROM sketchbooks where id = $1`
var dto SketchbookDTO
err := s.dbx.Get(&dto, query, id.String())
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return dto.ToSketchbook(), nil
}
func (s *SketchbookRepository) AddSketch(ctx context.Context, sketch *sketchbook.Sketch) error {
if sketch == nil {
return repository.ErrNoDataProvided
}
query := `INSERT INTO sketches VALUES(:id,:sketchbook,:title,:description,:created_at,:updated_at)`
dto := NewSketchDTO(sketch)
_, err := s.dbx.NamedExec(query, dto)
return err
}
type SketchbookDTO struct {
ID string `db:"id"`
Owner string `db:"owner"`
@ -71,3 +94,36 @@ func (s SketchbookDTO) ToSketchbook() *sketchbook.Sketchbook {
},
}
}
type SketchDTO struct {
ID string `db:"id"`
Sketchbook string `db:"sketchbook"`
Title string `db:"title"`
Description string `db:"description"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func NewSketchDTO(sk *sketchbook.Sketch) SketchDTO {
return SketchDTO{
ID: sk.ID.String(),
Sketchbook: sk.Sketchbook.String(),
Title: sk.Title.String(),
Description: sk.Description.String(),
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
}
}
func (s SketchDTO) ToSketch() *sketchbook.Sketch {
return &sketchbook.Sketch{
ID: types.ID(uuid.MustParse(s.ID)),
Sketchbook: types.ID(uuid.MustParse(s.Sketchbook)),
Title: sketchbook.Title(s.Title),
Description: sketchbook.Description(s.Description),
Audit: types.Audit{
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
},
}
}

View File

@ -16,7 +16,10 @@ func TestSketchbookRepository(t *testing.T, create func() sketchbook.ReadWriter)
testAddSketchbook(t, repo, ctx)
})
t.Run("GetSketchbookByOwnerID", func(t *testing.T) {
testGetSketchbookByOwnerID(t, repo, ctx)
})
t.Run("AddSketch", func(t *testing.T) {
testAddSketch(t, repo, ctx)
})
}
@ -77,3 +80,36 @@ func testGetSketchbookByOwnerID(t *testing.T, repo sketchbook.ReadWriter, ctx co
})
}
}
func testAddSketch(t *testing.T, repo sketchbook.ReadWriter, ctx context.Context) {
sk := &sketchbook.Sketchbook{
ID: types.GenerateId(),
Owner: types.GenerateId(),
}
if !assert.NoError(t, repo.AddSketchbook(ctx, sk)) {
t.Fatalf("failed to add test sketchbook")
}
tests := []struct {
name string
sketch *sketchbook.Sketch
wantErr error
}{
{name: "no data",
wantErr: repository.ErrNoDataProvided},
{name: "for existent sketchbook",
sketch: &sketchbook.Sketch{
ID: types.GenerateId(),
Sketchbook: sk.ID,
Title: "AddSketchTest",
Description: "Created from integration test",
Audit: types.NewAudit(),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := repo.AddSketch(ctx, test.sketch)
assert.ErrorIs(t, err, test.wantErr)
})
}
}