服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
744 lines
25 KiB
Go
744 lines
25 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dungeonpermission"
|
|
"longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/repository/memory"
|
|
)
|
|
|
|
func TestComponentPersistsRepositoriesAcrossRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
group, ok := component.Group()
|
|
if !ok {
|
|
t.Fatal("repository group is unavailable")
|
|
}
|
|
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1", State: "active"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
creator := group.Character.(repository.CharacterCreator)
|
|
if err := creator.CreateCharacter(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001", AccountID: "account-1", Slot: 0, Name: "hero", Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
|
CharacterID: "1001",
|
|
Slots: map[string]repository.ItemStack{"0:0": {ItemID: 1234, Count: 2}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := group.DungeonPermission.UpsertMax(ctx, "1001", 55, 3); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := component.Snapshot().RecordCount; got != 4 {
|
|
t.Fatalf("record count = %d, want 4", got)
|
|
}
|
|
if err := component.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restarted, restartedDB := startTestComponent(t, path)
|
|
defer restartedDB.Close()
|
|
defer restarted.Stop(ctx)
|
|
restored, _ := restarted.Group()
|
|
character, found, err := restored.Character.Load(ctx, "1001")
|
|
if err != nil || !found || character.Name != "hero" || character.Level != 90 {
|
|
t.Fatalf("restored character = %+v found=%t err=%v", character, found, err)
|
|
}
|
|
inventory, found, err := restored.Inventory.Load(ctx, "1001")
|
|
if err != nil || !found || inventory.Slots["0:0"].Count != 2 {
|
|
t.Fatalf("restored inventory = %+v found=%t err=%v", inventory, found, err)
|
|
}
|
|
permission, found, err := restored.DungeonPermission.Load(ctx, "1001")
|
|
if err != nil || !found || len(permission.Entries) != 1 || permission.Entries[0].ClearState != 3 {
|
|
t.Fatalf("restored permission = %+v found=%t err=%v", permission, found, err)
|
|
}
|
|
}
|
|
|
|
func TestComponentPersistsCaseSensitiveUnitedFriendsAcrossRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
group, ok := component.Group()
|
|
if !ok {
|
|
t.Fatal("repository group is unavailable")
|
|
}
|
|
if err := group.UnitedFriend.Save(ctx, repository.UnitedFriendRecord{
|
|
OwnerName: "Owner",
|
|
FriendNames: []string{"Friend", "friend"},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := component.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restarted, restartedDB := startTestComponent(t, path)
|
|
defer restartedDB.Close()
|
|
defer restarted.Stop(ctx)
|
|
restored, _ := restarted.Group()
|
|
record, found, err := restored.UnitedFriend.Load(ctx, "Owner")
|
|
if err != nil || !found || len(record.FriendNames) != 2 ||
|
|
record.FriendNames[0] != "Friend" || record.FriendNames[1] != "friend" {
|
|
t.Fatalf("restored united friends=%+v found=%t err=%v", record, found, err)
|
|
}
|
|
upper, err := restored.UnitedFriend.ListObservers(ctx, "Friend")
|
|
if err != nil || len(upper) != 1 || upper[0] != "Owner" {
|
|
t.Fatalf("uppercase observers=%v err=%v", upper, err)
|
|
}
|
|
lower, err := restored.UnitedFriend.ListObservers(ctx, "friend")
|
|
if err != nil || len(lower) != 1 || lower[0] != "Owner" {
|
|
t.Fatalf("lowercase observers=%v err=%v", lower, err)
|
|
}
|
|
}
|
|
|
|
func TestComponentPersistsAccountDungeonPermissionOwnerAcrossRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
group, ok := component.Group()
|
|
if !ok {
|
|
t.Fatal("repository group is unavailable")
|
|
}
|
|
accountKey, err := dungeonpermission.AccountOwnerKey("account-shared")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, changed, err := group.DungeonPermission.UpsertMax(ctx, accountKey, 700, 4); err != nil || !changed {
|
|
t.Fatalf("account permission changed=%t err=%v", changed, err)
|
|
}
|
|
if err := component.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restarted, restartedDB := startTestComponent(t, path)
|
|
defer restartedDB.Close()
|
|
defer restarted.Stop(ctx)
|
|
restored, _ := restarted.Group()
|
|
permission, found, err := restored.DungeonPermission.Load(ctx, accountKey)
|
|
if err != nil || !found || permission.CharacterID != accountKey ||
|
|
len(permission.Entries) != 1 || permission.Entries[0].DungeonID != 700 ||
|
|
permission.Entries[0].ClearState != 4 {
|
|
t.Fatalf("restored account permission=%+v found=%t err=%v", permission, found, err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteAccountFindIDByLoginUsernameUsesLoginMapping(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
if _, err := database.ExecContext(ctx, `CREATE TABLE dnf_login_accounts (
|
|
account_id TEXT NOT NULL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
state TEXT NOT NULL
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := database.ExecContext(ctx, `INSERT INTO dnf_login_accounts (account_id, username, state) VALUES (?, ?, ?)`, "dnf:1", "account-one", "active"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
group, ok := component.Group()
|
|
if !ok {
|
|
t.Fatal("repository group is unavailable")
|
|
}
|
|
finder, ok := group.Account.(repository.LoginAccountUsernameFinder)
|
|
if !ok {
|
|
t.Fatal("SQLite account repository has no login username finder")
|
|
}
|
|
accountID, found, err := finder.FindAccountIDByLoginUsername(ctx, " ACCOUNT-ONE ")
|
|
if err != nil || !found || accountID != "dnf:1" {
|
|
t.Fatalf("FindAccountIDByLoginUsername() account=%q found=%t error=%v", accountID, found, err)
|
|
}
|
|
}
|
|
|
|
func TestComponentPeriodicAndStopSyncKeepLatestSaveAcrossRestart(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
previousInterval := periodicSyncEvery
|
|
periodicSyncEvery = 10 * time.Millisecond
|
|
defer func() { periodicSyncEvery = previousInterval }()
|
|
|
|
component, database := startTestComponent(t, path)
|
|
group, ok := component.Group()
|
|
if !ok {
|
|
t.Fatal("repository group is unavailable")
|
|
}
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Name: "latest",
|
|
Level: 15,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for component.Snapshot().SyncCount == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
periodicSnapshot := component.Snapshot()
|
|
if periodicSnapshot.SyncCount == 0 || periodicSnapshot.LastSyncAt == "" || periodicSnapshot.LastSyncError != "" {
|
|
t.Fatalf("periodic sync snapshot = %+v", periodicSnapshot)
|
|
}
|
|
if err := component.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stoppedSnapshot := component.Snapshot()
|
|
if stoppedSnapshot.SyncCount <= periodicSnapshot.SyncCount || stoppedSnapshot.LastSyncError != "" {
|
|
t.Fatalf("stop sync snapshot = %+v, periodic = %+v", stoppedSnapshot, periodicSnapshot)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if walInfo, err := os.Stat(path + "-wal"); err == nil && walInfo.Size() > 0 {
|
|
t.Fatalf("WAL still contains %d bytes after stop", walInfo.Size())
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restarted, restartedDB := startTestComponent(t, path)
|
|
defer restartedDB.Close()
|
|
defer restarted.Stop(ctx)
|
|
restored, _ := restarted.Group()
|
|
character, found, err := restored.Character.Load(ctx, "1001")
|
|
if err != nil || !found || character.Name != "latest" || character.Level != 15 {
|
|
t.Fatalf("restored character = %+v found=%t err=%v", character, found, err)
|
|
}
|
|
}
|
|
|
|
func TestOrdinarySaveIsDirtyUntilBackgroundSync(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Name: "async",
|
|
Level: 11,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !component.Snapshot().Dirty {
|
|
t.Fatal("ordinary save was not marked dirty")
|
|
}
|
|
var count int
|
|
if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = 'character' AND record_key = '1001'`).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("ordinary save reached SQLite before background sync: count=%d", count)
|
|
}
|
|
if err := component.syncRuntime(ctx, checkpointModePassive, sqliteSnapshotSyncSourcePeriodic); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if component.Snapshot().Dirty {
|
|
t.Fatal("successful background sync left dirty state")
|
|
}
|
|
if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = 'character' AND record_key = '1001'`).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("background sync did not persist ordinary save: count=%d", count)
|
|
}
|
|
}
|
|
|
|
func TestCharacterAssetsSyncPersistsOnlySelectedCharacterDelta(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
|
|
// Ordinary saves are intentionally deferred. They model unrelated scene
|
|
// state already waiting for SQLite's periodic sync when a pickup arrives.
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Stats: map[string]int64{"gold": 1},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{
|
|
CharacterID: "1001",
|
|
Slots: map[string]repository.ItemStack{},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
|
CharacterID: "unrelated",
|
|
States: map[int64]repository.QuestState{7: {Status: "active"}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scopedAssets, ok := group.CharacterAssets.(repository.CharacterAssetScopedUnitOfWork)
|
|
if !ok {
|
|
t.Fatal("SQLite character assets do not expose the scoped pickup transaction")
|
|
}
|
|
err := scopedAssets.WithinCharacterAssetsScoped(ctx, "1001", func(
|
|
_ repository.CharacterRepository,
|
|
inventories repository.InventoryRepository,
|
|
_ repository.EquipmentRepository,
|
|
) error {
|
|
inventory, found, loadErr := inventories.Load(ctx, "1001")
|
|
if loadErr != nil || !found {
|
|
return fmt.Errorf("load selected inventory found=%t: %w", found, loadErr)
|
|
}
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]repository.ItemStack)
|
|
}
|
|
inventory.Slots["0:121"] = repository.ItemStack{ItemID: 3227, Count: 1}
|
|
return inventories.Save(ctx, inventory)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []struct {
|
|
kind string
|
|
key string
|
|
}{
|
|
{kind: kindCharacter, key: "1001"},
|
|
{kind: kindInventory, key: "1001"},
|
|
} {
|
|
var count int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = ? AND record_key = ?`,
|
|
want.kind,
|
|
want.key,
|
|
).Scan(&count); err != nil || count != 1 {
|
|
t.Fatalf("durable %s %q count=%d err=%v", want.kind, want.key, count, err)
|
|
}
|
|
}
|
|
var unrelatedCount int
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dnf_repository_records WHERE record_kind = ? AND record_key = ?`,
|
|
kindQuest,
|
|
"unrelated",
|
|
).Scan(&unrelatedCount); err != nil || unrelatedCount != 0 {
|
|
t.Fatalf("unrelated async record count=%d err=%v", unrelatedCount, err)
|
|
}
|
|
if !component.Snapshot().Dirty {
|
|
t.Fatal("selected character sync cleared unrelated pending SQLite state")
|
|
}
|
|
}
|
|
|
|
func TestQuestStateEntryWriterMergesObjectivesWithoutStaleFullRecordOverwrite(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
if err := group.Quest.Save(ctx, repository.QuestRecord{
|
|
CharacterID: "1001",
|
|
States: map[int64]repository.QuestState{
|
|
101: {Status: "active", ProgressValue: 3, Extra: map[string]string{"source": "seed-a"}},
|
|
102: {Status: "active", ProgressValue: 5, Extra: map[string]string{"source": "seed-b"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer, ok := group.Quest.(repository.QuestStateEntryWriter)
|
|
if !ok {
|
|
t.Fatal("SQLite quest repository has no single-entry writer")
|
|
}
|
|
firstAt := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
|
secondAt := firstAt.Add(time.Second)
|
|
if err := writer.SaveQuestStateEntry(ctx, "1001", repository.QuestFieldStates, 101, repository.QuestState{
|
|
Status: "active", ProgressValue: 2, UpdatedAt: firstAt, Extra: map[string]string{"source": "op33"},
|
|
}, firstAt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.SaveQuestStateEntry(ctx, "1001", repository.QuestFieldStates, 102, repository.QuestState{
|
|
Status: "active", ProgressValue: 4, UpdatedAt: secondAt, Extra: map[string]string{"source": "drop"},
|
|
}, secondAt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, found, err := group.Quest.Load(ctx, "1001")
|
|
if err != nil || !found {
|
|
t.Fatalf("load merged quest found=%t err=%v", found, err)
|
|
}
|
|
if got := record.States[101]; got.ProgressValue != 2 || got.Extra["source"] != "op33" {
|
|
t.Fatalf("quest 101=%+v", got)
|
|
}
|
|
if got := record.States[102]; got.ProgressValue != 4 || got.Extra["source"] != "drop" {
|
|
t.Fatalf("quest 102=%+v", got)
|
|
}
|
|
if !record.UpdatedAt.Equal(secondAt) {
|
|
t.Fatalf("quest updated_at=%s want=%s", record.UpdatedAt, secondAt)
|
|
}
|
|
}
|
|
|
|
func TestCriticalUnitOfWorkFlushesPendingAndCurrentStateImmediately(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !component.Snapshot().Dirty {
|
|
t.Fatal("ordinary inventory seed was not dirty")
|
|
}
|
|
if err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
|
record, found, err := inventory.Load(ctx, "1001")
|
|
if err != nil || !found {
|
|
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
|
}
|
|
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9001, Count: 2}}
|
|
return inventory.Save(ctx, record)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if component.Snapshot().Dirty {
|
|
t.Fatal("critical unit of work did not flush pending state")
|
|
}
|
|
state, err := loadState(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(state.Inventories) != 1 || state.Inventories[0].Slots["0:0"].Count != 2 {
|
|
t.Fatalf("critical state was not immediately persisted: %+v", state.Inventories)
|
|
}
|
|
}
|
|
|
|
func TestCriticalUnitOfWorkFlushesUnrelatedPendingState(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Name: "pending-character",
|
|
Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
|
record, found, err := inventory.Load(ctx, "1001")
|
|
if err != nil || !found {
|
|
return fmt.Errorf("load inventory found=%t: %w", found, err)
|
|
}
|
|
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9001, Count: 1}}
|
|
return inventory.Save(ctx, record)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := loadState(ctx, database)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(state.Characters) != 1 || state.Characters[0].Name != "pending-character" {
|
|
t.Fatalf("unrelated pending character was not flushed: %+v", state.Characters)
|
|
}
|
|
if len(state.Inventories) != 1 || state.Inventories[0].Slots["0:0"].ItemID != 9001 {
|
|
t.Fatalf("critical inventory was not flushed: %+v", state.Inventories)
|
|
}
|
|
}
|
|
|
|
func TestCriticalUnitOfWorkAllowsOuterRepositoryReadWithoutSelfDeadlock(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(context.Background())
|
|
group, _ := component.Group()
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Name: "outer-read",
|
|
Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
|
// This deliberately uses the outer SQLite wrapper, matching the op19
|
|
// placement validator that used to re-enter Component.mu forever.
|
|
character, found, err := group.Character.Load(ctx, "1001")
|
|
if err != nil || !found || character.Level != 90 {
|
|
return fmt.Errorf("load outer character found=%t level=%d: %w", found, character.Level, err)
|
|
}
|
|
record, found, err := inventory.Load(ctx, "1001")
|
|
if err != nil || !found {
|
|
return fmt.Errorf("load transaction inventory found=%t: %w", found, err)
|
|
}
|
|
record.Slots = map[string]repository.ItemStack{"1:34": {ItemID: 112540232, Count: 1}}
|
|
return inventory.Save(ctx, record)
|
|
})
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("outer repository read self-deadlocked: %v", ctx.Err())
|
|
}
|
|
}
|
|
|
|
func TestMailboxAssetUnitOfWorkAllowsScopedAccountSaveWithoutSelfDeadlock(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(context.Background())
|
|
group, _ := component.Group()
|
|
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1", State: "active"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Character.Save(ctx, repository.CharacterRecord{
|
|
CharacterID: "1001",
|
|
AccountID: "account-1",
|
|
Name: "mailbox-assets",
|
|
Level: 90,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- group.MailboxAssets.WithinMailboxAssets(ctx, "1001", "1001", func(
|
|
accounts repository.AccountRepository,
|
|
_ repository.AccountInventoryRepository,
|
|
_ repository.CharacterRepository,
|
|
inventory repository.InventoryRepository,
|
|
_ repository.MailboxRepository,
|
|
) error {
|
|
// The Seria box is an account-and-character atomic write. It must
|
|
// save through the repository scoped by this unit of work, rather
|
|
// than re-enter the outer SQLite Account wrapper and wait on itself.
|
|
account, found, err := accounts.Load(ctx, "account-1")
|
|
if err != nil || !found {
|
|
return fmt.Errorf("load scoped account found=%t: %w", found, err)
|
|
}
|
|
account.Metadata = map[string]string{"seria_luck_value": "1"}
|
|
if err := accounts.Save(ctx, account); err != nil {
|
|
return fmt.Errorf("save scoped account: %w", err)
|
|
}
|
|
record, found, err := inventory.Load(ctx, "1001")
|
|
if err != nil || !found {
|
|
return fmt.Errorf("load transaction inventory found=%t: %w", found, err)
|
|
}
|
|
record.Slots = map[string]repository.ItemStack{"0:65": {ItemID: 2600014, Count: 1}}
|
|
return inventory.Save(ctx, record)
|
|
})
|
|
}()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatalf("scoped account save self-deadlocked: %v", ctx.Err())
|
|
}
|
|
account, found, err := group.Account.Load(context.Background(), "account-1")
|
|
if err != nil || !found || account.Metadata["seria_luck_value"] != "1" {
|
|
t.Fatalf("saved account found=%t err=%v account=%+v", found, err, account)
|
|
}
|
|
bag, found, err := group.Inventory.Load(context.Background(), "1001")
|
|
if err != nil || !found || bag.Slots["0:65"].ItemID != 2600014 {
|
|
t.Fatalf("saved inventory found=%t err=%v inventory=%+v", found, err, bag)
|
|
}
|
|
}
|
|
|
|
func TestMutationContentionHonorsContextDeadline(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- component.mutateSync(ctx, func(repository.Group) error {
|
|
close(entered)
|
|
<-release
|
|
return nil
|
|
})
|
|
}()
|
|
<-entered
|
|
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Millisecond)
|
|
defer cancel()
|
|
err := group.Character.Save(waitCtx, repository.CharacterRecord{CharacterID: "1001"})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
close(release)
|
|
<-done
|
|
t.Fatalf("contended mutation error=%v, want deadline exceeded", err)
|
|
}
|
|
close(release)
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestComponentUnitOfWorkRollbackDoesNotReachSQLite(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
defer database.Close()
|
|
defer component.Stop(ctx)
|
|
group, _ := component.Group()
|
|
if err := group.Inventory.Save(ctx, repository.InventoryRecord{CharacterID: "1001"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantErr := errors.New("reject mutation")
|
|
err := group.CharacterItems.WithinCharacterItems(ctx, "1001", func(inventory repository.InventoryRepository, _ repository.EquipmentRepository) error {
|
|
record, _, loadErr := inventory.Load(ctx, "1001")
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
record.Slots = map[string]repository.ItemStack{"0:0": {ItemID: 9999, Count: 1}}
|
|
if saveErr := inventory.Save(ctx, record); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
return wantErr
|
|
})
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("transaction error = %v, want %v", err, wantErr)
|
|
}
|
|
record, found, err := group.Inventory.Load(ctx, "1001")
|
|
if err != nil || !found || len(record.Slots) != 0 {
|
|
t.Fatalf("rolled-back inventory = %+v found=%t err=%v", record, found, err)
|
|
}
|
|
}
|
|
|
|
func TestComponentRejectsCorruptRecordChecksum(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
component, database := startTestComponent(t, path)
|
|
group, _ := component.Group()
|
|
if err := group.Account.Save(ctx, repository.AccountRecord{AccountID: "account-1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := component.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := database.ExecContext(ctx, `UPDATE dnf_repository_records SET checksum = 'bad'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
corrupt := NewComponent(database, path)
|
|
if err := corrupt.Start(ctx); err == nil {
|
|
t.Fatal("corrupt checksum was accepted")
|
|
}
|
|
database.Close()
|
|
}
|
|
|
|
func TestReplaceStateValidatesAndReplacesAllRecords(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "dnf90.db")
|
|
database, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
database.SetMaxOpenConns(1)
|
|
database.SetMaxIdleConns(1)
|
|
defer database.Close()
|
|
|
|
first := memory.State{
|
|
Version: memory.StateVersion,
|
|
Accounts: []repository.AccountRecord{{
|
|
AccountID: "old-account",
|
|
}},
|
|
}
|
|
if err := ReplaceState(ctx, database, first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := memory.State{
|
|
Version: memory.StateVersion,
|
|
Accounts: []repository.AccountRecord{{
|
|
AccountID: "account-1",
|
|
}},
|
|
Characters: []repository.CharacterRecord{{
|
|
CharacterID: "1",
|
|
AccountID: "account-1",
|
|
Slot: 0,
|
|
Name: "Test",
|
|
Level: 90,
|
|
}},
|
|
}
|
|
if err := ReplaceState(ctx, database, second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
component := NewComponent(database, path)
|
|
if err := component.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer component.Stop(ctx)
|
|
if got := component.Snapshot().RecordCount; got != 2 {
|
|
t.Fatalf("record count = %d, want 2", got)
|
|
}
|
|
group, _ := component.Group()
|
|
if _, found, err := group.Account.Load(ctx, "old-account"); err != nil || found {
|
|
t.Fatalf("old account found=%t err=%v", found, err)
|
|
}
|
|
character, found, err := group.Character.Load(ctx, "1")
|
|
if err != nil || !found || character.Name != "Test" || character.Level != 90 {
|
|
t.Fatalf("character = %+v found=%t err=%v", character, found, err)
|
|
}
|
|
}
|
|
|
|
func startTestComponent(t *testing.T, path string) (*Component, *sql.DB) {
|
|
t.Helper()
|
|
database, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
database.SetMaxOpenConns(1)
|
|
database.SetMaxIdleConns(1)
|
|
component := NewComponent(database, path)
|
|
if err := component.Start(context.Background()); err != nil {
|
|
database.Close()
|
|
t.Fatal(err)
|
|
}
|
|
return component, database
|
|
}
|