first
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package orders_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/orders"
|
||||
"backend/internal/modules/pricing"
|
||||
"backend/internal/modules/products"
|
||||
"backend/internal/modules/units"
|
||||
)
|
||||
|
||||
// fakeRepository is an in-memory orders.Repository used to unit-test
|
||||
// orders.Service without a real database.
|
||||
type fakeRepository struct {
|
||||
orders map[uuid.UUID]*orders.Order
|
||||
items map[uuid.UUID][]*orders.OrderItem
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{orders: map[uuid.UUID]*orders.Order{}, items: map[uuid.UUID][]*orders.OrderItem{}}
|
||||
}
|
||||
|
||||
func (r *fakeRepository) Create(_ context.Context, order *orders.Order, items []*orders.OrderItem) error {
|
||||
cp := *order
|
||||
r.orders[order.ID] = &cp
|
||||
for _, item := range items {
|
||||
item.OrderID = order.ID
|
||||
}
|
||||
r.items[order.ID] = items
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) FindByID(_ context.Context, id uuid.UUID) (*orders.Order, []*orders.OrderItem, error) {
|
||||
o, ok := r.orders[id]
|
||||
if !ok {
|
||||
return nil, nil, orders.ErrNotFound
|
||||
}
|
||||
return o, r.items[id], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) List(_ context.Context, status string) ([]*orders.Order, error) {
|
||||
var list []*orders.Order
|
||||
for _, o := range r.orders {
|
||||
if status == "" || o.Status == status {
|
||||
list = append(list, o)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) UpdateStatus(_ context.Context, id uuid.UUID, status string) (*orders.Order, error) {
|
||||
o, ok := r.orders[id]
|
||||
if !ok {
|
||||
return nil, orders.ErrNotFound
|
||||
}
|
||||
o.Status = status
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// fakeProducts / fakeUnits / fakePrices back the small consumer-defined
|
||||
// interfaces orders.Service depends on.
|
||||
type fakeProducts struct {
|
||||
byID map[uuid.UUID]*products.Product
|
||||
}
|
||||
|
||||
func (f *fakeProducts) Get(_ context.Context, id uuid.UUID) (*products.Product, error) {
|
||||
p, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, products.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type fakeUnits struct {
|
||||
byID map[uuid.UUID]*units.Unit
|
||||
}
|
||||
|
||||
func (f *fakeUnits) Get(_ context.Context, id uuid.UUID) (*units.Unit, error) {
|
||||
u, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, units.ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
type fakePrices struct {
|
||||
byID map[uuid.UUID]*pricing.PriceTier
|
||||
}
|
||||
|
||||
func (f *fakePrices) PriceForQuantity(_ context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error) {
|
||||
t, ok := f.byID[tierID]
|
||||
if !ok {
|
||||
return 0, nil, pricing.ErrNotFound
|
||||
}
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
return t.PriceCents * multiplier, t, nil
|
||||
}
|
||||
|
||||
type fakeNotifier struct {
|
||||
newOrderCalls []string
|
||||
statusChangeCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) NotifyNewOrder(_ context.Context, summary string) {
|
||||
f.newOrderCalls = append(f.newOrderCalls, summary)
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) NotifyOrderStatusChange(_ context.Context, summary string) {
|
||||
f.statusChangeCalls = append(f.statusChangeCalls, summary)
|
||||
}
|
||||
|
||||
// testFixture wires a full set of fakes with one product/unit/tier, ready
|
||||
// for a service under test.
|
||||
type testFixture struct {
|
||||
repo *fakeRepository
|
||||
products *fakeProducts
|
||||
units *fakeUnits
|
||||
prices *fakePrices
|
||||
notifier *fakeNotifier
|
||||
|
||||
productID uuid.UUID
|
||||
unitID uuid.UUID
|
||||
tierID uuid.UUID
|
||||
}
|
||||
|
||||
func newFixture() *testFixture {
|
||||
productID := uuid.New()
|
||||
unitID := uuid.New()
|
||||
tierID := uuid.New()
|
||||
|
||||
return &testFixture{
|
||||
repo: newFakeRepository(),
|
||||
products: &fakeProducts{byID: map[uuid.UUID]*products.Product{
|
||||
productID: {ID: productID, Name: "Honey jar", Slug: "honey-jar", IsActive: true},
|
||||
}},
|
||||
units: &fakeUnits{byID: map[uuid.UUID]*units.Unit{
|
||||
unitID: {ID: unitID, Name: "Kilogram", Symbol: "kg"},
|
||||
}},
|
||||
prices: &fakePrices{byID: map[uuid.UUID]*pricing.PriceTier{
|
||||
tierID: {ID: tierID, ProductID: productID, UnitID: unitID, Quantity: 5, PriceCents: 4500},
|
||||
}},
|
||||
notifier: &fakeNotifier{},
|
||||
productID: productID,
|
||||
unitID: unitID,
|
||||
tierID: tierID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testFixture) service() *orders.Service {
|
||||
return orders.NewService(f.repo, f.products, f.units, f.prices, f.notifier)
|
||||
}
|
||||
|
||||
func TestService_Create_ComputesTotalAndNotifies(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
order, items, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{
|
||||
{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 2},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if order.TotalCents != 9000 {
|
||||
t.Fatalf("Create() order.TotalCents = %d, want 9000 (2x 4500)", order.TotalCents)
|
||||
}
|
||||
if order.Status != orders.StatusPending {
|
||||
t.Fatalf("Create() order.Status = %q, want %q", order.Status, orders.StatusPending)
|
||||
}
|
||||
if len(items) != 1 || items[0].ProductName != "Honey jar" || items[0].UnitSymbol != "kg" {
|
||||
t.Fatalf("Create() items = %+v, want snapshot of product name/unit symbol", items)
|
||||
}
|
||||
if len(f.notifier.newOrderCalls) != 1 {
|
||||
t.Fatalf("Create() triggered %d new-order notifications, want exactly 1", len(f.notifier.newOrderCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_RejectsEmptyOrder(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
_, _, err := svc.Create(context.Background(), orders.CreateInput{CustomerName: "Alice", CustomerEmail: "a@example.com"})
|
||||
if !errors.Is(err, orders.ErrEmptyOrder) {
|
||||
t.Fatalf("Create() error = %v, want ErrEmptyOrder", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Create_RejectsTierProductMismatch(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
otherProduct := uuid.New()
|
||||
f.products.byID[otherProduct] = &products.Product{ID: otherProduct, Name: "Other", Slug: "other", IsActive: true}
|
||||
|
||||
_, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{
|
||||
// tierID actually belongs to f.productID, not otherProduct.
|
||||
{ProductID: otherProduct, PriceTierID: f.tierID, Multiplier: 1},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, orders.ErrProductMismatch) {
|
||||
t.Fatalf("Create() error = %v, want ErrProductMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateStatus_ValidatesStatus(t *testing.T) {
|
||||
f := newFixture()
|
||||
svc := f.service()
|
||||
|
||||
order, _, err := svc.Create(context.Background(), orders.CreateInput{
|
||||
CustomerName: "Alice",
|
||||
CustomerEmail: "alice@example.com",
|
||||
Items: []orders.ItemInput{{ProductID: f.productID, PriceTierID: f.tierID, Multiplier: 1}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.UpdateStatus(context.Background(), order.ID, "not-a-real-status"); !errors.Is(err, orders.ErrInvalidStatus) {
|
||||
t.Fatalf("UpdateStatus() error = %v, want ErrInvalidStatus", err)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateStatus(context.Background(), order.ID, orders.StatusConfirmed)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateStatus() error = %v", err)
|
||||
}
|
||||
if updated.Status != orders.StatusConfirmed {
|
||||
t.Fatalf("UpdateStatus() status = %q, want %q", updated.Status, orders.StatusConfirmed)
|
||||
}
|
||||
if len(f.notifier.statusChangeCalls) != 1 {
|
||||
t.Fatalf("UpdateStatus() triggered %d status-change notifications, want exactly 1", len(f.notifier.statusChangeCalls))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user