Files
gibbz/backend/internal/items/service_test.go
T
2026-09-22 08:41:20 +02:00

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)
}
}