Files
apskel-pos-backend/internal/processor/order_paid_hook_test.go
T
2026-09-30 15:31:44 +07:00

273 lines
9.2 KiB
Go

package processor
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// These fakes embed the interface they stand for and implement only what the paths
// under test call; anything else would panic, which would show a path doing more than
// expected.
type hookOrderRepo struct {
OrderRepository
order *entities.Order
statusUpdates int
hookCallsAtPay int // hook calls seen when the status was written
hook *orderPaidHookFake
}
func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) {
o := *r.order
return &o, nil
}
func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) {
o := *r.order
return &o, nil
}
func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error {
r.statusUpdates++
r.hookCallsAtPay = len(r.hook.calls)
r.order.Status, r.order.PaymentStatus = status, payment
return nil
}
type hookPaymentRepo struct {
PaymentRepository
created []*entities.Payment
}
func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) {
return 0, nil
}
func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error {
p.ID = uuid.New()
r.created = append(r.created, p)
return nil
}
func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) {
for _, p := range r.created {
if p.ID == id {
return p, nil
}
}
return nil, nil
}
func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) {
return r.created, nil
}
type hookPaymentMethodRepo struct{}
func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) {
return &entities.PaymentMethod{ID: id}, nil
}
type hookOrderItemRepo struct{ OrderItemRepository }
func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) {
return nil, nil
}
// splitFake settles the order on the payment that covers what is left, as the real
// split bill processor does.
type splitFake struct{ settle bool }
func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) {
if f.settle {
order.PaymentStatus = entities.PaymentStatusCompleted
} else {
order.PaymentStatus = entities.PaymentStatusPartial
}
return &models.SplitBillResponse{OrderID: order.ID}, nil
}
func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
return f.split(order)
}
func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
return f.split(order)
}
type orderPaidHookFake struct {
calls []uuid.UUID
ctxs []context.Context
refunded []uuid.UUID
earned map[uuid.UUID]OrderEarned
}
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
h.calls = append(h.calls, orderID)
h.ctxs = append(h.ctxs, ctx)
}
func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
h.refunded = append(h.refunded, orderID)
}
func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
return h.earned, nil
}
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
hook := &orderPaidHookFake{}
orders := &hookOrderRepo{
order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending},
hook: hook,
}
p := &OrderProcessorImpl{
orderRepo: orders,
orderItemRepo: hookOrderItemRepo{},
paymentRepo: &hookPaymentRepo{},
paymentMethodRepo: hookPaymentMethodRepo{},
splitBillProcessor: split,
txManager: repository.NewTxManager(nil),
}
p.SetLoyalty(hook)
return p, orders, hook
}
func TestOrderPaidHook_CreatePayment(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
ctx, cancel := context.WithCancel(context.Background())
_, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000})
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction")
// The hook's context outlives the request.
cancel()
assert.NoError(t, hook.ctxs[0].Err())
}
func TestOrderPaidHook_UpdateOrder(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
assert.Equal(t, 1, orders.statusUpdates)
assert.Zero(t, orders.hookCallsAtPay)
}
func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
for _, splitType := range []string{"AMOUNT", "ITEM"} {
t.Run(splitType, func(t *testing.T) {
req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()}
p, orders, hook := newHookedOrderProcessor(splitFake{settle: false})
req.OrderID = orders.order.ID
_, err := p.SplitBill(context.Background(), req)
require.NoError(t, err)
assert.Empty(t, hook.calls, "a partial split payment does not make the order paid")
p, orders, hook = newHookedOrderProcessor(splitFake{settle: true})
req.OrderID = orders.order.ID
_, err = p.SplitBill(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
})
}
}
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
p, orders, _ := newHookedOrderProcessor(nil)
p.SetLoyalty(nil)
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
assert.NoError(t, err)
}
func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error {
r.order.Status, r.order.IsVoid = status, true
return nil
}
func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error {
r.order.RefundAmount = o.RefundAmount
return nil
}
func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error {
r.order.IsRefund = true
return nil
}
func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error {
for _, p := range r.created {
if p.ID == id {
p.RefundAmount = amount
}
}
return nil
}
func TestOrderRefundedHook(t *testing.T) {
t.Run("void", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
})
t.Run("refund order", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
orders.order.PaymentStatus = entities.PaymentStatusCompleted
amount := 25000.0
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
})
t.Run("refund payment", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
payments := p.paymentRepo.(*hookPaymentRepo)
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
payments.created = append(payments.created, payment)
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
// A refund that fails does not run the hook.
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
assert.Len(t, hook.refunded, 1)
})
}
func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) {
o := *r.order
other := entities.Order{ID: uuid.New()}
return []*entities.Order{&o, &other}, 2, nil
}
func TestOrderResponsesCarryEarnings(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}}
got, err := p.GetOrderByID(context.Background(), orders.order.ID)
require.NoError(t, err)
assert.Equal(t, int64(875), got.PointsEarned)
assert.Equal(t, int64(3), got.CoinsEarned)
list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10})
require.NoError(t, err)
require.Len(t, list.Orders, 2)
assert.Equal(t, int64(875), list.Orders[0].PointsEarned)
assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0")
// Without the loyalty program the fields stay at 0.
p.SetLoyalty(nil)
got, err = p.GetOrderByID(context.Background(), orders.order.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), got.PointsEarned)
}