Files
90CNServer/server/internal/modules/dnf/repository/sqlite/engine_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

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
}