diff --git a/network/mhfpacket/msg_mhf_get_daily_mission_master.go b/network/mhfpacket/msg_mhf_get_daily_mission_master.go index f844ce171..9bfc6fad4 100644 --- a/network/mhfpacket/msg_mhf_get_daily_mission_master.go +++ b/network/mhfpacket/msg_mhf_get_daily_mission_master.go @@ -8,17 +8,22 @@ import ( "erupe-ce/network/clientctx" ) -// MsgMhfGetDailyMissionMaster represents the MSG_MHF_GET_DAILY_MISSION_MASTER -type MsgMhfGetDailyMissionMaster struct{} +// MsgMhfGetDailyMissionMaster requests the server-side daily mission master list. +// Full request payload beyond the AckHandle is not yet reverse-engineered. +type MsgMhfGetDailyMissionMaster struct { + AckHandle uint32 +} // Opcode returns the ID associated with this packet type. func (m *MsgMhfGetDailyMissionMaster) Opcode() network.PacketID { return network.MSG_MHF_GET_DAILY_MISSION_MASTER } -// Parse parses the packet from binary +// Parse parses the packet from binary. +// Only the AckHandle is parsed; additional fields are unknown. func (m *MsgMhfGetDailyMissionMaster) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_mhf_get_daily_mission_personal.go b/network/mhfpacket/msg_mhf_get_daily_mission_personal.go index 58088f7b5..3055ca659 100644 --- a/network/mhfpacket/msg_mhf_get_daily_mission_personal.go +++ b/network/mhfpacket/msg_mhf_get_daily_mission_personal.go @@ -8,17 +8,22 @@ import ( "erupe-ce/network/clientctx" ) -// MsgMhfGetDailyMissionPersonal represents the MSG_MHF_GET_DAILY_MISSION_PERSONAL -type MsgMhfGetDailyMissionPersonal struct{} +// MsgMhfGetDailyMissionPersonal requests the character's personal daily mission progress. +// Full request payload beyond the AckHandle is not yet reverse-engineered. +type MsgMhfGetDailyMissionPersonal struct { + AckHandle uint32 +} // Opcode returns the ID associated with this packet type. func (m *MsgMhfGetDailyMissionPersonal) Opcode() network.PacketID { return network.MSG_MHF_GET_DAILY_MISSION_PERSONAL } -// Parse parses the packet from binary +// Parse parses the packet from binary. +// Only the AckHandle is parsed; additional fields are unknown. func (m *MsgMhfGetDailyMissionPersonal) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_mhf_set_daily_mission_personal.go b/network/mhfpacket/msg_mhf_set_daily_mission_personal.go index 4b5da5c52..d19ef2fcb 100644 --- a/network/mhfpacket/msg_mhf_set_daily_mission_personal.go +++ b/network/mhfpacket/msg_mhf_set_daily_mission_personal.go @@ -8,17 +8,22 @@ import ( "erupe-ce/network/clientctx" ) -// MsgMhfSetDailyMissionPersonal represents the MSG_MHF_SET_DAILY_MISSION_PERSONAL -type MsgMhfSetDailyMissionPersonal struct{} +// MsgMhfSetDailyMissionPersonal writes the character's personal daily mission progress. +// Full request payload beyond the AckHandle is not yet reverse-engineered. +type MsgMhfSetDailyMissionPersonal struct { + AckHandle uint32 +} // Opcode returns the ID associated with this packet type. func (m *MsgMhfSetDailyMissionPersonal) Opcode() network.PacketID { return network.MSG_MHF_SET_DAILY_MISSION_PERSONAL } -// Parse parses the packet from binary +// Parse parses the packet from binary. +// Only the AckHandle is parsed; additional fields are unknown. func (m *MsgMhfSetDailyMissionPersonal) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_parse_small_test.go b/network/mhfpacket/msg_parse_small_test.go index 36b005fd7..ff728f928 100644 --- a/network/mhfpacket/msg_parse_small_test.go +++ b/network/mhfpacket/msg_parse_small_test.go @@ -22,8 +22,6 @@ func TestParseSmallNotImplemented(t *testing.T) { {"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}}, {"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}}, {"MsgMhfGetCaUniqueID", &MsgMhfGetCaUniqueID{}}, - {"MsgMhfGetDailyMissionMaster", &MsgMhfGetDailyMissionMaster{}}, - {"MsgMhfGetDailyMissionPersonal", &MsgMhfGetDailyMissionPersonal{}}, {"MsgMhfGetRestrictionEvent", &MsgMhfGetRestrictionEvent{}}, {"MsgMhfKickExportForce", &MsgMhfKickExportForce{}}, {"MsgMhfPaymentAchievement", &MsgMhfPaymentAchievement{}}, @@ -32,7 +30,6 @@ func TestParseSmallNotImplemented(t *testing.T) { {"MsgMhfResetAchievement", &MsgMhfResetAchievement{}}, {"MsgMhfResetTitle", &MsgMhfResetTitle{}}, {"MsgMhfSetCaAchievement", &MsgMhfSetCaAchievement{}}, - {"MsgMhfSetDailyMissionPersonal", &MsgMhfSetDailyMissionPersonal{}}, {"MsgMhfSetUdTacticsFollower", &MsgMhfSetUdTacticsFollower{}}, {"MsgMhfStampcardPrize", &MsgMhfStampcardPrize{}}, {"MsgMhfUpdateForceGuildRank", &MsgMhfUpdateForceGuildRank{}}, diff --git a/server/api/dashboard_test.go b/server/api/dashboard_test.go new file mode 100644 index 000000000..babfe98cc --- /dev/null +++ b/server/api/dashboard_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestDashboardStatsJSON_NoDB verifies the stats endpoint returns valid JSON +// with safe zero values when no database is configured. +func TestDashboardStatsJSON_NoDB(t *testing.T) { + logger := NewTestLogger(t) + defer func() { _ = logger.Sync() }() + + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + startTime: time.Now().Add(-5 * time.Minute), + // db intentionally nil + } + + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/stats", nil) + rec := httptest.NewRecorder() + + server.DashboardStatsJSON(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Errorf("Expected Content-Type application/json, got %q", ct) + } + + var stats DashboardStats + if err := json.NewDecoder(rec.Body).Decode(&stats); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Verify required fields are present and have expected zero-DB values. + if stats.ServerVersion == "" { + t.Error("Expected non-empty ServerVersion") + } + if stats.Uptime == "" || stats.Uptime == "unknown" { + // startTime is set so uptime should be computed, not "unknown". + t.Errorf("Expected computed uptime, got %q", stats.Uptime) + } + if stats.TotalAccounts != 0 { + t.Errorf("Expected TotalAccounts=0 without DB, got %d", stats.TotalAccounts) + } + if stats.TotalCharacters != 0 { + t.Errorf("Expected TotalCharacters=0 without DB, got %d", stats.TotalCharacters) + } + if stats.OnlinePlayers != 0 { + t.Errorf("Expected OnlinePlayers=0 without DB, got %d", stats.OnlinePlayers) + } + if stats.DatabaseOK { + t.Error("Expected DatabaseOK=false without DB") + } + if stats.Channels != nil { + t.Errorf("Expected nil Channels without DB, got %v", stats.Channels) + } +} + +// TestDashboardStatsJSON_UptimeUnknown verifies "unknown" uptime when startTime is zero. +func TestDashboardStatsJSON_UptimeUnknown(t *testing.T) { + logger := NewTestLogger(t) + defer func() { _ = logger.Sync() }() + + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + // startTime is zero value + } + + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/stats", nil) + rec := httptest.NewRecorder() + + server.DashboardStatsJSON(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var stats DashboardStats + if err := json.NewDecoder(rec.Body).Decode(&stats); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if stats.Uptime != "unknown" { + t.Errorf("Expected Uptime='unknown' for zero startTime, got %q", stats.Uptime) + } +} + +// TestDashboardStatsJSON_JSONShape validates every field of the DashboardStats payload. +func TestDashboardStatsJSON_JSONShape(t *testing.T) { + logger := NewTestLogger(t) + defer func() { _ = logger.Sync() }() + + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + startTime: time.Now(), + } + + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/stats", nil) + rec := httptest.NewRecorder() + + server.DashboardStatsJSON(rec, req) + + // Decode into a raw map so we can check key presence independent of type. + var raw map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&raw); err != nil { + t.Fatalf("Failed to decode response as raw map: %v", err) + } + + requiredKeys := []string{ + "uptime", "serverVersion", "clientMode", + "onlinePlayers", "totalAccounts", "totalCharacters", + "databaseOK", + } + for _, key := range requiredKeys { + if _, ok := raw[key]; !ok { + t.Errorf("Missing required JSON key %q", key) + } + } +} + +// TestFormatDuration covers the human-readable duration formatter. +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {10 * time.Second, "10s"}, + {90 * time.Second, "1m 30s"}, + {2*time.Hour + 15*time.Minute + 5*time.Second, "2h 15m 5s"}, + {25*time.Hour + 3*time.Minute + 0*time.Second, "1d 1h 3m 0s"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatDuration(tt.d) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + }) + } +} diff --git a/server/channelserver/handlers_misc.go b/server/channelserver/handlers_misc.go index 58f150b99..2091c46a4 100644 --- a/server/channelserver/handlers_misc.go +++ b/server/channelserver/handlers_misc.go @@ -147,11 +147,39 @@ func handleMsgMhfGetSenyuDailyCount(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } -func handleMsgMhfGetDailyMissionMaster(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented +// handleMsgMhfGetDailyMissionMaster returns an empty daily mission master list. +// The full response format is not yet reverse-engineered; count=0 is safe. +func handleMsgMhfGetDailyMissionMaster(s *Session, p mhfpacket.MHFPacket) { + if p == nil { + return + } + pkt := p.(*mhfpacket.MsgMhfGetDailyMissionMaster) + bf := byteframe.NewByteFrame() + bf.WriteUint32(0) // entry count = 0 + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} -func handleMsgMhfGetDailyMissionPersonal(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented +// handleMsgMhfGetDailyMissionPersonal returns an empty personal daily mission progress list. +// The full response format is not yet reverse-engineered; count=0 is safe. +func handleMsgMhfGetDailyMissionPersonal(s *Session, p mhfpacket.MHFPacket) { + if p == nil { + return + } + pkt := p.(*mhfpacket.MsgMhfGetDailyMissionPersonal) + bf := byteframe.NewByteFrame() + bf.WriteUint32(0) // entry count = 0 + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} -func handleMsgMhfSetDailyMissionPersonal(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented +// handleMsgMhfSetDailyMissionPersonal acknowledges a personal daily mission progress write. +// The full request/response format is not yet reverse-engineered. +func handleMsgMhfSetDailyMissionPersonal(s *Session, p mhfpacket.MHFPacket) { + if p == nil { + return + } + pkt := p.(*mhfpacket.MsgMhfSetDailyMissionPersonal) + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) +} // Equip skin history buffer sizes per game version const ( diff --git a/server/channelserver/repo_character.go b/server/channelserver/repo_character.go index 2fc20cdb0..9c3083061 100644 --- a/server/channelserver/repo_character.go +++ b/server/channelserver/repo_character.go @@ -2,6 +2,7 @@ package channelserver import ( "database/sql" + "errors" "fmt" "time" @@ -49,10 +50,24 @@ func (r *CharacterRepository) LoadColumn(charID uint32, column string) ([]byte, return data, err } +// ErrCharacterNotFound is returned by write methods when no character row is matched. +var ErrCharacterNotFound = errors.New("character not found") + // SaveColumn writes a single []byte column by character ID. +// Returns ErrCharacterNotFound if no row was updated (character does not exist). func (r *CharacterRepository) SaveColumn(charID uint32, column string, data []byte) error { - _, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", data, charID) - return err + result, err := r.db.Exec("UPDATE characters SET "+column+"=$1 WHERE id=$2", data, charID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("SaveColumn %s for char %d: %w", column, charID, ErrCharacterNotFound) + } + return nil } // ReadInt reads a single integer column (0 for NULL) by character ID. @@ -222,13 +237,26 @@ func (r *CharacterRepository) ReadGuildPostChecked(charID uint32) (time.Time, er // When rastaID is 0, only the mercenary blob is saved — the existing rasta_id // (typically NULL for characters without a mercenary) is preserved. Writing 0 // would pollute GetMercenaryLoans queries that match on pact_id. +// Returns ErrCharacterNotFound if no row was updated. func (r *CharacterRepository) SaveMercenary(charID uint32, data []byte, rastaID uint32) error { + var result sql.Result + var err error if rastaID == 0 { - _, err := r.db.Exec("UPDATE characters SET savemercenary=$1 WHERE id=$2", data, charID) + result, err = r.db.Exec("UPDATE characters SET savemercenary=$1 WHERE id=$2", data, charID) + } else { + result, err = r.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", data, rastaID, charID) + } + if err != nil { return err } - _, err := r.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", data, rastaID, charID) - return err + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("SaveMercenary for char %d: %w", charID, ErrCharacterNotFound) + } + return nil } // UpdateGCPAndPact updates gcp and pact_id atomically. diff --git a/server/channelserver/repo_guild_subsystems_test.go b/server/channelserver/repo_guild_subsystems_test.go new file mode 100644 index 000000000..e68515a9b --- /dev/null +++ b/server/channelserver/repo_guild_subsystems_test.go @@ -0,0 +1,227 @@ +package channelserver + +// Tests for guild subsystem methods not covered by repo_guild_test.go: +// - SetAllianceRecruiting (repo_guild_alliance.go) +// - RolloverDailyRP (repo_guild_rp.go) +// - AddWeeklyBonusUsers (repo_guild_rp.go) +// - InsertKillLog (repo_guild_hunt.go) +// - ClearTreasureHunt (repo_guild_hunt.go) + +import ( + "testing" + "time" +) + +func TestSetAllianceRecruiting(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "sar_user") + charID := CreateTestCharacter(t, db, userID, "SAR_Leader") + guildID := CreateTestGuild(t, db, charID, "SAR_Guild") + repo := NewGuildRepository(db) + + if err := repo.CreateAlliance("SAR_Alliance", guildID); err != nil { + t.Fatalf("CreateAlliance failed: %v", err) + } + alliances, err := repo.ListAlliances() + if err != nil { + t.Fatalf("ListAlliances failed: %v", err) + } + if len(alliances) == 0 { + t.Fatal("Expected at least 1 alliance") + } + allianceID := alliances[0].ID + + // Default should be false. + if alliances[0].Recruiting { + t.Error("Expected initial Recruiting=false") + } + + if err := repo.SetAllianceRecruiting(allianceID, true); err != nil { + t.Fatalf("SetAllianceRecruiting(true) failed: %v", err) + } + alliance, err := repo.GetAllianceByID(allianceID) + if err != nil { + t.Fatalf("GetAllianceByID after set true failed: %v", err) + } + if !alliance.Recruiting { + t.Error("Expected Recruiting=true after SetAllianceRecruiting(true)") + } + + if err := repo.SetAllianceRecruiting(allianceID, false); err != nil { + t.Fatalf("SetAllianceRecruiting(false) failed: %v", err) + } + alliance, err = repo.GetAllianceByID(allianceID) + if err != nil { + t.Fatalf("GetAllianceByID after set false failed: %v", err) + } + if alliance.Recruiting { + t.Error("Expected Recruiting=false after SetAllianceRecruiting(false)") + } +} + +func TestRolloverDailyRP(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "rollover_user") + charID := CreateTestCharacter(t, db, userID, "Rollover_Leader") + guildID := CreateTestGuild(t, db, charID, "Rollover_Guild") + repo := NewGuildRepository(db) + + // Set rp_today for the member so we can verify the rollover. + if _, err := db.Exec("UPDATE guild_characters SET rp_today = 50 WHERE character_id = $1", charID); err != nil { + t.Fatalf("Failed to set rp_today: %v", err) + } + + noon := time.Now().UTC() + if err := repo.RolloverDailyRP(guildID, noon); err != nil { + t.Fatalf("RolloverDailyRP failed: %v", err) + } + + var rpToday, rpYesterday int + if err := db.QueryRow("SELECT rp_today, rp_yesterday FROM guild_characters WHERE character_id = $1", charID). + Scan(&rpToday, &rpYesterday); err != nil { + t.Fatalf("Failed to read rp values: %v", err) + } + if rpToday != 0 { + t.Errorf("Expected rp_today=0 after rollover, got %d", rpToday) + } + if rpYesterday != 50 { + t.Errorf("Expected rp_yesterday=50 after rollover, got %d", rpYesterday) + } +} + +func TestRolloverDailyRP_Idempotent(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "idem_rollover_user") + charID := CreateTestCharacter(t, db, userID, "Idem_Rollover_Leader") + guildID := CreateTestGuild(t, db, charID, "Idem_Rollover_Guild") + repo := NewGuildRepository(db) + + if _, err := db.Exec("UPDATE guild_characters SET rp_today = 100 WHERE character_id = $1", charID); err != nil { + t.Fatalf("Failed to set rp_today: %v", err) + } + + noon := time.Now().UTC() + if err := repo.RolloverDailyRP(guildID, noon); err != nil { + t.Fatalf("First RolloverDailyRP failed: %v", err) + } + // Second call with same noon should be a no-op (rp_reset_at >= noon). + if err := repo.RolloverDailyRP(guildID, noon); err != nil { + t.Fatalf("Second RolloverDailyRP (idempotent) failed: %v", err) + } + + var rpToday int + _ = db.QueryRow("SELECT rp_today FROM guild_characters WHERE character_id = $1", charID).Scan(&rpToday) + if rpToday != 0 { + t.Errorf("Expected rp_today=0 after idempotent rollover, got %d", rpToday) + } +} + +func TestAddWeeklyBonusUsers(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "wbu_user") + charID := CreateTestCharacter(t, db, userID, "WBU_Leader") + guildID := CreateTestGuild(t, db, charID, "WBU_Guild") + repo := NewGuildRepository(db) + + if err := repo.AddWeeklyBonusUsers(guildID, 3); err != nil { + t.Fatalf("AddWeeklyBonusUsers failed: %v", err) + } + + // Verify the column incremented. + var wbu int + if err := db.QueryRow("SELECT weekly_bonus_users FROM guilds WHERE id = $1", guildID).Scan(&wbu); err != nil { + t.Fatalf("Failed to read weekly_bonus_users: %v", err) + } + if wbu != 3 { + t.Errorf("Expected weekly_bonus_users=3, got %d", wbu) + } + + // Add again and verify accumulation. + if err := repo.AddWeeklyBonusUsers(guildID, 2); err != nil { + t.Fatalf("Second AddWeeklyBonusUsers failed: %v", err) + } + if err := db.QueryRow("SELECT weekly_bonus_users FROM guilds WHERE id = $1", guildID).Scan(&wbu); err != nil { + t.Fatalf("Failed to read weekly_bonus_users after second add: %v", err) + } + if wbu != 5 { + t.Errorf("Expected weekly_bonus_users=5 after second add, got %d", wbu) + } +} + +func TestInsertKillLogAndCount(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "kill_log_user") + charID := CreateTestCharacter(t, db, userID, "Kill_Logger") + guildID := CreateTestGuild(t, db, charID, "Kill_Guild") + repo := NewGuildRepository(db) + + // Set box_claimed to 1 hour ago so kills inserted now are within the window. + if _, err := db.Exec("UPDATE guild_characters SET box_claimed = now() - interval '1 hour' WHERE character_id = $1", charID); err != nil { + t.Fatalf("Failed to set box_claimed: %v", err) + } + + if err := repo.InsertKillLog(charID, 42, 2, time.Now()); err != nil { + t.Fatalf("InsertKillLog failed: %v", err) + } + + count, err := repo.CountGuildKills(guildID, charID) + if err != nil { + t.Fatalf("CountGuildKills failed: %v", err) + } + if count != 1 { + t.Errorf("Expected 1 kill log entry, got %d", count) + } +} + +func TestClearTreasureHunt(t *testing.T) { + db := SetupTestDB(t) + defer TeardownTestDB(t, db) + + userID := CreateTestUser(t, db, "cth_user") + charID := CreateTestCharacter(t, db, userID, "CTH_Leader") + guildID := CreateTestGuild(t, db, charID, "CTH_Guild") + repo := NewGuildRepository(db) + + // Create and register a hunt. + if err := repo.CreateHunt(guildID, charID, 7, 1, []byte{}, ""); err != nil { + t.Fatalf("CreateHunt failed: %v", err) + } + hunt, err := repo.GetPendingHunt(charID) + if err != nil || hunt == nil { + t.Fatalf("GetPendingHunt failed or nil: %v", err) + } + if err := repo.RegisterHuntReport(hunt.HuntID, charID); err != nil { + t.Fatalf("RegisterHuntReport failed: %v", err) + } + + // Verify treasure_hunt is set. + var th interface{} + if err := db.QueryRow("SELECT treasure_hunt FROM guild_characters WHERE character_id = $1", charID).Scan(&th); err != nil { + t.Fatalf("Failed to read treasure_hunt: %v", err) + } + if th == nil { + t.Error("Expected treasure_hunt to be set after RegisterHuntReport") + } + + // Clear it. + if err := repo.ClearTreasureHunt(charID); err != nil { + t.Fatalf("ClearTreasureHunt failed: %v", err) + } + + if err := db.QueryRow("SELECT treasure_hunt FROM guild_characters WHERE character_id = $1", charID).Scan(&th); err != nil { + t.Fatalf("Failed to read treasure_hunt after clear: %v", err) + } + if th != nil { + t.Errorf("Expected treasure_hunt=nil after ClearTreasureHunt, got %v", th) + } +}