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