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