Initial commit

This commit is contained in:
mats committed 2026-09-22 08:41:20 +02:00
commit 89053153cc
8152 files changed
+2086461

No files matched your search

+89
View File
@@ -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 }
+81
View File
@@ -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
}
+147
View File
@@ -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
}
+231
View File
@@ -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)
}
}