mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-21 10:13:01 +00:00
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
353 lines
11 KiB
Go
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)
|
|
}
|
|
}
|