Initial commit
This commit is contained in:
commit
89053153cc
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,89 @@
|
||||
// Package items is the reference vertical slice: model, repository
|
||||
// interface, Bun implementation, and service. To add a new resource, copy
|
||||
// this package's shape and register its routes in internal/api.
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// Status is the lifecycle state of an item, stored as text.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusArchived Status = "archived"
|
||||
)
|
||||
|
||||
// Valid reports whether s is a known status value.
|
||||
func (s Status) Valid() bool {
|
||||
return s == StatusActive || s == StatusArchived
|
||||
}
|
||||
|
||||
// Item is the persisted model. Struct tags are Bun's mapping; json tags
|
||||
// define the wire format (camelCase, matching the frontend).
|
||||
type Item struct {
|
||||
bun.BaseModel `bun:"table:items,alias:i"`
|
||||
|
||||
ID int64 `bun:"id,pk,autoincrement" json:"id"`
|
||||
Title string `bun:"title,notnull" json:"title"`
|
||||
Description string `bun:"description,nullzero" json:"description"`
|
||||
Status Status `bun:"status,notnull,default:'active'" json:"status"`
|
||||
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"createdAt"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// BeforeAppendModel stamps timestamps on INSERT and UPDATE queries.
|
||||
// Postgres stores microsecond UTC; truncate + UTC so values set in Go
|
||||
// (PATCH responses) match what round-trips through the database.
|
||||
func (i *Item) BeforeAppendModel(_ context.Context, query bun.Query) error {
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
switch query.(type) {
|
||||
case *bun.InsertQuery:
|
||||
i.CreatedAt, i.UpdatedAt = now, now
|
||||
case *bun.UpdateQuery:
|
||||
i.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter narrows and pages List queries. Apply normalizeFilter (via the
|
||||
// service) before handing one to a repository.
|
||||
type Filter struct {
|
||||
Status Status
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// List result paging defaults, applied by normalizeFilter.
|
||||
const (
|
||||
DefaultLimit = 50
|
||||
MaxLimit = 100
|
||||
)
|
||||
|
||||
// Repository is the persistence contract for items. The service depends on
|
||||
// this interface — not on Bun — so tests use in-memory fakes and other
|
||||
// backends remain possible.
|
||||
type Repository interface {
|
||||
List(ctx context.Context, f Filter) ([]Item, error)
|
||||
Get(ctx context.Context, id int64) (Item, error)
|
||||
Create(ctx context.Context, item *Item) error
|
||||
Update(ctx context.Context, item *Item) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// ErrNotFound is returned by repositories and services when an item does
|
||||
// not exist. Handlers map it to 404.
|
||||
var ErrNotFound = errors.New("item not found")
|
||||
|
||||
// ValidationError reports an invalid field value. Handlers map it to 400.
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
|
||||
@@ -0,0 +1,81 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// BunRepository is the Postgres implementation of Repository. It is the
|
||||
// only file in the package that speaks Bun/SQL.
|
||||
type BunRepository struct {
|
||||
db *bun.DB
|
||||
}
|
||||
|
||||
// NewBunRepository wraps a *bun.DB opened by internal/database.
|
||||
func NewBunRepository(db *bun.DB) *BunRepository {
|
||||
return &BunRepository{db: db}
|
||||
}
|
||||
|
||||
// List returns items newest-first, honoring the (already normalized) filter.
|
||||
// An empty result set encodes as [] on the wire, not null.
|
||||
func (r *BunRepository) List(ctx context.Context, f Filter) ([]Item, error) {
|
||||
var out []Item
|
||||
q := r.db.NewSelect().Model(&out)
|
||||
if f.Status != "" {
|
||||
q = q.Where("i.status = ?", f.Status)
|
||||
}
|
||||
if err := q.
|
||||
Order("i.created_at DESC", "i.id DESC").
|
||||
Limit(f.Limit).
|
||||
Offset(f.Offset).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Item{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Get fetches a single item by id, mapping missing rows to ErrNotFound.
|
||||
func (r *BunRepository) Get(ctx context.Context, id int64) (Item, error) {
|
||||
var item Item
|
||||
err := r.db.NewSelect().Model(&item).Where("i.id = ?", id).Scan(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// Create inserts item and reads back the generated columns.
|
||||
func (r *BunRepository) Create(ctx context.Context, item *Item) error {
|
||||
return r.db.NewInsert().Model(item).Returning("*").Scan(ctx, item)
|
||||
}
|
||||
|
||||
// Update persists item (matched by primary key); ErrNotFound when the row
|
||||
// is gone.
|
||||
func (r *BunRepository) Update(ctx context.Context, item *Item) error {
|
||||
res, err := r.db.NewUpdate().Model(item).WherePK().Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an item by id; ErrNotFound when the row is gone.
|
||||
func (r *BunRepository) Delete(ctx context.Context, id int64) error {
|
||||
res, err := r.db.NewDelete().Model((*Item)(nil)).Where("i.id = ?", id).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Field length limits enforced on input.
|
||||
const (
|
||||
maxTitleLen = 200
|
||||
maxDescriptionLen = 2000
|
||||
)
|
||||
|
||||
// Service holds the business rules for items. It depends only on the
|
||||
// Repository interface; main wires the Bun implementation in.
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
// NewService returns a Service over the given repository.
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// CreateInput is the request body for creating an item.
|
||||
type CreateInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
|
||||
// UpdateInput patches only provided fields (nil = leave untouched), which
|
||||
// is why every field is a pointer.
|
||||
type UpdateInput struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *Status `json:"status"`
|
||||
}
|
||||
|
||||
// List validates the filter and delegates to the repository.
|
||||
func (s *Service) List(ctx context.Context, f Filter) ([]Item, error) {
|
||||
f, err := normalizeFilter(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.List(ctx, f)
|
||||
}
|
||||
|
||||
// Get returns one item.
|
||||
func (s *Service) Get(ctx context.Context, id int64) (Item, error) {
|
||||
return s.repo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Create validates input and stores a new item.
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Item, error) {
|
||||
item := Item{
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
}
|
||||
if item.Title == "" {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "required"}
|
||||
}
|
||||
if len(item.Title) > maxTitleLen {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
|
||||
}
|
||||
if len(item.Description) > maxDescriptionLen {
|
||||
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
|
||||
}
|
||||
|
||||
switch in.Status {
|
||||
case "":
|
||||
item.Status = StatusActive
|
||||
case StatusActive, StatusArchived:
|
||||
item.Status = in.Status
|
||||
default:
|
||||
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, &item); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch to an existing item.
|
||||
func (s *Service) Update(ctx context.Context, id int64, in UpdateInput) (Item, error) {
|
||||
if in.Title == nil && in.Description == nil && in.Status == nil {
|
||||
return Item{}, &ValidationError{Field: "body", Reason: "at least one field must be set"}
|
||||
}
|
||||
|
||||
item, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
|
||||
if in.Title != nil {
|
||||
item.Title = strings.TrimSpace(*in.Title)
|
||||
if item.Title == "" {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "required"}
|
||||
}
|
||||
if len(item.Title) > maxTitleLen {
|
||||
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
|
||||
}
|
||||
}
|
||||
if in.Description != nil {
|
||||
item.Description = strings.TrimSpace(*in.Description)
|
||||
if len(item.Description) > maxDescriptionLen {
|
||||
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
|
||||
}
|
||||
}
|
||||
if in.Status != nil {
|
||||
if !in.Status.Valid() {
|
||||
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
item.Status = *in.Status
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, &item); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Delete removes an item.
|
||||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// normalizeFilter applies paging defaults and validates the status filter.
|
||||
func normalizeFilter(f Filter) (Filter, error) {
|
||||
switch f.Status {
|
||||
case "":
|
||||
case StatusActive, StatusArchived:
|
||||
default:
|
||||
return Filter{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
|
||||
}
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = DefaultLimit
|
||||
}
|
||||
if f.Limit > MaxLimit {
|
||||
f.Limit = MaxLimit
|
||||
}
|
||||
if f.Offset < 0 {
|
||||
f.Offset = 0
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user