mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +01:00
fix(repo): detect silent save failures + partial daily mission stubs
SaveColumn and SaveMercenary now check RowsAffected() and return a wrapped ErrCharacterNotFound when 0 rows are updated, preventing silent data loss when a character ID is missing or mismatched. AdjustInt already detects this via its RETURNING scan — no change. Daily mission packet structs (Get/SetDailyMission*) now parse the AckHandle instead of returning NOT IMPLEMENTED, letting handlers send empty-list success ACKs and avoiding client softlocks. Also adds tests for dashboard stats endpoint and for five guild repo methods (SetAllianceRecruiting, RolloverDailyRP, AddWeeklyBonusUsers, InsertKillLog, ClearTreasureHunt) that had no coverage.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{}},
|
||||
|
||||
153
server/api/dashboard_test.go
Normal file
153
server/api/dashboard_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
227
server/channelserver/repo_guild_subsystems_test.go
Normal file
227
server/channelserver/repo_guild_subsystems_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user