232 lines
5.7 KiB
Go
232 lines
5.7 KiB
Go
package items_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/fullstack-template/internal/items"
|
|
)
|
|
|
|
// fakeRepo is an in-memory items.Repository — the point of the Repository
|
|
// interface is that these tests never touch a database.
|
|
type fakeRepo struct {
|
|
mu sync.Mutex
|
|
nextID int64
|
|
byID map[int64]items.Item
|
|
}
|
|
|
|
func newFakeRepo() *fakeRepo {
|
|
return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}}
|
|
}
|
|
|
|
func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
ids := make([]int64, 0, len(f.byID))
|
|
for id := range f.byID {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
|
|
out := []items.Item{}
|
|
for _, id := range ids {
|
|
item := f.byID[id]
|
|
if fl.Status != "" && item.Status != fl.Status {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if fl.Offset < len(out) {
|
|
out = out[fl.Offset:]
|
|
} else {
|
|
out = []items.Item{}
|
|
}
|
|
if fl.Limit < len(out) {
|
|
out = out[:fl.Limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
item, ok := f.byID[id]
|
|
if !ok {
|
|
return items.Item{}, items.ErrNotFound
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (f *fakeRepo) Create(_ context.Context, item *items.Item) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
item.ID = f.nextID
|
|
f.nextID++
|
|
now := time.Now()
|
|
item.CreatedAt, item.UpdatedAt = now, now
|
|
f.byID[item.ID] = *item
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRepo) Update(_ context.Context, item *items.Item) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.byID[item.ID]; !ok {
|
|
return items.ErrNotFound
|
|
}
|
|
item.UpdatedAt = time.Now()
|
|
f.byID[item.ID] = *item
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRepo) Delete(_ context.Context, id int64) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.byID[id]; !ok {
|
|
return items.ErrNotFound
|
|
}
|
|
delete(f.byID, id)
|
|
return nil
|
|
}
|
|
|
|
func newService() (*items.Service, *fakeRepo) {
|
|
repo := newFakeRepo()
|
|
return items.NewService(repo), repo
|
|
}
|
|
|
|
func TestCreateValidation(t *testing.T) {
|
|
svc, _ := newService()
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in items.CreateInput
|
|
want string // expected ValidationError.Field
|
|
}{
|
|
{"empty title", items.CreateInput{Title: " "}, "title"},
|
|
{"long title", items.CreateInput{Title: strings.Repeat("x", 201)}, "title"},
|
|
{"bad status", items.CreateInput{Title: "ok", Status: "nope"}, "status"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := svc.Create(ctx, tt.in)
|
|
var vErr *items.ValidationError
|
|
if !errors.As(err, &vErr) {
|
|
t.Fatalf("got error %v, want *items.ValidationError", err)
|
|
}
|
|
if vErr.Field != tt.want {
|
|
t.Errorf("field = %q, want %q", vErr.Field, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateDefaults(t *testing.T) {
|
|
svc, _ := newService()
|
|
|
|
item, err := svc.Create(context.Background(), items.CreateInput{Title: " hello "})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if item.Title != "hello" {
|
|
t.Errorf("title = %q, want trimmed %q", item.Title, "hello")
|
|
}
|
|
if item.Status != items.StatusActive {
|
|
t.Errorf("status = %q, want default %q", item.Status, items.StatusActive)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePatchesOnlyProvidedFields(t *testing.T) {
|
|
svc, _ := newService()
|
|
ctx := context.Background()
|
|
|
|
created, err := svc.Create(ctx, items.CreateInput{Title: "original", Description: "keep me"})
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
|
|
archived := items.StatusArchived
|
|
updated, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: &archived})
|
|
if err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
if updated.Status != items.StatusArchived {
|
|
t.Errorf("status = %q, want %q", updated.Status, items.StatusArchived)
|
|
}
|
|
if updated.Title != "original" || updated.Description != "keep me" {
|
|
t.Errorf("untouched fields changed: %+v", updated)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRequiresField(t *testing.T) {
|
|
svc, _ := newService()
|
|
ctx := context.Background()
|
|
|
|
created, _ := svc.Create(ctx, items.CreateInput{Title: "x"})
|
|
_, err := svc.Update(ctx, created.ID, items.UpdateInput{})
|
|
var vErr *items.ValidationError
|
|
if !errors.As(err, &vErr) || vErr.Field != "body" {
|
|
t.Fatalf("got %v, want validation error on body", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateNotFound(t *testing.T) {
|
|
svc, _ := newService()
|
|
title := "x"
|
|
_, err := svc.Update(context.Background(), 999, items.UpdateInput{Title: &title})
|
|
if !errors.Is(err, items.ErrNotFound) {
|
|
t.Fatalf("got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestListFilterAndPaging(t *testing.T) {
|
|
svc, _ := newService()
|
|
ctx := context.Background()
|
|
|
|
svc.Create(ctx, items.CreateInput{Title: "one"})
|
|
svc.Create(ctx, items.CreateInput{Title: "two", Status: items.StatusArchived})
|
|
svc.Create(ctx, items.CreateInput{Title: "three", Status: items.StatusArchived})
|
|
|
|
got, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
|
|
if _, err := svc.List(ctx, items.Filter{Status: "bogus"}); err == nil {
|
|
t.Error("List with bogus status: want validation error")
|
|
}
|
|
|
|
got, err = svc.List(ctx, items.Filter{Limit: 1})
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(got) != items.DefaultLimit && len(got) != 1 {
|
|
t.Fatalf("len = %d, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
func TestDeleteFlow(t *testing.T) {
|
|
svc, _ := newService()
|
|
ctx := context.Background()
|
|
|
|
item, _ := svc.Create(ctx, items.CreateInput{Title: "gone"})
|
|
if err := svc.Delete(ctx, item.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if _, err := svc.Get(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
|
|
t.Fatalf("Get after delete: got %v, want ErrNotFound", err)
|
|
}
|
|
if err := svc.Delete(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
|
|
t.Fatalf("second delete: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|