Files
90CNServer/server/internal/modules/dnf/repository/mysql/mysql_test.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1298 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"longheng.io/server/internal/modules/dnf/dungeonpermission"
"longheng.io/server/internal/modules/dnf/repository"
"slices"
"strings"
"testing"
"time"
)
type fakeSQLCall struct {
Query string
Args []any
}
func TestMySQLGroupExposesAtomicCharacterSettlement(t *testing.T) {
database, err := sql.Open("mysql", "root:password@tcp(127.0.0.1:1)/dnf")
if err != nil {
t.Fatal(err)
}
defer database.Close()
group, err := NewMySQLGroupFromDB(database, MySQLGroupOptions{DatabasePlan: repository.DatabasePlan{
Feature: repository.FeatureRepository, ShardID: "9999", GroupID: "test",
ReadDatabases: []string{"dnf"}, WriteDatabases: []string{"dnf"},
}})
if err != nil {
t.Fatal(err)
}
if group.CharacterSettlement == nil {
t.Fatal("MySQL repository group does not expose the atomic quest/inventory settlement")
}
}
func TestMySQLDungeonPermissionLoadsAccountOwnerKeyWithoutCharacterCoercion(t *testing.T) {
accountKey, err := dungeonpermission.AccountOwnerKey("account-shared")
if err != nil {
t.Fatal(err)
}
sqlDB := &fakeSQLDB{
rowsets: []fakeSQLRows{{
rows: []fakeSQLRow{{
values: []any{
uint64(700), uint8(4), 0,
time.Date(2026, 8, 21, 23, 0, 0, 0, time.UTC),
},
}},
}},
}
repositories := newTestMySQLGroup(t, sqlDB)
record, found, err := repositories.DungeonPermission.Load(context.Background(), accountKey)
if err != nil || !found || record.CharacterID != accountKey || len(record.Entries) != 1 ||
record.Entries[0].DungeonID != 700 || record.Entries[0].ClearState != 4 {
t.Fatalf("account permission=%+v found=%t err=%v", record, found, err)
}
call := requireExecContaining(t, sqlDB, "dnf_dungeon_permissions")
if len(call.Args) != 1 || call.Args[0] != accountKey {
t.Fatalf("account owner query args=%#v want=%q", call.Args, accountKey)
}
}
type fakeSQLDB struct {
execs []fakeSQLCall
rows []fakeSQLRow
rowsets []fakeSQLRows
pings int
execErr error
pingErr error
}
func (f *fakeSQLDB) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
f.execs = append(f.execs, fakeSQLCall{
Query: query,
Args: append([]any(nil), args...),
})
if f.execErr != nil {
return nil, f.execErr
}
return nil, nil
}
func (f *fakeSQLDB) QueryContext(_ context.Context, query string, args ...any) (SQLRows, error) {
f.execs = append(f.execs, fakeSQLCall{
Query: query,
Args: append([]any(nil), args...),
})
if len(f.rowsets) == 0 {
return &fakeSQLRows{}, nil
}
rows := f.rowsets[0]
f.rowsets = f.rowsets[1:]
return &rows, nil
}
func (f *fakeSQLDB) QueryRowContext(_ context.Context, query string, args ...any) SQLRow {
f.execs = append(f.execs, fakeSQLCall{
Query: query,
Args: append([]any(nil), args...),
})
if len(f.rows) == 0 {
return fakeSQLRow{err: sql.ErrNoRows}
}
row := f.rows[0]
f.rows = f.rows[1:]
return row
}
func (f *fakeSQLDB) PingContext(context.Context) error {
f.pings++
if f.pingErr != nil {
return f.pingErr
}
return nil
}
type fakeSQLRow struct {
values []any
err error
}
type fakeSQLRows struct {
rows []fakeSQLRow
idx int
err error
}
func (r *fakeSQLRows) Close() error {
return nil
}
func (r *fakeSQLRows) Next() bool {
if r == nil || r.idx >= len(r.rows) {
return false
}
r.idx++
return true
}
func (r *fakeSQLRows) Scan(dest ...any) error {
if r == nil || r.idx == 0 || r.idx > len(r.rows) {
return errors.New("fake sql rows scan before next")
}
return r.rows[r.idx-1].Scan(dest...)
}
func (r *fakeSQLRows) Err() error {
if r == nil {
return nil
}
return r.err
}
func (r fakeSQLRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
if len(dest) != len(r.values) {
return errors.New("fake sql row destination count mismatch")
}
for idx, value := range r.values {
if err := assignFakeSQLValue(dest[idx], value); err != nil {
return err
}
}
return nil
}
func assignFakeSQLValue(dest any, value any) error {
switch out := dest.(type) {
case *string:
if value == nil {
*out = ""
return nil
}
*out = value.(string)
case *int:
if value == nil {
*out = 0
return nil
}
*out = value.(int)
case *int16:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case int16:
*out = v
case int:
*out = int16(v)
case int64:
*out = int16(v)
default:
return errors.New("unsupported fake sql int16 value")
}
case *int64:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case int:
*out = int64(v)
case int64:
*out = v
default:
return errors.New("unsupported fake sql int64 value")
}
case *bool:
if value == nil {
*out = false
return nil
}
*out = value.(bool)
case *byte:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case byte:
*out = v
case int:
*out = byte(v)
default:
return errors.New("unsupported fake sql byte value")
}
case *uint16:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case uint16:
*out = v
case int:
*out = uint16(v)
default:
return errors.New("unsupported fake sql uint16 value")
}
case *uint32:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case uint32:
*out = v
case int:
*out = uint32(v)
default:
return errors.New("unsupported fake sql uint32 value")
}
case *uint64:
if value == nil {
*out = 0
return nil
}
switch v := value.(type) {
case uint64:
*out = v
case int64:
*out = uint64(v)
case int:
*out = uint64(v)
default:
return errors.New("unsupported fake sql uint64 value")
}
case *[]byte:
if value == nil {
*out = nil
return nil
}
*out = append([]byte(nil), value.([]byte)...)
case *sql.NullString:
if value == nil {
*out = sql.NullString{}
return nil
}
switch v := value.(type) {
case string:
*out = sql.NullString{String: v, Valid: true}
case []byte:
*out = sql.NullString{String: string(v), Valid: true}
default:
return errors.New("unsupported fake sql null string value")
}
case *sql.NullTime:
if value == nil {
*out = sql.NullTime{}
return nil
}
*out = sql.NullTime{Time: value.(time.Time), Valid: true}
case *sql.NullInt64:
if value == nil {
*out = sql.NullInt64{}
return nil
}
switch v := value.(type) {
case int:
*out = sql.NullInt64{Int64: int64(v), Valid: true}
case int64:
*out = sql.NullInt64{Int64: v, Valid: true}
default:
return errors.New("unsupported fake sql null int64 value")
}
case *time.Time:
if value == nil {
*out = time.Time{}
return nil
}
*out = value.(time.Time)
default:
return errors.New("unsupported fake sql destination")
}
return nil
}
func TestNewMySQLGroupRejectsMissingDB(t *testing.T) {
_, err := NewMySQLGroup(nil, MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{WriteDatabases: []string{"dnf_s1_w1"}},
})
if !errors.Is(err, ErrMySQLDBRequired) {
t.Fatalf("NewMySQLGroup() error = %v, want ErrMySQLDBRequired", err)
}
}
func TestMySQLGroupCheckPingsStores(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
if err := repos.Check(context.Background()); err != nil {
t.Fatalf("Check() error = %v", err)
}
if sqlDB.pings != 15 {
t.Fatalf("pings = %d, want 15", sqlDB.pings)
}
}
func TestMySQLCharacterCreateWritesSQLColumnsOnly(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.CharacterRecord{
CharacterID: "char-1",
AccountID: "acc-1",
Slot: 2,
Name: "fighter",
Job: "15",
Level: 1,
}
if err := repository.CreateCharacter(context.Background(), repos.Character, record); err != nil {
t.Fatalf("CreateCharacter() error = %v", err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, call.Query, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, call.Query, "`character_id`")
assertContains(t, call.Query, "`grow_type`")
assertContains(t, call.Query, "`user_state`")
assertContains(t, call.Query, "`tutorial_completed`")
assertContains(t, call.Query, "`tutorial_reward_progress_38`")
assertContains(t, call.Query, "`delete_flag`")
assertContains(t, call.Query, "`pc_room_id`")
assertContains(t, call.Query, "`stat_block_marker`")
assertContains(t, call.Query, "`roster_card_flag`")
assertContains(t, call.Query, "`create_option_byte_63`")
assertNotContains(t, call.Query, "`stats_json`")
assertNotContains(t, call.Query, "`location_json`")
assertNotContains(t, call.Query, "`roster_json`")
statIndex := requireMySQLCharacterStatIndex(t, "tutorial_completed")
if got := call.Args[8+statIndex]; got != int64(0) {
t.Fatalf("created tutorial_completed arg = %#v, want 0", got)
}
rewardStatIndex := requireMySQLCharacterStatIndex(t, "tutorial_reward_progress_38")
if got := call.Args[8+rewardStatIndex]; got != int64(0) {
t.Fatalf("created tutorial_reward_progress_38 arg = %#v, want 0", got)
}
}
func TestMySQLCharacterFindIDByNameIgnoresSoftDeletedRows(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
_, ok, err := repos.Character.FindIDByName(context.Background(), "gone")
if err != nil {
t.Fatalf("FindIDByName() error = %v", err)
}
if ok {
t.Fatalf("FindIDByName() found a row from empty fake DB")
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_characters`")
assertContains(t, call.Query, "WHERE name = ? AND delete_flag = 0")
}
func TestMySQLCharacterSaveFieldsOnlyUpdatesDirtyColumns(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.CharacterRecord{
CharacterID: "char-1",
AccountID: "acc-1",
Name: "fighter",
Stats: map[string]int64{"str": 99},
}
if err := repository.SaveCharacterFields(context.Background(), repos.Character, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, call.Query, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, call.Query, "`grow_type` = VALUES(`grow_type`)")
assertContains(t, call.Query, "`roster_display_flags` = VALUES(`roster_display_flags`)")
assertNotContains(t, call.Query, "`location_json` = VALUES(`location_json`)")
assertNotContains(t, call.Query, "`roster_json` = VALUES(`roster_json`)")
assertNotContains(t, call.Query, "`stats_json`")
assertNotContains(t, call.Query, "`name` = VALUES(`name`)")
}
func TestMySQLCharacterTutorialFlagsSaveAndLoadThroughMirrorColumns(t *testing.T) {
ctx := context.Background()
statIndex := requireMySQLCharacterStatIndex(t, "tutorial_completed")
rewardStatIndex := requireMySQLCharacterStatIndex(t, "tutorial_reward_progress_38")
saveDB := &fakeSQLDB{}
saveRepos := newTestMySQLGroup(t, saveDB)
record := repository.CharacterRecord{
CharacterID: "tutorial-char-1",
AccountID: "acc-1",
Name: "gunner",
Job: "2",
Level: 1,
Stats: map[string]int64{
"tutorial_completed": 1,
"tutorial_reward_progress_38": 1,
},
}
if err := repository.SaveCharacterFields(ctx, saveRepos.Character, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
saveCall := requireExecContaining(t, saveDB, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, saveCall.Query, "`tutorial_completed` = VALUES(`tutorial_completed`)")
assertContains(t, saveCall.Query, "`tutorial_reward_progress_38` = VALUES(`tutorial_reward_progress_38`)")
if got := saveCall.Args[8+statIndex]; got != int64(1) {
t.Fatalf("saved tutorial_completed arg = %#v, want 1", got)
}
if got := saveCall.Args[8+rewardStatIndex]; got != int64(1) {
t.Fatalf("saved tutorial_reward_progress_38 arg = %#v, want 1", got)
}
rowValues := []any{"tutorial-char-1", "acc-1", 0, "gunner", "2", 1}
for idx := range mysqlCharacterStatColumns {
value := int64(0)
if idx == statIndex || idx == rewardStatIndex {
value = 1
}
rowValues = append(rowValues, value)
}
rowValues = append(rowValues, nil, nil)
loadDB := &fakeSQLDB{rows: []fakeSQLRow{{values: rowValues}}}
loadRepos := newTestMySQLGroup(t, loadDB)
loaded, ok, err := loadRepos.Character.Load(ctx, "tutorial-char-1")
if err != nil || !ok {
t.Fatalf("Load() ok=%v error=%v", ok, err)
}
if got := loaded.Stats["tutorial_completed"]; got != 1 {
t.Fatalf("loaded tutorial_completed = %d, want 1", got)
}
if got := loaded.Stats["tutorial_reward_progress_38"]; got != 1 {
t.Fatalf("loaded tutorial_reward_progress_38 = %d, want 1", got)
}
loadCall := requireExecContaining(t, loadDB, "`dnf_s1_r1`.`dnf_characters`")
assertContains(t, loadCall.Query, "`tutorial_completed`")
assertContains(t, loadCall.Query, "`tutorial_reward_progress_38`")
}
func TestMySQLCharacterCrystalSelectionSaveAndLoadThroughSignedMirrorColumn(t *testing.T) {
ctx := context.Background()
statIndex := requireMySQLCharacterStatIndex(t, "premium_crystal_selection")
for _, test := range []struct {
name string
selection int64
}{
{name: "none", selection: -1},
{name: "black", selection: 0},
} {
selection := test.selection
t.Run(test.name, func(t *testing.T) {
saveDB := &fakeSQLDB{}
saveRepos := newTestMySQLGroup(t, saveDB)
record := repository.CharacterRecord{
CharacterID: "crystal-char-1",
AccountID: "acc-1",
Name: "slayer",
Job: "0",
Level: 1,
Stats: map[string]int64{
"premium_crystal_selection": selection,
},
}
if err := repository.SaveCharacterFields(ctx, saveRepos.Character, record, repository.CharacterFieldStats); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
saveCall := requireExecContaining(t, saveDB, "`dnf_s1_w1`.`dnf_characters`")
assertContains(t, saveCall.Query, "`premium_crystal_selection` = VALUES(`premium_crystal_selection`)")
if got := saveCall.Args[8+statIndex]; got != selection {
t.Fatalf("saved premium_crystal_selection arg = %#v, want %d", got, selection)
}
rowValues := []any{"crystal-char-1", "acc-1", 0, "slayer", "0", 1}
for idx, column := range mysqlCharacterStatColumns {
value := column.fallback
if idx == statIndex {
value = selection
}
rowValues = append(rowValues, value)
}
rowValues = append(rowValues, nil, nil)
loadDB := &fakeSQLDB{rows: []fakeSQLRow{{values: rowValues}}}
loadRepos := newTestMySQLGroup(t, loadDB)
loaded, ok, err := loadRepos.Character.Load(ctx, "crystal-char-1")
if err != nil || !ok {
t.Fatalf("Load() ok=%v error=%v", ok, err)
}
if got := loaded.Stats["premium_crystal_selection"]; got != selection {
t.Fatalf("loaded premium_crystal_selection = %d, want %d", got, selection)
}
})
}
}
func TestMySQLCharacterSaveFieldsUsesRelationalRosterTables(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.CharacterRecord{
CharacterID: "char-1",
AccountID: "acc-1",
Name: "fighter",
Roster: repository.CharacterRoster{
Entry: repository.CharacterRosterEntry{
PackedJobGrow: 0x24,
ByteC: 56,
ObjectID: 17,
Flags: []int64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 4},
},
},
}
if err := repository.SaveCharacterFields(context.Background(), repos.Character, record, repository.CharacterFieldRoster); err != nil {
t.Fatalf("SaveCharacterFields() error = %v", err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_characters`")
assertNotContains(t, call.Query, "`roster_json`")
assertNotContains(t, call.Query, "`stats_json` = VALUES(`stats_json`)")
assertNotContains(t, call.Query, "`location_json` = VALUES(`location_json`)")
assertNotContains(t, call.Query, "`name` = VALUES(`name`)")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_character_rosters`")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_character_roster_lists`")
}
func TestMergeCharacterMirrorStatsUsesOnlyMySQLColumns(t *testing.T) {
record := repository.CharacterRecord{
Stats: map[string]int64{
"grow_type": 7,
"user_state": 9,
"pvp_grade": 8,
"custom_only": 123,
},
}
values := make([]sql.NullInt64, len(mysqlCharacterStatColumns))
for idx, column := range mysqlCharacterStatColumns {
switch column.key {
case "grow_type":
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
case "user_state":
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
case "pvp_grade":
values[idx] = sql.NullInt64{Int64: 0, Valid: true}
}
}
mergeCharacterMirrorStats(&record, values)
if record.Stats["grow_type"] != 0 || record.Stats["user_state"] != 0 || record.Stats["pvp_grade"] != 0 {
t.Fatalf("MySQL mirror columns did not override stale stats_json: %+v", record.Stats)
}
if _, ok := record.Stats["custom_only"]; ok {
t.Fatalf("custom stats_json field leaked into MySQL-only stats: %+v", record.Stats)
}
}
func TestMySQLInventorySaveFieldsOnlyWritesWarehouse(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.InventoryRecord{
CharacterID: "char-1",
Warehouse: map[string]repository.ItemStack{"safe-1": {ItemID: 1001, Count: 2}},
}
if err := repository.SaveInventoryFields(context.Background(), repos.Inventory, record, repository.InventoryFieldWarehouse); err != nil {
t.Fatalf("SaveInventoryFields() error = %v", err)
}
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_inventories`")
insert := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_inventory_items`")
assertContains(t, insert.Query, "collection_name")
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "slots_json")
}
// 一个普通背包保存必须把全部槽位行合并成单条多行 VALUES INSERT;逐行
// ExecContext 是 op26 分解 ~350ms 延迟的主要放大器(93 行循环 vs 单语句)。
func TestMySQLInventorySaveSlotsUsesSingleMultiRowInsert(t *testing.T) {
ctx := context.Background()
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
slots := make(map[string]repository.ItemStack, 93)
for index := 0; index < 93; index++ {
slots[fmt.Sprintf("slot-%02d", index)] = repository.ItemStack{
ItemID: int64(1000 + index),
Count: 1,
RawEntry: make([]byte, 0x77),
Extra: map[string]string{"source": "test"},
}
}
record := repository.InventoryRecord{CharacterID: "char-bulk-slots", Slots: slots}
if err := repository.SaveInventoryFields(ctx, repos.Inventory, record, repository.InventoryFieldSlots); err != nil {
t.Fatalf("SaveInventoryFields() error = %v", err)
}
itemInserts := 0
extraInserts := 0
for _, call := range sqlDB.execs {
switch {
case strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_items`"):
itemInserts++
assertContains(t, call.Query, "), (")
rows := len(call.Args) / 8
if rows == 0 || len(call.Args)%8 != 0 || rows > mysqlReplaceBatchSize {
t.Fatalf("item insert args=%d rows=%d query=%s", len(call.Args), rows, call.Query)
}
case strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_item_extra`"):
extraInserts++
assertContains(t, call.Query, "), (")
rows := len(call.Args) / 5
if rows == 0 || len(call.Args)%5 != 0 || rows > mysqlReplaceBatchSize {
t.Fatalf("extra insert args=%d rows=%d query=%s", len(call.Args), rows, call.Query)
}
}
}
if itemInserts != 1 || extraInserts != 1 {
t.Fatalf("slot writes must be one multi-row INSERT per table; items=%d extra=%d queries=%v",
itemInserts, extraInserts, execQueries(sqlDB))
}
}
// 超过批大小(128 行)时按块拆分,且保持 entry_key 排序顺序。
func TestMySQLInventorySaveSlotsChunksBeyondBatchSize(t *testing.T) {
ctx := context.Background()
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
const total = mysqlReplaceBatchSize + 10
slots := make(map[string]repository.ItemStack, total)
for index := 0; index < total; index++ {
slots[fmt.Sprintf("slot-%04d", index)] = repository.ItemStack{ItemID: int64(2000 + index), Count: 1}
}
record := repository.InventoryRecord{CharacterID: "char-chunked-slots", Slots: slots}
if err := repository.SaveInventoryFields(ctx, repos.Inventory, record, repository.InventoryFieldSlots); err != nil {
t.Fatalf("SaveInventoryFields() error = %v", err)
}
var chunkRows []int
for _, call := range sqlDB.execs {
if !strings.Contains(call.Query, "INSERT INTO `dnf_s1_w1`.`dnf_inventory_items`") {
continue
}
chunkRows = append(chunkRows, len(call.Args)/8)
}
if len(chunkRows) != 2 || chunkRows[0] != mysqlReplaceBatchSize || chunkRows[1] != 10 {
t.Fatalf("expected chunks [%d 10], got %v", mysqlReplaceBatchSize, chunkRows)
}
}
func TestMySQLEquipmentSaveFieldsWritesEntries(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.EquipmentRecord{
CharacterID: "char-1",
Entries: map[string]repository.EquipmentEntry{
"11": {SlotIndex: 11, ItemID: 1001, RawEntry: []byte{1, 2, 3}},
},
}
if err := repository.SaveEquipmentFields(context.Background(), repos.Equipment, record, repository.EquipmentFieldEntries); err != nil {
t.Fatalf("SaveEquipmentFields() error = %v", err)
}
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_equipments`")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_equipment_entries`")
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "entries_json")
}
func TestMySQLSkillSaveFieldsWritesLevelsAndPointsTogether(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.SkillRecord{
CharacterID: "char-1",
Skills: map[int64]repository.SkillState{46: {Level: 2, Enabled: true}},
Points: repository.SkillPointState{TotalSP: 100, RemainingSP: 60},
Layouts: map[int]repository.SkillLayout{0: {0: 46}},
}
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldSkills, repository.SkillFieldPoints, repository.SkillFieldLayouts); err != nil {
t.Fatalf("SaveSkillFields() error = %v", err)
}
root := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skills`")
assertContains(t, root.Query, "`total_sp` = VALUES(`total_sp`)")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_states`")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_layouts`")
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "_json")
}
func TestMySQLSkillSaveFieldsWritesIndependentAlternateTree(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.SkillRecord{
CharacterID: "char-1",
Trees: map[int]repository.SkillTreeState{
1: {
Skills: map[int64]repository.SkillState{47: {Level: 3, Enabled: true}},
Points: repository.SkillPointState{TotalSP: 200, RemainingSP: 80, TotalTP: 20, RemainingTP: 7},
},
},
}
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldTrees); err != nil {
t.Fatalf("SaveSkillFields() error = %v", err)
}
// 列序仍被断言:下面的 Args 断言按下标取值,列序是它们的前提。
// 形状改为批量插入后与 TestMySQLInventorySaveSlotsUsesSingleMultiRowInsert
// 固化的规范一致:标识符由 buildMySQLBulkInsert 加反引号。
points := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_tree_points` (`character_id`, `tree_id`")
if len(points.Args) < 7 || points.Args[1] != 1 || points.Args[2] != 200 || points.Args[3] != 80 || points.Args[4] != 20 || points.Args[5] != 7 {
t.Fatalf("alternate-tree point args = %#v", points.Args)
}
states := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_skill_tree_states` (`character_id`, `tree_id`")
if len(states.Args) < 5 || states.Args[1] != 1 || states.Args[2] != int64(47) || states.Args[3] != 3 || states.Args[4] != true {
t.Fatalf("alternate-tree skill args = %#v", states.Args)
}
}
func TestMySQLSkillSaveFieldsWritesCommandBytes(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.SkillRecord{
CharacterID: "char-1",
Commands: map[int64][]byte{46: {0, 4, 0xff}},
}
if err := repository.SaveSkillFields(context.Background(), repos.Skill, record, repository.SkillFieldCommands); err != nil {
t.Fatalf("SaveSkillFields() error = %v", err)
}
queries := strings.Join(execQueries(sqlDB), "\n")
if !strings.Contains(queries, "INSERT INTO `dnf_s1_w1`.`dnf_skill_commands` (`character_id`, `skill_id`, `command_bytes`)") {
t.Fatalf("command write queries=%q", queries)
}
}
func TestMySQLPetSaveFieldsWritesEntriesAndDisplay(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.PetRecord{
CharacterID: "char-1",
Entries: map[string]repository.PetEntry{
"pet-1": {PetKey: "pet-1", ItemID: 1001},
},
EquippedKey: "pet-1",
TownDisplay: true,
}
if err := repository.SavePetFields(context.Background(), repos.Pet, record, repository.PetFieldEntries, repository.PetFieldEquipped, repository.PetFieldDisplay); err != nil {
t.Fatalf("SavePetFields() error = %v", err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_pets`")
assertContains(t, call.Query, "`equipped_key` = VALUES(`equipped_key`)")
assertContains(t, call.Query, "`town_display` = VALUES(`town_display`)")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_pet_entries`")
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "entries_json")
}
func TestMySQLMailboxSaveFieldsWritesMails(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
record := repository.MailboxRecord{
CharacterID: "char-1",
Mails: map[string]repository.MailRecord{
"mail-1": {MailID: "mail-1", Title: "hello", Gold: 100},
},
}
if err := repository.SaveMailboxFields(context.Background(), repos.Mailbox, record, repository.MailboxFieldMails); err != nil {
t.Fatalf("SaveMailboxFields() error = %v", err)
}
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_mailboxes`")
requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_mails`")
assertNotContains(t, strings.Join(execQueries(sqlDB), "\n"), "mails_json")
}
func TestMySQLMailboxLoadReadsMails(t *testing.T) {
sqlDB := &fakeSQLDB{
rows: []fakeSQLRow{{
values: []any{
"char-1",
time.Date(2026, 7, 20, 2, 0, 0, 0, time.UTC),
},
}},
rowsets: []fakeSQLRows{{rows: []fakeSQLRow{{values: []any{
"mail-1", "", "", "", "", "hello", "", int64(100), false, false, false, nil, nil,
}}}}},
}
repos := newTestMySQLGroup(t, sqlDB)
record, ok, err := repos.Mailbox.Load(context.Background(), "char-1")
if err != nil || !ok {
t.Fatalf("Load() ok=%v error=%v", ok, err)
}
if record.Mails["mail-1"].Gold != 100 || record.Mails["mail-1"].Title != "hello" {
t.Fatalf("mailbox = %+v", record)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_mailboxes`")
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_mailboxes`")
}
func TestMySQLAccountLoadUsesReadDatabase(t *testing.T) {
sqlDB := &fakeSQLDB{
rows: []fakeSQLRow{{
values: []any{
"acc-1",
"open",
uint64(123456789),
"adventure-one",
time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC),
time.Date(2026, 6, 29, 2, 0, 0, 0, time.UTC),
},
}},
rowsets: []fakeSQLRows{{rows: []fakeSQLRow{{values: []any{"source", "unit"}}}}},
}
repos := newTestMySQLGroup(t, sqlDB)
record, ok, err := repos.Account.Load(context.Background(), "acc-1")
if err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
if record.Metadata["source"] != "unit" || record.State != "open" || record.HonorExp != 123456789 || record.RepresentAccountName != "adventure-one" {
t.Fatalf("unexpected account: %+v", record)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_r1`.`dnf_accounts`")
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_accounts`")
assertNotContains(t, call.Query, "FOR UPDATE")
}
func TestReplaceQuestStatesUsesBoundedMultiRowInsertBatches(t *testing.T) {
sqlDB := &fakeSQLDB{}
states := make(map[int64]repository.QuestState, 129)
for questID := int64(1); questID <= 129; questID++ {
states[questID] = repository.QuestState{
Status: "active",
TriggerType: 1,
ProgressValue: questID,
Extra: map[string]string{"completion_key": "batch-test"},
}
}
if err := replaceQuestStates(context.Background(), sqlDB, "quest_states", "quest_extra", "char-1", "states", states); err != nil {
t.Fatal(err)
}
if len(sqlDB.execs) != 6 {
t.Fatalf("exec count=%d want 6 (2 deletes + 2 state batches + 2 extra batches)", len(sqlDB.execs))
}
stateInserts, extraInserts := 0, 0
for _, call := range sqlDB.execs {
switch {
case strings.Contains(call.Query, "INSERT INTO quest_states"):
stateInserts++
if len(call.Args) > 128*9 {
t.Fatalf("state batch args=%d exceeds bounded batch", len(call.Args))
}
case strings.Contains(call.Query, "INSERT INTO quest_extra"):
extraInserts++
if len(call.Args) > 128*5 {
t.Fatalf("extra batch args=%d exceeds bounded batch", len(call.Args))
}
}
}
if stateInserts != 2 || extraInserts != 2 {
t.Fatalf("state inserts=%d extra inserts=%d", stateInserts, extraInserts)
}
}
// replaceStringMap 的差异写:**值没变的行一个字节都不写**。
//
// 这条是数据库那批移植里风险最高的分支:默认的假 driver 读回来是"没有行",等于只覆盖了
// "全是新增";只有喂进一份与输入相同的现有行集,才能证明"相等 → 不删也不插"。
// (上游对这条的证明放在 DNF_MYSQL_LIVE_DSN 的真库往返用例里,见 live_bulk_roundtrip_test.go;
// 本树运行时 MySQL 当时没起,所以补一条不依赖真库的等价断言。)
func TestReplaceStringMapWritesOnlyChangedKeys(t *testing.T) {
ctx := context.Background()
database := &fakeSQLDB{rowsets: []fakeSQLRows{{rows: []fakeSQLRow{
{values: []any{"keep", "same"}},
{values: []any{"edit", "old"}},
{values: []any{"drop", "gone"}},
}}}}
err := replaceStringMap(ctx, database, "account_metadata", "character_id", "77", map[string]string{
"keep": "same", // 未变:一个语句里都不该出现
"edit": "new", // 改值:删掉再插回
"fresh": "added", // 新增:只插
// "drop" 从输入里消失:只删
})
if err != nil {
t.Fatalf("replaceStringMap() error = %v", err)
}
deletes, inserts := 0, 0
insertedRows := make([]string, 0, 2)
for _, call := range database.execs {
switch {
case strings.HasPrefix(call.Query, "DELETE FROM account_metadata"):
deletes++
assertContains(t, call.Query, "IN (")
// 只应删「要重写的」与「已消失的」:edit / fresh / drop —— 不含 keep。
if containsArg(call.Args, "keep") {
t.Fatalf("未变化的键被删除: args=%v", call.Args)
}
for _, key := range []string{"edit", "fresh", "drop"} {
if !containsArg(call.Args, key) {
t.Fatalf("键 %q 应被删除: args=%v", key, call.Args)
}
}
case strings.HasPrefix(call.Query, "INSERT INTO account_metadata"):
inserts++
assertContains(t, call.Query, "), (")
if len(call.Args)%3 != 0 {
t.Fatalf("insert args=%v", call.Args)
}
for index := 0; index < len(call.Args); index += 3 {
insertedRows = append(insertedRows, call.Args[index+1].(string))
if containsArg([]any{call.Args[index+1]}, "keep") {
t.Fatalf("未变化的键被重写: args=%v", call.Args)
}
}
}
}
if deletes != 1 || inserts != 1 {
t.Fatalf("差异写应恰好一次 DELETE + 一次 INSERT,实际 deletes=%d inserts=%d queries=%v",
deletes, inserts, execQueries(database))
}
if !slices.Equal(insertedRows, []string{"edit", "fresh"}) {
t.Fatalf("只应重写变化/新增的键,实际插入 %v", insertedRows)
}
}
// 宠物保存的差异写:**没变的主行、附加键、清除令牌一行都不写**。
//
// 这是 0897707 要证明的核心 —— 上游抓包显示"每次宠物保存都会重写全部宠物连同它们
// 119 个清除令牌,只为改一个饱食度"。这里喂进一只完全没变的宠物(带 3 个清除令牌)、
// 一只只改了饱食度的宠物、一只新增的、一只被删掉的,逐一断言各部分的写法。
func TestReplacePetEntriesWritesOnlyChangedPetsAndParts(t *testing.T) {
ctx := context.Background()
entryRow := func(key string, satiety byte, extra ...any) fakeSQLRow {
values := []any{
key, uint32(5001), int64(900001), byte(0), int16(9),
"", []byte(nil), satiety, int64(0), byte(0), byte(0), byte(0),
int64(1), int64(0), byte(0), []byte(nil),
}
return fakeSQLRow{values: append(values, extra...)}
}
database := &fakeSQLDB{rowsets: []fakeSQLRows{
// 现有 pet_entries:unchanged(饱食度 50)与 shrunk(饱食度 50,输入改成 40)
{rows: []fakeSQLRow{entryRow("unchanged", 50), entryRow("shrunk", 50), entryRow("dropped", 7)}},
// 现有 pet_extra:unchanged 有一条
{rows: []fakeSQLRow{{values: []any{"unchanged", "k", "v"}}}},
// 现有 pet_clear_tokens:unchanged 三个令牌(顺序 t1,t2,t3)
{rows: []fakeSQLRow{
{values: []any{"unchanged", "t1", true}},
{values: []any{"unchanged", "t2", false}},
{values: []any{"unchanged", "t3", true}},
}},
}}
unchanged := repository.PetEntry{
PetKey: "unchanged", CreatureKey: 5001, ItemID: 900001, SourceSlotIndex: 9,
Satiety: 50, Level: 1,
Extra: map[string]string{"k": "v"},
AppliedClearTokens: map[string]bool{"t1": true, "t2": false, "t3": true},
AppliedClearTokenOrder: []string{"t1", "t2", "t3"},
}
// shrunk 的持久状态只有主行(无附加键、无令牌),输入只改了饱食度 50→40。
shrunk := repository.PetEntry{
PetKey: "shrunk", CreatureKey: 5001, ItemID: 900001, SourceSlotIndex: 9,
Satiety: 40, Level: 1,
}
added := unchanged
added.PetKey = "added"
err := replacePetEntries(ctx, database, "pet_entries", "pet_extra", "pet_clear_tokens", "77",
map[string]repository.PetEntry{"unchanged": unchanged, "shrunk": shrunk, "added": added})
if err != nil {
t.Fatalf("replacePetEntries() error = %v", err)
}
for _, call := range database.execs {
isWrite := strings.HasPrefix(call.Query, "DELETE FROM") || strings.HasPrefix(call.Query, "INSERT INTO")
if !isWrite {
continue // 三次 FOR UPDATE 读
}
if containsArg(call.Args, "unchanged") {
t.Fatalf("完全没变的宠物被写了: query=%s args=%v", call.Query, call.Args)
}
}
// shrunk 只改了饱食度 ⇒ 只有主行重写,令牌一行不动。
// added 是新键 ⇒ 三部分都写。dropped 从输入里消失 ⇒ 三部分都删。
var entryDeletes, tokenDeletes, entryInserts, tokenInserts int
for _, call := range database.execs {
switch {
case strings.HasPrefix(call.Query, "DELETE FROM pet_entries"):
entryDeletes++
for _, key := range []string{"shrunk", "added", "dropped"} {
if !containsArg(call.Args, key) {
t.Fatalf("主行应删除 %q: args=%v", key, call.Args)
}
}
case strings.HasPrefix(call.Query, "DELETE FROM pet_clear_tokens"):
tokenDeletes++
if containsArg(call.Args, "shrunk") {
t.Fatalf("只改了饱食度的宠物不应重写清除令牌: args=%v", call.Args)
}
if !containsArg(call.Args, "dropped") || !containsArg(call.Args, "added") {
t.Fatalf("被删与新增的宠物都该重写清除令牌: args=%v", call.Args)
}
case strings.HasPrefix(call.Query, "INSERT INTO pet_entries"):
entryInserts++
for index := 1; index < len(call.Args); index += 17 {
key, _ := call.Args[index].(string)
if key == "unchanged" || key == "dropped" {
t.Fatalf("只应重插变化/新增的主行,出现 %q", key)
}
}
case strings.HasPrefix(call.Query, "INSERT INTO pet_clear_tokens"):
tokenInserts++
if containsArg(call.Args, "unchanged") || containsArg(call.Args, "shrunk") {
t.Fatalf("未变化的令牌不应重插: args=%v", call.Args)
}
}
}
if entryDeletes != 1 || entryInserts != 1 || tokenDeletes != 1 || tokenInserts != 1 {
t.Fatalf("语句条数 entryDel=%d entryIns=%d tokenDel=%d tokenIns=%d queries=%v",
entryDeletes, entryInserts, tokenDeletes, tokenInserts, execQueries(database))
}
}
// 背包与装备的差异写:**没变的行一条语句都不发**(c0ebdce)。
//
// 上游的证明在 DNF_MYSQL_LIVE_DSN 真库用例里;这里用能返回行集的假 driver 做等价断言 ——
// 默认的假 driver 读回来是"没有行",只覆盖"全是新增",恰好漏掉最该防的那条分支。
func TestDiffCollectionWritersTouchOnlyChangedRows(t *testing.T) {
ctx := context.Background()
stack := func(itemID, count int64) repository.ItemStack {
return repository.ItemStack{ItemID: itemID, Count: count}
}
database := &fakeSQLDB{rowsets: []fakeSQLRows{
// 现有 inventory_items:keep(会被原样写回)与 edit(数量变了)、drop(输入里没了)
{rows: []fakeSQLRow{
{values: []any{"keep", int64(100), int64(1), false, nil, []byte(nil)}},
{values: []any{"edit", int64(200), int64(1), false, nil, []byte(nil)}},
{values: []any{"drop", int64(300), int64(1), false, nil, []byte(nil)}},
}},
// 现有 inventory_item_extra:空
{rows: nil},
}}
err := replaceItemStackCollection(ctx, database, "inventory_items", "inventory_item_extra",
"character_id", "77", "", false, map[string]repository.ItemStack{
"keep": stack(100, 1), // 未变
"edit": stack(200, 5), // 数量变了
"fresh": stack(400, 1), // 新增
// "drop" 消失
})
if err != nil {
t.Fatalf("replaceItemStackCollection() error = %v", err)
}
for _, call := range database.execs {
if !strings.HasPrefix(call.Query, "DELETE FROM") && !strings.HasPrefix(call.Query, "INSERT INTO") {
continue
}
if containsArg(call.Args, "keep") {
t.Fatalf("没变的背包行被写了: query=%s args=%v", call.Query, call.Args)
}
}
// 装备同理。
equipmentDB := &fakeSQLDB{rowsets: []fakeSQLRows{
{rows: []fakeSQLRow{
{values: []any{"7", int16(7), int64(900), false, nil, []byte(nil)}},
{values: []any{"13", int16(13), int64(901), false, nil, []byte(nil)}},
}},
{rows: nil},
}}
unchangedEntry := repository.EquipmentEntry{SlotIndex: 7, ItemID: 900}
changedEntry := repository.EquipmentEntry{SlotIndex: 13, ItemID: 999}
if err := replaceEquipmentEntries(ctx, equipmentDB, "equipment_entries", "equipment_extra", "77",
map[string]repository.EquipmentEntry{"7": unchangedEntry, "13": changedEntry}); err != nil {
t.Fatalf("replaceEquipmentEntries() error = %v", err)
}
sawWrite := false
for _, call := range equipmentDB.execs {
if strings.HasPrefix(call.Query, "DELETE FROM") || strings.HasPrefix(call.Query, "INSERT INTO") {
sawWrite = true
if containsArg(call.Args, "7") {
t.Fatalf("没变的装备条目被写了: query=%s args=%v", call.Query, call.Args)
}
if !containsArg(call.Args, "13") {
t.Fatalf("变化的装备条目应被写: query=%s args=%v", call.Query, call.Args)
}
}
}
if !sawWrite {
t.Fatal("变化的装备条目一条语句都没发")
}
}
func containsArg(args []any, want string) bool {
for _, arg := range args {
if value, ok := arg.(string); ok && value == want {
return true
}
}
return false
}
func TestMySQLAccountLoadLocksCurrentRowInsideTransaction(t *testing.T) {
sqlDB := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{
"acc-1",
"open",
uint64(123),
nil,
nil,
nil,
}}}}
store := &mysqlAccountStore{mysqlStoreBase: mysqlStoreBase{router: mysqlRouter{
db: sqlDB,
readDBs: []string{"dnf_s1_w1"},
writeDBs: []string{"dnf_s1_w1"},
tablePrefix: "dnf",
lockReads: true,
}}}
if _, ok, err := store.Load(context.Background(), "acc-1"); err != nil || !ok {
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_accounts`")
assertContains(t, call.Query, "FOR UPDATE")
}
func TestMySQLAccountSaveWritesHonorExp(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
if err := repos.Account.Save(context.Background(), repository.AccountRecord{
AccountID: "acc-1",
State: "open",
HonorExp: 17699999999,
RepresentAccountName: "adventure-one",
}); err != nil {
t.Fatalf("Save() error = %v", err)
}
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_accounts`")
assertContains(t, call.Query, "`honor_exp`")
assertContains(t, call.Query, "`represent_account_name`")
if len(call.Args) < 3 || call.Args[2] != uint64(17699999999) {
t.Fatalf("honor_exp args = %#v", call.Args)
}
if len(call.Args) < 4 || call.Args[3] != "adventure-one" {
t.Fatalf("represent_account_name args = %#v", call.Args)
}
}
func TestMySQLAccountFindIDByRepresentNameUsesReadDatabase(t *testing.T) {
sqlDB := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{"acc-1"}}}}
repos := newTestMySQLGroup(t, sqlDB)
finder := repos.Account.(repository.RepresentAccountNameFinder)
accountID, found, err := finder.FindAccountIDByRepresentName(context.Background(), "adventure-one")
if err != nil || !found || accountID != "acc-1" {
t.Fatalf("FindAccountIDByRepresentName() account=%q found=%v err=%v", accountID, found, err)
}
call := requireOneExec(t, sqlDB)
assertContains(t, call.Query, "`dnf_s1_r1`.`dnf_accounts`")
assertContains(t, call.Query, "represent_account_name = ?")
}
func TestMySQLAccountFindIDByLoginUsernameUsesLoginMapping(t *testing.T) {
database := &fakeSQLDB{rows: []fakeSQLRow{{values: []any{"dnf:1"}}}}
router, err := newMySQLRouter(database, MySQLGroupOptions{
TablePrefix: "dnf",
DatabasePlan: repository.DatabasePlan{
ReadDatabases: []string{"dnf_local"},
WriteDatabases: []string{"dnf_local"},
},
})
if err != nil {
t.Fatal(err)
}
accountID, found, err := (&mysqlAccountStore{mysqlStoreBase{router: router}}).FindAccountIDByLoginUsername(context.Background(), " ACCOUNT-ONE ")
if err != nil || !found || accountID != "dnf:1" {
t.Fatalf("FindAccountIDByLoginUsername() account=%q found=%t error=%v", accountID, found, err)
}
if len(database.execs) != 1 || !strings.Contains(database.execs[0].Query, "dnf_login_accounts") ||
len(database.execs[0].Args) != 1 || database.execs[0].Args[0] != "account-one" {
t.Fatalf("login lookup query = %+v", database.execs)
}
}
func TestMySQLCharacterLoadNoRows(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
_, ok, err := repos.Character.Load(context.Background(), "missing-char")
if err != nil || ok {
t.Fatalf("Load() ok=%v err=%v, want no row", ok, err)
}
}
func TestMySQLPacketSaveClonesBodyArg(t *testing.T) {
sqlDB := &fakeSQLDB{}
repos := newTestMySQLGroup(t, sqlDB)
body := []byte{1, 2, 3}
if err := repos.PacketTemplate.Save(context.Background(), repository.PacketTemplateRecord{
TemplateID: "tpl-1",
Name: "login",
Body: body,
}); err != nil {
t.Fatalf("Save() error = %v", err)
}
body[0] = 9
call := requireExecContaining(t, sqlDB, "`dnf_s1_w1`.`dnf_packet_templates`")
got := call.Args[2].([]byte)
if got[0] != 1 {
t.Fatalf("body arg should be cloned, got %v", got)
}
}
func newTestMySQLGroup(t *testing.T, sqlDB *fakeSQLDB) repository.Group {
t.Helper()
repos, err := NewMySQLGroup(sqlDB, MySQLGroupOptions{
DatabasePlan: repository.DatabasePlan{
WriteDatabases: []string{"dnf_s1_w1"},
ReadDatabases: []string{"dnf_s1_r1"},
},
Now: func() time.Time {
return time.Date(2026, 6, 29, 3, 0, 0, 0, time.UTC)
},
})
if err != nil {
t.Fatalf("NewMySQLGroup() error = %v", err)
}
return repos
}
func requireOneExec(t *testing.T, sqlDB *fakeSQLDB) fakeSQLCall {
t.Helper()
if len(sqlDB.execs) != 1 {
t.Fatalf("exec count = %d, want 1", len(sqlDB.execs))
}
return sqlDB.execs[0]
}
func requireExecContaining(t *testing.T, sqlDB *fakeSQLDB, needle string) fakeSQLCall {
t.Helper()
for _, call := range sqlDB.execs {
if strings.Contains(call.Query, needle) {
return call
}
}
t.Fatalf("no SQL call contains %q; calls=%v", needle, execQueries(sqlDB))
return fakeSQLCall{}
}
func execQueries(sqlDB *fakeSQLDB) []string {
queries := make([]string, 0, len(sqlDB.execs))
for _, call := range sqlDB.execs {
queries = append(queries, call.Query)
}
return queries
}
func assertContains(t *testing.T, value, want string) {
t.Helper()
if !strings.Contains(value, want) {
t.Fatalf("value missing %q:\n%s", want, value)
}
}
func assertNotContains(t *testing.T, value, want string) {
t.Helper()
if strings.Contains(value, want) {
t.Fatalf("value unexpectedly contains %q:\n%s", want, value)
}
}
func requireMySQLCharacterStatIndex(t *testing.T, key string) int {
t.Helper()
for idx, column := range mysqlCharacterStatColumns {
if column.key == key {
return idx
}
}
t.Fatalf("mysql character stat column %q not found", key)
return -1
}