package product import ( "context" "errors" "fmt" "github.com/go-sql-driver/mysql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" "github.com/zeromicro/go-zero/core/stores/sqlx" "perms-system-server/internal/testutil" "strings" "sync" "sync/atomic" "testing" "time" ) func newTestModel(t *testing.T) SysProductModel { t.Helper() return NewSysProductModel( testutil.GetTestSqlConn(), testutil.GetTestCacheConf(), testutil.GetTestCachePrefix(), ) } func newSysProduct() *SysProduct { ts := time.Now().Unix() return &SysProduct{ Code: testutil.UniqueId(), Name: "integration-product", AppKey: testutil.UniqueId(), AppSecret: "app-secret-" + testutil.UniqueId(), Remark: "remark", Status: 1, CreateTime: ts, UpdateTime: ts, } } // TC-0423: 正常分页 func TestSysProductModel_Integration(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) t.Run("TableName", func(t *testing.T) { require.Equal(t, "`sys_product`", m.TableName()) }) t.Run("FindList_pagination", func(t *testing.T) { var beforeTotal int64 _, beforeTotal, _ = m.FindList(ctx, 1, 1) const n = 5 ids := make([]int64, 0, n) for i := 0; i < n; i++ { p := newSysProduct() p.Name = fmt.Sprintf("page-item-%d", i) res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) ids = append(ids, id) } defer func() { testutil.CleanTable(ctx, conn, "`sys_product`", ids...) }() expectedTotal := beforeTotal + int64(n) _, total, err := m.FindList(ctx, 1, 2) require.NoError(t, err) require.Equal(t, expectedTotal, total) var pageSize int64 = 2 fullPages := expectedTotal / pageSize lastPageSize := expectedTotal % pageSize if lastPageSize > 0 { lastList, _, err := m.FindList(ctx, fullPages+1, pageSize) require.NoError(t, err) require.Len(t, lastList, int(lastPageSize)) } }) t.Run("CRUD_cycle", func(t *testing.T) { p := newSysProduct() p.Name = "crud-name" res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, "`sys_product`", id) got, err := m.FindOne(ctx, id) require.NoError(t, err) require.Equal(t, id, got.Id) require.Equal(t, p.Code, got.Code) require.Equal(t, p.Name, got.Name) require.Equal(t, p.AppKey, got.AppKey) require.Equal(t, p.AppSecret, got.AppSecret) require.Equal(t, p.Remark, got.Remark) require.Equal(t, p.Status, got.Status) ts2 := time.Now().Unix() got.Name = "crud-name-updated" got.Remark = "updated-remark" got.Status = 2 got.UpdateTime = ts2 require.NoError(t, m.Update(ctx, got)) after, err := m.FindOne(ctx, id) require.NoError(t, err) require.Equal(t, "crud-name-updated", after.Name) require.Equal(t, "updated-remark", after.Remark) require.Equal(t, int64(2), after.Status) require.Equal(t, ts2, after.UpdateTime) require.NoError(t, m.Delete(ctx, id)) _, err = m.FindOne(ctx, id) require.ErrorIs(t, err, ErrNotFound) }) t.Run("FindOneByAppKey_found_and_notFound", func(t *testing.T) { p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, "`sys_product`", id) got, err := m.FindOneByAppKey(ctx, p.AppKey) require.NoError(t, err) require.Equal(t, p.AppKey, got.AppKey) require.Equal(t, id, got.Id) _, err = m.FindOneByAppKey(ctx, "nonexistent-appkey-"+testutil.UniqueId()) require.ErrorIs(t, err, ErrNotFound) }) t.Run("FindOneByCode_found_and_notFound", func(t *testing.T) { p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, "`sys_product`", id) got, err := m.FindOneByCode(ctx, p.Code) require.NoError(t, err) require.Equal(t, p.Code, got.Code) require.Equal(t, id, got.Id) _, err = m.FindOneByCode(ctx, "nonexistent-code-"+testutil.UniqueId()) require.ErrorIs(t, err, ErrNotFound) }) t.Run("BatchInsert_and_BatchDelete", func(t *testing.T) { items := []*SysProduct{newSysProduct(), newSysProduct(), newSysProduct()} for i := range items { items[i].Name = fmt.Sprintf("batch-%d", i) } require.NoError(t, m.BatchInsert(ctx, items)) var ids []int64 for _, it := range items { row, err := m.FindOneByCode(ctx, it.Code) require.NoError(t, err) ids = append(ids, row.Id) } defer testutil.CleanTable(ctx, conn, "`sys_product`", ids...) require.NoError(t, m.BatchDelete(ctx, ids)) for _, id := range ids { _, err := m.FindOne(ctx, id) require.ErrorIs(t, err, ErrNotFound) } }) t.Run("TransactCtx_commit", func(t *testing.T) { p := newSysProduct() p.Name = "tx-commit" err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, err := m.InsertWithTx(c, session, p) return err }) require.NoError(t, err) row, err := m.FindOneByCode(ctx, p.Code) require.NoError(t, err) defer testutil.CleanTable(ctx, conn, "`sys_product`", row.Id) require.Equal(t, p.Code, row.Code) require.Equal(t, "tx-commit", row.Name) }) t.Run("TransactCtx_rollback", func(t *testing.T) { p := newSysProduct() p.Name = "tx-rollback" err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { if _, err := m.InsertWithTx(c, session, p); err != nil { return err } return fmt.Errorf("force rollback") }) require.Error(t, err) _, err = m.FindOneByCode(ctx, p.Code) require.ErrorIs(t, err, ErrNotFound) }) t.Run("Insert_duplicateCode", func(t *testing.T) { code := testutil.UniqueId() ts := time.Now().Unix() p1 := &SysProduct{ Code: code, Name: "dup-a", AppKey: testutil.UniqueId(), AppSecret: "s1", Remark: "", Status: 1, CreateTime: ts, UpdateTime: ts, } res, err := m.Insert(ctx, p1) require.NoError(t, err) id1, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, "`sys_product`", id1) p2 := &SysProduct{ Code: code, Name: "dup-b", AppKey: testutil.UniqueId(), AppSecret: "s2", Remark: "", Status: 1, CreateTime: ts, UpdateTime: ts, } _, err = m.Insert(ctx, p2) require.Error(t, err) var me *mysql.MySQLError require.True(t, errors.As(err, &me)) require.Equal(t, uint16(1062), me.Number) }) } // TC-0319: 记录不存在 func TestSysProductModel_FindOne_NotFound(t *testing.T) { m := newTestModel(t) _, err := m.FindOne(context.Background(), 999999999999) require.ErrorIs(t, err, ErrNotFound) } // TC-0326: 记录不存在 func TestSysProductModel_Update_NotFound(t *testing.T) { m := newTestModel(t) err := m.Update(context.Background(), &SysProduct{ Id: 999999999999, Code: "x", Name: "n", AppKey: "k", AppSecret: "s", Status: 1, CreateTime: time.Now().Unix(), UpdateTime: time.Now().Unix(), }) require.ErrorIs(t, err, ErrNotFound) } // TC-0329: 记录不存在 func TestSysProductModel_Delete_NotFound(t *testing.T) { m := newTestModel(t) err := m.Delete(context.Background(), 999999999999) require.ErrorIs(t, err, ErrNotFound) } // TC-0334: 空列表 func TestSysProductModel_BatchInsert_Empty(t *testing.T) { m := newTestModel(t) require.NoError(t, m.BatchInsert(context.Background(), nil)) require.NoError(t, m.BatchInsert(context.Background(), []*SysProduct{})) } // TC-0353: 空ids func TestSysProductModel_BatchDelete_Empty(t *testing.T) { m := newTestModel(t) require.NoError(t, m.BatchDelete(context.Background(), nil)) require.NoError(t, m.BatchDelete(context.Background(), []int64{})) } // TC-0327: 事务内更新 func TestSysProductModel_UpdateWithTx(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, m.TableName(), id) err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { p.Id = id p.Name = "tx_updated_name" p.UpdateTime = time.Now().Unix() return m.UpdateWithTx(c, session, p) }) require.NoError(t, err) got, err := m.FindOne(ctx, id) require.NoError(t, err) require.Equal(t, "tx_updated_name", got.Name) } // TC-0330: 事务内删除 func TestSysProductModel_DeleteWithTx(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) defer testutil.CleanTable(ctx, conn, m.TableName(), id) err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.DeleteWithTx(c, session, id) }) require.NoError(t, err) _, err = m.FindOne(ctx, id) require.ErrorIs(t, err, ErrNotFound) } // TC-0335: 单条记录 func TestSysProductModel_BatchInsert_Single(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() require.NoError(t, m.BatchInsert(ctx, []*SysProduct{p})) found, err := m.FindOneByCode(ctx, p.Code) require.NoError(t, err) defer testutil.CleanTable(ctx, conn, m.TableName(), found.Id) require.Equal(t, p.Code, found.Code) } // TC-0338: 唯一索引冲突 func TestSysProductModel_BatchInsert_UniqueConflict(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) code := testutil.UniqueId() ts := time.Now().Unix() list := []*SysProduct{ {Code: code, Name: "a", AppKey: testutil.UniqueId(), AppSecret: "s1", Status: 1, CreateTime: ts, UpdateTime: ts}, {Code: code, Name: "b", AppKey: testutil.UniqueId(), AppSecret: "s2", Status: 1, CreateTime: ts, UpdateTime: ts}, } err := m.BatchInsert(ctx, list) require.Error(t, err) t.Cleanup(func() { if found, e := m.FindOneByCode(ctx, code); e == nil { testutil.CleanTable(ctx, conn, m.TableName(), found.Id) } }) var me *mysql.MySQLError require.True(t, errors.As(err, &me)) require.Equal(t, uint16(1062), me.Number) } // TC-0343: 空列表 func TestSysProductModel_BatchUpdate_Empty(t *testing.T) { m := newTestModel(t) require.NoError(t, m.BatchUpdate(context.Background(), nil)) require.NoError(t, m.BatchUpdate(context.Background(), []*SysProduct{})) } // TC-0345: 多条记录(3条) func TestSysProductModel_BatchUpdate_Multi(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p1 := newSysProduct() p2 := newSysProduct() r1, err := m.Insert(ctx, p1) require.NoError(t, err) id1, _ := r1.LastInsertId() r2, err := m.Insert(ctx, p2) require.NoError(t, err) id2, _ := r2.LastInsertId() defer testutil.CleanTable(ctx, conn, m.TableName(), id1, id2) now := time.Now().Unix() upd := []*SysProduct{ {Id: id1, Code: p1.Code, Name: "upd1", AppKey: p1.AppKey, AppSecret: p1.AppSecret, Remark: "r1", Status: 1, CreateTime: p1.CreateTime, UpdateTime: now}, {Id: id2, Code: p2.Code, Name: "upd2", AppKey: p2.AppKey, AppSecret: p2.AppSecret, Remark: "r2", Status: 2, CreateTime: p2.CreateTime, UpdateTime: now}, } require.NoError(t, m.BatchUpdate(ctx, upd)) g1, err := m.FindOne(ctx, id1) require.NoError(t, err) require.Equal(t, "upd1", g1.Name) g2, err := m.FindOne(ctx, id2) require.NoError(t, err) require.Equal(t, "upd2", g2.Name) require.Equal(t, int64(2), g2.Status) } // TC-0341: 正常多条 func TestSysProductModel_BatchInsertWithTx_Normal(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p1 := newSysProduct() p2 := newSysProduct() err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.BatchInsertWithTx(c, session, []*SysProduct{p1, p2}) }) require.NoError(t, err) f1, err := m.FindOneByCode(ctx, p1.Code) require.NoError(t, err) f2, err := m.FindOneByCode(ctx, p2.Code) require.NoError(t, err) defer testutil.CleanTable(ctx, conn, m.TableName(), f1.Id, f2.Id) require.Equal(t, p1.Code, f1.Code) require.Equal(t, p2.Code, f2.Code) } // TC-0340: 空列表 func TestSysProductModel_BatchInsertWithTx_Empty(t *testing.T) { m := newTestModel(t) err := m.TransactCtx(context.Background(), func(c context.Context, session sqlx.Session) error { return m.BatchInsertWithTx(c, session, nil) }) require.NoError(t, err) } // TC-0342: 事务回滚 func TestSysProductModel_BatchInsertWithTx_Rollback(t *testing.T) { ctx := context.Background() m := newTestModel(t) p := newSysProduct() err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { if e := m.BatchInsertWithTx(c, session, []*SysProduct{p}); e != nil { return e } return fmt.Errorf("force rollback") }) require.Error(t, err) _, err = m.FindOneByCode(ctx, p.Code) require.ErrorIs(t, err, ErrNotFound) } // TC-0349: 正常多条 func TestSysProductModel_BatchUpdateWithTx_Normal(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p1 := newSysProduct() p2 := newSysProduct() r1, err := m.Insert(ctx, p1) require.NoError(t, err) id1, _ := r1.LastInsertId() r2, err := m.Insert(ctx, p2) require.NoError(t, err) id2, _ := r2.LastInsertId() defer testutil.CleanTable(ctx, conn, m.TableName(), id1, id2) now := time.Now().Unix() err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.BatchUpdateWithTx(c, session, []*SysProduct{ {Id: id1, Code: p1.Code, Name: "tx_upd1", AppKey: p1.AppKey, AppSecret: p1.AppSecret, Status: 1, CreateTime: p1.CreateTime, UpdateTime: now}, {Id: id2, Code: p2.Code, Name: "tx_upd2", AppKey: p2.AppKey, AppSecret: p2.AppSecret, Status: 1, CreateTime: p2.CreateTime, UpdateTime: now}, }) }) require.NoError(t, err) g1, err := m.FindOne(ctx, id1) require.NoError(t, err) require.Equal(t, "tx_upd1", g1.Name) g2, err := m.FindOne(ctx, id2) require.NoError(t, err) require.Equal(t, "tx_upd2", g2.Name) } // TC-0348: 空列表 func TestSysProductModel_BatchUpdateWithTx_Empty(t *testing.T) { m := newTestModel(t) err := m.TransactCtx(context.Background(), func(c context.Context, session sqlx.Session) error { return m.BatchUpdateWithTx(c, session, nil) }) require.NoError(t, err) } // TC-0354: 单个id func TestSysProductModel_BatchDelete_Single(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() defer testutil.CleanTable(ctx, conn, m.TableName(), id) require.NoError(t, m.BatchDelete(ctx, []int64{id})) _, err = m.FindOne(ctx, id) require.ErrorIs(t, err, ErrNotFound) } // TC-0356: 包含不存在id func TestSysProductModel_BatchDelete_ContainsNonExist(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() defer testutil.CleanTable(ctx, conn, m.TableName(), id) require.NoError(t, m.BatchDelete(ctx, []int64{id, 999999999})) _, err = m.FindOne(ctx, id) require.ErrorIs(t, err, ErrNotFound) } // TC-0358: 正常多条 func TestSysProductModel_BatchDeleteWithTx_Normal(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p1 := newSysProduct() p2 := newSysProduct() r1, err := m.Insert(ctx, p1) require.NoError(t, err) id1, _ := r1.LastInsertId() r2, err := m.Insert(ctx, p2) require.NoError(t, err) id2, _ := r2.LastInsertId() defer testutil.CleanTable(ctx, conn, m.TableName(), id1, id2) err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.BatchDeleteWithTx(c, session, []int64{id1, id2}) }) require.NoError(t, err) _, err = m.FindOne(ctx, id1) require.ErrorIs(t, err, ErrNotFound) _, err = m.FindOne(ctx, id2) require.ErrorIs(t, err, ErrNotFound) } // TC-0357: 空ids func TestSysProductModel_BatchDeleteWithTx_Empty(t *testing.T) { m := newTestModel(t) err := m.TransactCtx(context.Background(), func(c context.Context, session sqlx.Session) error { return m.BatchDeleteWithTx(c, session, nil) }) require.NoError(t, err) } // TC-0323: 事务内可见性 func TestSysProductModel_FindOneWithTx_InsertThenFind(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) now := time.Now().Unix() var foundInTx *SysProduct var insertedId int64 err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { res, err := m.InsertWithTx(c, session, &SysProduct{ Code: "ftx_code_" + testutil.UniqueId(), Name: "ftx_name_" + testutil.UniqueId(), AppKey: "ftx_ak_" + testutil.UniqueId(), AppSecret: "ftx_sec_" + testutil.UniqueId(), Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) if err != nil { return err } insertedId, _ = res.LastInsertId() foundInTx, err = m.FindOneWithTx(c, session, insertedId) return err }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), insertedId) }) require.NotNil(t, foundInTx) assert.Equal(t, insertedId, foundInTx.Id) } // TC-0322: 事务内记录不存在 func TestSysProductModel_FindOneWithTx_NotFound(t *testing.T) { ctx := context.Background() m := newTestModel(t) err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, err := m.FindOneWithTx(c, session, 999999999999) return err }) require.ErrorIs(t, err, ErrNotFound) } // TC-0365: FindOneByAppKeyWithTx func TestSysProductModel_FindOneByAppKeyWithTx_InsertThenFind(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) appKey := "ftx_ak_" + testutil.UniqueId() code := "ftx_code_" + testutil.UniqueId() now := time.Now().Unix() var foundByKey *SysProduct var insertedId int64 err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { res, err := m.InsertWithTx(c, session, &SysProduct{ Code: code, Name: "ftx_name_" + testutil.UniqueId(), AppKey: appKey, AppSecret: "ftx_sec_" + testutil.UniqueId(), Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) if err != nil { return err } insertedId, _ = res.LastInsertId() foundByKey, err = m.FindOneByAppKeyWithTx(c, session, appKey) return err }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), insertedId) }) require.NotNil(t, foundByKey) assert.Equal(t, insertedId, foundByKey.Id) assert.Equal(t, appKey, foundByKey.AppKey) } // TC-0366: FindOneByAppKeyWithTx func TestSysProductModel_FindOneByAppKeyWithTx_NotFound(t *testing.T) { ctx := context.Background() m := newTestModel(t) err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, err := m.FindOneByAppKeyWithTx(c, session, "notexist_appkey_"+testutil.UniqueId()) return err }) require.ErrorIs(t, err, ErrNotFound) } // TC-0369: FindOneByCodeWithTx func TestSysProductModel_FindOneByCodeWithTx_InsertThenFind(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) code := "ftx_code_" + testutil.UniqueId() now := time.Now().Unix() var foundByCode *SysProduct var insertedId int64 err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { res, err := m.InsertWithTx(c, session, &SysProduct{ Code: code, Name: "ftx_name_" + testutil.UniqueId(), AppKey: "ftx_ak_" + testutil.UniqueId(), AppSecret: "ftx_sec_" + testutil.UniqueId(), Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) if err != nil { return err } insertedId, _ = res.LastInsertId() foundByCode, err = m.FindOneByCodeWithTx(c, session, code) return err }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, m.TableName(), insertedId) }) require.NotNil(t, foundByCode) assert.Equal(t, insertedId, foundByCode.Id) assert.Equal(t, code, foundByCode.Code) } // TC-0370: FindOneByCodeWithTx func TestSysProductModel_FindOneByCodeWithTx_NotFound(t *testing.T) { ctx := context.Background() m := newTestModel(t) err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, err := m.FindOneByCodeWithTx(c, session, "notexist_code_"+testutil.UniqueId()) return err }) require.ErrorIs(t, err, ErrNotFound) } // TC-0404: 多唯一索引前缀(SysProduct) func TestSysProductModel_CachePrefix_MultiUniqueIndex(t *testing.T) { oldId := cacheSysProductIdPrefix oldAppKey := cacheSysProductAppKeyPrefix oldCode := cacheSysProductCodePrefix defer func() { cacheSysProductIdPrefix = oldId cacheSysProductAppKeyPrefix = oldAppKey cacheSysProductCodePrefix = oldCode }() _ = newSysProductModel(testutil.GetTestSqlConn(), testutil.GetTestCacheConf(), "test") assert.True(t, strings.HasPrefix(cacheSysProductIdPrefix, "test:")) assert.True(t, strings.HasPrefix(cacheSysProductAppKeyPrefix, "test:")) assert.True(t, strings.HasPrefix(cacheSysProductCodePrefix, "test:")) } func TestSysProductModel_LockByCodeTx_Found(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) var got *SysProduct require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { got, err = m.LockByCodeTx(c, session, p.Code) return err })) require.NotNil(t, got) assert.Equal(t, id, got.Id) assert.Equal(t, p.Code, got.Code) assert.Equal(t, p.AppKey, got.AppKey) assert.Equal(t, p.Status, got.Status, "锁行时不得过滤禁用态,否则 SyncPermissions 无法为禁用产品正确 fail-close") } // TC-0810: LockByCodeTx 对不存在的 code 返回 sqlx.ErrNotFound。 func TestSysProductModel_LockByCodeTx_NotFound(t *testing.T) { ctx := context.Background() m := newTestModel(t) err := m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, e := m.LockByCodeTx(c, session, "definitely_no_such_code_"+testutil.UniqueId()) return e }) require.Error(t, err) assert.True(t, errors.Is(err, sqlx.ErrNotFound), "LockByCodeTx 对不存在的 code 必须返回 ErrNotFound,便于上层 fail-close 返回 401/404") } // TC-0811: FOR UPDATE 行锁真实生效 —— 两个事务同时尝试锁同一行时, // 后进者必须被阻塞直到先进者结束事务。 // 测量方式:goroutine A 在 tx 内 Lock 住后 sleep 500ms 再 commit; // // goroutine B 等 100ms 后也尝试 Lock 同一行,记录耗时。B 的耗时必须≥400ms。 func TestSysProductModel_LockByCodeTx_BlocksConcurrentWriter(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) var ( wg sync.WaitGroup aHoldMs = int64(500) bStartDelayMs = int64(100) bElapsedNanos int64 aFinishedNanos int64 aErr, bErr error ) wg.Add(2) go func() { defer wg.Done() aErr = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { if _, e := m.LockByCodeTx(c, session, p.Code); e != nil { return e } // A 拿到锁后故意延时,模拟一段业务处理期。期间 B 必须被阻塞。 time.Sleep(time.Duration(aHoldMs) * time.Millisecond) atomic.StoreInt64(&aFinishedNanos, time.Now().UnixNano()) return nil }) }() go func() { defer wg.Done() time.Sleep(time.Duration(bStartDelayMs) * time.Millisecond) start := time.Now() bErr = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { _, e := m.LockByCodeTx(c, session, p.Code) return e }) atomic.StoreInt64(&bElapsedNanos, time.Since(start).Nanoseconds()) }() wg.Wait() require.NoError(t, aErr) require.NoError(t, bErr) // B 的耗时 ≥ A 的剩余持锁时间。A 持 500ms,B 延 100ms 后入场, // 因此 B 被阻塞的时间至少 (500-100)=400ms。给 DB 一点抖动放到 300ms。 elapsedMs := atomic.LoadInt64(&bElapsedNanos) / int64(time.Millisecond) minBlockedMs := int64(300) assert.GreaterOrEqualf(t, elapsedMs, minBlockedMs, fmt.Sprintf( "B 的 LockByCodeTx 总耗时 %dms 明显低于预期最小阻塞 %dms —— "+ "意味着 FOR UPDATE 行锁失效,声称的'按 product 串行化'不成立", elapsedMs, minBlockedMs)) } // --------------------------------------------------------------------------- // L-R15-3 / L-R12-1:UpdateWithOptLockTx + InvalidateProductCache // // UpdateWithOptLockTx 语义契约: // - WHERE id=? AND updateTime=? 复现 UpdateWithOptLock 的 CAS; // - 必须走调用方 session,不得自身失效 sqlc 低层缓存(交给 post-commit 的 InvalidateProductCache); // - affected=0 → ErrUpdateConflict。 // // InvalidateProductCache 语义契约: // - 必须一次失效 sysProduct 的 id / appKey / code 三把低层缓存 key; // - 不能依赖 session,只在 post-commit 调用。 // --------------------------------------------------------------------------- // TC-1151: UpdateWithOptLockTx 正常路径——CAS 命中时 UPDATE 成功。 func TestSysProductModel_UpdateWithOptLockTx_HappyPath(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) orig, err := m.FindOne(ctx, id) require.NoError(t, err) newData := *orig newData.Name = "updated_name" newData.Remark = "updated_remark" newData.Status = 2 newData.UpdateTime = orig.UpdateTime + 1 require.NoError(t, m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.UpdateWithOptLockTx(c, session, &newData, orig.UpdateTime) })) // 直读 DB 验证(绕过缓存——UpdateWithOptLockTx 不该动缓存,故 FindOne 可能仍看到旧值; // 这里是对"DB 确实被改写"的第一类证据) var dbName, dbRemark string var dbStatus int64 require.NoError(t, conn.QueryRowCtx(ctx, &dbName, "SELECT `name` FROM `sys_product` WHERE `id` = ?", id)) require.NoError(t, conn.QueryRowCtx(ctx, &dbRemark, "SELECT `remark` FROM `sys_product` WHERE `id` = ?", id)) require.NoError(t, conn.QueryRowCtx(ctx, &dbStatus, "SELECT `status` FROM `sys_product` WHERE `id` = ?", id)) assert.Equal(t, "updated_name", dbName) assert.Equal(t, "updated_remark", dbRemark) assert.Equal(t, int64(2), dbStatus) } // TC-1152: UpdateWithOptLockTx expectedUpdateTime 不匹配 → ErrUpdateConflict,DB 不变。 func TestSysProductModel_UpdateWithOptLockTx_StaleExpectedUpdateTime_Conflict(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) orig, err := m.FindOne(ctx, id) require.NoError(t, err) staleUpdateTime := orig.UpdateTime - 100 newData := *orig newData.Name = "should_not_land" newData.UpdateTime = orig.UpdateTime + 1 err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { return m.UpdateWithOptLockTx(c, session, &newData, staleUpdateTime) }) require.ErrorIs(t, err, ErrUpdateConflict, "expectedUpdateTime 与 DB 当前值不一致 → ErrUpdateConflict") // DB 名称保持原值,证明 CAS 失败时 UPDATE 未落盘 var dbName string require.NoError(t, conn.QueryRowCtx(ctx, &dbName, "SELECT `name` FROM `sys_product` WHERE `id` = ?", id)) assert.Equal(t, p.Name, dbName, "CAS 未命中时 DB 必须保持原值,不得部分落盘") } // TC-1153: UpdateWithOptLockTx nil session → error(与 IncrementTokenVersionWithTx 同家族契约)。 func TestSysProductModel_UpdateWithOptLockTx_NilSession_ReturnsError(t *testing.T) { m := newTestModel(t) err := m.UpdateWithOptLockTx(context.Background(), nil, &SysProduct{Id: 1}, 0) require.Error(t, err) assert.Contains(t, err.Error(), "non-nil session") } // TC-1154: UpdateWithOptLockTx 事务 rollback 后 DB 不落盘。 func TestSysProductModel_UpdateWithOptLockTx_Rollback_NoPersistence(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) orig, err := m.FindOne(ctx, id) require.NoError(t, err) newData := *orig newData.Name = "rolled_back_name" newData.UpdateTime = orig.UpdateTime + 1 err = m.TransactCtx(ctx, func(c context.Context, session sqlx.Session) error { if e := m.UpdateWithOptLockTx(c, session, &newData, orig.UpdateTime); e != nil { return e } return errors.New("force rollback") }) require.Error(t, err) var dbName string require.NoError(t, conn.QueryRowCtx(ctx, &dbName, "SELECT `name` FROM `sys_product` WHERE `id` = ?", id)) assert.Equal(t, p.Name, dbName, "rollback 后 DB 必须保持原值——证明 UpdateWithOptLockTx 确实走 session 而非独立连接") } // TC-1155: InvalidateProductCache 必须一次失效 id / appKey / code 三把 key。 // 对应 UpdateProduct 禁用后"sysProduct 低层缓存不准"风险。 func TestSysProductModel_InvalidateProductCache_DelsAllThreeKeys(t *testing.T) { ctx := context.Background() conn := testutil.GetTestSqlConn() m := newTestModel(t) rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf) p := newSysProduct() res, err := m.Insert(ctx, p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", id) }) // 预热三把 key _, err = m.FindOne(ctx, id) require.NoError(t, err) _, err = m.FindOneByAppKey(ctx, p.AppKey) require.NoError(t, err) _, err = m.FindOneByCode(ctx, p.Code) require.NoError(t, err) prefix := testutil.GetTestCachePrefix() keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, id) keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, p.AppKey) keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, p.Code) for _, k := range []string{keyId, keyAppKey, keyCode} { v, _ := rds.Get(k) require.NotEmpty(t, v, "预置:%s 已写入缓存", k) } m.InvalidateProductCache(ctx, id, p.AppKey, p.Code) for _, k := range []string{keyId, keyAppKey, keyCode} { v, _ := rds.Get(k) assert.Empty(t, v, "InvalidateProductCache 必须失效低层缓存 key %s", k) } } // TC-1156: InvalidateProductCache 在 ctx 已取消/超时下必须不 panic、不阻塞(best-effort 契约)。 // 与 InvalidateProfileCache 的 L-R13-5 方案 B 完全同构,理由相同:事务已 commit, // 缓存清理失败必须吞错 + audit tag 日志,绝不能向上抛。 func TestSysProductModel_InvalidateProductCache_CanceledCtxDoesNotPanicOrBlock(t *testing.T) { conn := testutil.GetTestSqlConn() m := newTestModel(t) p := newSysProduct() res, err := m.Insert(context.Background(), p) require.NoError(t, err) id, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(context.Background(), conn, "`sys_product`", id) }) cases := []struct { name string makeCtx func() (context.Context, context.CancelFunc) }{ { name: "canceled", makeCtx: func() (context.Context, context.CancelFunc) { ctx, cancel := context.WithCancel(context.Background()) cancel() return ctx, func() {} }, }, { name: "deadline_exceeded", makeCtx: func() (context.Context, context.CancelFunc) { ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) return ctx, cancel }, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { ctx, cancel := tc.makeCtx() defer cancel() done := make(chan struct{}) go func() { defer close(done) assert.NotPanics(t, func() { m.InvalidateProductCache(ctx, id, p.AppKey, p.Code) }) }() select { case <-done: case <-time.After(500 * time.Millisecond): t.Fatal("InvalidateProductCache 在 canceled ctx 下必须立即返回") } }) } }