Files
go-admin/example/app-order/service/order_test.go
zhangwenjian adf617f5d0 feat(example): hand-write the order service and api layers
No generic CRUD action anywhere: real business - a cross-table order
placement, a payment transition - is what the contract surface has to
carry, and the actions cover only the single-table case that a real
application outgrows immediately.

The transaction is Orm.Transaction(), not the Begin/defer shape that
app/admin/service/sys_role.go and three other files use. That shape
commits a half-written transaction when the body panics, because the
deferred check reads err, which a panic leaves nil.

The payment transition guards concurrency through the update itself -
WHERE status = 'pending' plus RowsAffected - rather than a read followed
by a write.

The tests cover both rollback paths, because they fail differently: a
mid-transaction error returns, a panic unwinds - and the second is what
tells Orm.Transaction() apart from the shape it replaces. The concurrency
test pins the pool to one writer so sqlite's own single-writer semantics
cannot stand in for the guard being tested. The data-scope tests assert
the fail-closed direction too: an unrecognised scope must return no rows
rather than every row.

Claude-Session: https://claude.ai/code/session_01HPTAw8b8tAdFNFn8rKdPYx
2026-09-05 10:27:41 +08:00

353 lines
11 KiB
Go

package service
import (
"errors"
"sync"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/actions"
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
"github.com/go-admin-team/example-app-order/models"
orderdto "github.com/go-admin-team/example-app-order/service/dto"
)
// testDB returns a fresh, isolated in-memory sqlite database with
// app_order/app_order_item created, following the same
// glebarez/sqlite-and-no-build-tag setup core's own contract package tests
// use (see sdk/contract/actions/permission_test.go and
// sdk/contract/seed/seed_test.go).
func testDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.Order{}, &models.OrderItem{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
return db
}
// enableDataPermission flips on the switch actions.Permission checks before
// applying any data-scope filtering at all, restoring the previous value
// after the test - the same pattern
// sdk/contract/actions/permission_test.go uses.
func enableDataPermission(t *testing.T) {
t.Helper()
previous := config.ApplicationConfig.EnableDP
config.ApplicationConfig.EnableDP = true
t.Cleanup(func() { config.ApplicationConfig.EnableDP = previous })
}
func newOrderService(t *testing.T, db *gorm.DB) *Order {
t.Helper()
return &Order{Service: coreservice.Service{Orm: db}}
}
// -- cross-table transaction ------------------------------------------------
func TestCreate_CommitsOrderAndItemsTogether(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 2, PriceCents: 500},
{ProductName: "gadget", Quantity: 1, PriceCents: 1200},
}}
order, err := s.Create(req, 42)
if err != nil {
t.Fatalf("Create: %v", err)
}
if order.TotalCents != 2*500+1200 {
t.Errorf("TotalCents = %d, want %d", order.TotalCents, 2*500+1200)
}
if order.Status != models.StatusPending {
t.Errorf("Status = %q, want pending", order.Status)
}
if order.CreateBy != 42 || order.UpdateBy != 42 {
t.Errorf("CreateBy/UpdateBy = %d/%d, want 42/42", order.CreateBy, order.UpdateBy)
}
var itemCount int64
db.Model(&models.OrderItem{}).Where("order_id = ?", order.Id).Count(&itemCount)
if itemCount != 2 {
t.Errorf("persisted %d items, want 2", itemCount)
}
}
func TestCreate_EmptyItemsReturnsErrorAndWritesNothing(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
_, err := s.Create(&orderdto.OrderCreateReq{}, 1)
if !errors.Is(err, ErrOrderEmpty) {
t.Fatalf("got error %v, want ErrOrderEmpty", err)
}
var count int64
db.Model(&models.Order{}).Count(&count)
if count != 0 {
t.Errorf("an order was written despite the empty-items error")
}
}
// A mid-transaction failure must roll back everything written before it in
// the same transaction, including the parent row. The duplicate product
// name is what forces a real, DB-enforced constraint violation on the
// second item's insert - see OrderItem's doc comment.
func TestCreate_MidTransactionFailureRollsBackEverything(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
req := &orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 1, PriceCents: 100},
{ProductName: "widget", Quantity: 1, PriceCents: 100}, // duplicate: violates uk_app_order_item_product
}}
_, err := s.Create(req, 1)
if err == nil {
t.Fatal("Create succeeded despite a duplicate line item; the unique constraint did not fire")
}
var orderCount, itemCount int64
db.Model(&models.Order{}).Count(&orderCount)
db.Model(&models.OrderItem{}).Count(&itemCount)
if orderCount != 0 {
t.Errorf("the order row survived the rollback: %d rows in app_order", orderCount)
}
if itemCount != 0 {
t.Errorf("an item row survived the rollback: %d rows in app_order_item", itemCount)
}
}
// A panic partway through the transaction must roll back exactly as
// cleanly as a returned error does. This is not testing app-order's own
// code so much as the primitive Create is built on: gorm's db.Transaction
// recovers a panic, rolls back, and re-panics, which is what makes it safe
// to use in place of go-admin's hand-rolled Begin/defer pattern (see
// Create's doc comment) - a pattern that, on a panic, commits whatever the
// transaction had written so far instead of undoing it.
func TestCreate_PanicInsideTransactionRollsBackEverything(t *testing.T) {
db := testDB(t)
func() {
defer func() {
if recover() == nil {
t.Fatal("db.Transaction did not propagate the panic")
}
}()
_ = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&models.Order{OrderNo: "panic-test", Status: models.StatusPending}).Error; err != nil {
t.Fatalf("Create inside transaction: %v", err)
}
panic("simulated failure after a partial write")
})
}()
var count int64
db.Model(&models.Order{}).Count(&count)
if count != 0 {
t.Errorf("the order row survived a panic mid-transaction: %d rows in app_order", count)
}
}
// -- status transition / concurrency guard ----------------------------------
func createPendingOrder(t *testing.T, s *Order, userId int) *models.Order {
t.Helper()
order, err := s.Create(&orderdto.OrderCreateReq{Items: []orderdto.OrderItemReq{
{ProductName: "widget", Quantity: 1, PriceCents: 100},
}}, userId)
if err != nil {
t.Fatalf("Create: %v", err)
}
return order
}
func TestPay_TransitionsPendingToPaid(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
if err := s.Pay(order.Id, &actions.DataPermission{DataScope: actions.DataScopeAll}); err != nil {
t.Fatalf("Pay: %v", err)
}
var got models.Order
db.First(&got, order.Id)
if got.Status != models.StatusPaid {
t.Errorf("Status = %q, want paid", got.Status)
}
}
func TestPay_AlreadyPaidReturnsErrOrderNotPending(t *testing.T) {
db := testDB(t)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
if err := s.Pay(order.Id, all); err != nil {
t.Fatalf("first Pay: %v", err)
}
if err := s.Pay(order.Id, all); !errors.Is(err, ErrOrderNotPending) {
t.Fatalf("second Pay returned %v, want ErrOrderNotPending", err)
}
}
// Two concurrent payment attempts against the same pending order: exactly
// one must succeed. MaxOpenConns(1) is set on the underlying *sql.DB so the
// two goroutines' UPDATEs serialize the way two independent connections
// would under MySQL, rather than one of them failing outright with
// SQLITE_BUSY - sqlite is a single-writer database with no useful
// concurrency of its own to exercise here. What the test actually verifies
// is unaffected by that: the guard is the UPDATE ... WHERE status =
// 'pending' clause and the RowsAffected check on its result (Pay's doc
// comment), and that logic runs once per goroutine regardless of how the
// pool schedules the two connections.
func TestPay_ConcurrentPaymentsOnlyOneSucceeds(t *testing.T) {
db := testDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("DB(): %v", err)
}
sqlDB.SetMaxOpenConns(1)
s := newOrderService(t, db)
order := createPendingOrder(t, s, 1)
all := &actions.DataPermission{DataScope: actions.DataScopeAll}
var wg sync.WaitGroup
errs := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
errs[i] = s.Pay(order.Id, all)
}(i)
}
wg.Wait()
successes, failures := 0, 0
for _, err := range errs {
switch {
case err == nil:
successes++
case errors.Is(err, ErrOrderNotPending):
failures++
default:
t.Fatalf("unexpected error from a concurrent Pay: %v", err)
}
}
if successes != 1 || failures != 1 {
t.Fatalf("got %d successes and %d failures, want exactly 1 and 1", successes, failures)
}
}
// -- data permission ---------------------------------------------------------
func TestGetPage_SelfScopeOnlySeesOwnOrders(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1) // belongs to user 1
createPendingOrder(t, s, 2) // belongs to user 2
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{
DataScope: actions.DataScopeSelf,
UserId: 1,
}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 1 || len(list) != 1 {
t.Fatalf("got %d orders, want exactly the 1 belonging to user 1", count)
}
if list[0].UserId != 1 {
t.Errorf("returned order belongs to user %d, not the caller", list[0].UserId)
}
}
func TestGetPage_AllScopeSeesEveryOrder(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1)
createPendingOrder(t, s, 2)
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: actions.DataScopeAll}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 2 {
t.Fatalf("got %d orders, want 2", count)
}
}
// An invalid/unrecognized data_scope must fail closed - match nothing -
// never fall back to "see everything". This is core's own documented
// contract (contract/actions.Permission's default case), exercised here
// against app-order's own table to confirm the fail-closed behaviour
// actually reaches a hand-written Service's query, not just core's own
// unit tests.
func TestGetPage_InvalidScopeSeesNothing(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
createPendingOrder(t, s, 1)
createPendingOrder(t, s, 2)
var list []models.Order
count, err := s.GetPage(&orderdto.OrderSearchReq{}, &actions.DataPermission{DataScope: "not-a-real-scope"}, &list)
if err != nil {
t.Fatalf("GetPage: %v", err)
}
if count != 0 || len(list) != 0 {
t.Fatalf("an invalid data scope returned %d orders, want 0 (fail closed)", count)
}
}
func TestGet_ReturnsOrderWithItemsPreloaded(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
created := createPendingOrder(t, s, 1)
var got models.Order
err := s.Get(created.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
if err != nil {
t.Fatalf("Get: %v", err)
}
if len(got.Items) != 1 {
t.Fatalf("got %d items, want the 1 created with the order", len(got.Items))
}
if got.Items[0].ProductName != "widget" {
t.Errorf("item ProductName = %q, want widget", got.Items[0].ProductName)
}
}
func TestGet_ScopedOutOrderReportsNotFoundNotForbidden(t *testing.T) {
enableDataPermission(t)
db := testDB(t)
s := newOrderService(t, db)
other := createPendingOrder(t, s, 2)
var got models.Order
err := s.Get(other.Id, &actions.DataPermission{DataScope: actions.DataScopeSelf, UserId: 1}, &got)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("Get on another user's order returned %v, want gorm.ErrRecordNotFound", err)
}
}