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