Files
Erupe/server/channelserver/handlers_cafe_test.go
Houmgaor 4ad0012f62 fix(handlers): guard GetBoostTimeLimit against past boost_time
Live-server testing via protbot surfaced an inconsistency between
GetBoostTimeLimit and GetBoostRight: on the same character, the
former reported a far-future boost limit (2288912640 = year 2042)
while the latter correctly reported "expired / available". The two
handlers read the same boost_time row and disagreed.

Root cause: GetBoostTimeLimit was doing a naked uint32(int64) cast
on boostLimit.Unix(). The test character's boost_time was actually
year 1906 (a pre-1970 sentinel left behind by the pre-#187 bug),
whose negative int64 Unix timestamp wraps through uint32 to a huge
positive value the client interprets as a permanently active boost.

Harmonise the guard with GetBoostRight: return 0 whenever the stored
boost_time is not strictly after TimeAdjusted(), covering both the
pre-1970 wraparound and the "already expired" case.

Add a healing migration (0011_fix_stale_boost_time) that NULLs out
any boost_time column older than 1970 or more than 10 years in the
future, so affected characters recover on upgrade without waiting
for a fresh boost start.

Regression test uses the exact year-1906 value observed on the live
frontier.mogapedia.fr test account.
2026-04-06 18:26:15 +02:00

661 lines
17 KiB
Go

package channelserver
import (
"errors"
"erupe-ce/common/mhfcourse"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
"testing"
"time"
)
func TestHandleMsgMhfGetBoostTime(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostTime{
AckHandle: 12345,
}
handleMsgMhfGetBoostTime(session, pkt)
select {
case p := <-session.sendPackets:
// Response should be empty bytes for this handler
if p.data == nil {
t.Error("Response packet data should not be nil")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfPostBoostTimeQuestReturn(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfPostBoostTimeQuestReturn{
AckHandle: 12345,
}
handleMsgMhfPostBoostTimeQuestReturn(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfPostBoostTime(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfPostBoostTime{
AckHandle: 12345,
}
handleMsgMhfPostBoostTime(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfPostBoostTimeLimit(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfPostBoostTimeLimit{
AckHandle: 12345,
}
handleMsgMhfPostBoostTimeLimit(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
}
}
func TestCafeBonusStruct(t *testing.T) {
// Test CafeBonus struct can be created
bonus := CafeBonus{
ID: 1,
TimeReq: 3600,
ItemType: 1,
ItemID: 100,
Quantity: 5,
Claimed: false,
}
if bonus.ID != 1 {
t.Errorf("ID = %d, want 1", bonus.ID)
}
if bonus.TimeReq != 3600 {
t.Errorf("TimeReq = %d, want 3600", bonus.TimeReq)
}
if bonus.Claimed {
t.Error("Claimed should be false")
}
}
// --- Mock-based handler tests ---
func TestHandleMsgMhfUpdateCafepoint(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.ints["netcafe_points"] = 150
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfUpdateCafepoint{AckHandle: 100}
handleMsgMhfUpdateCafepoint(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfAcquireCafeItem(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.ints["netcafe_points"] = 500
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfAcquireCafeItem{
AckHandle: 100,
PointCost: 200,
}
handleMsgMhfAcquireCafeItem(session, pkt)
if charMock.ints["netcafe_points"] != 300 {
t.Errorf("netcafe_points = %d, want 300 (500-200)", charMock.ints["netcafe_points"])
}
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfStartBoostTime_Disabled(t *testing.T) {
server := createMockServer()
server.erupeConfig.GameplayOptions.DisableBoostTime = true
charMock := newMockCharacterRepo()
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfStartBoostTime{AckHandle: 100}
handleMsgMhfStartBoostTime(session, pkt)
// When disabled, boost_time should NOT be saved
if _, ok := charMock.times["boost_time"]; ok {
t.Error("boost_time should not be saved when disabled")
}
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfStartBoostTime_Enabled(t *testing.T) {
server := createMockServer()
server.erupeConfig.GameplayOptions.DisableBoostTime = false
server.erupeConfig.GameplayOptions.BoostTimeDuration = 3600
charMock := newMockCharacterRepo()
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfStartBoostTime{AckHandle: 100}
handleMsgMhfStartBoostTime(session, pkt)
savedTime, ok := charMock.times["boost_time"]
if !ok {
t.Fatal("boost_time should be saved")
}
if savedTime.Before(time.Now()) {
t.Error("boost_time should be in the future")
}
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfGetBoostTimeLimit(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
future := time.Now().Add(1 * time.Hour)
charMock.times["boost_time"] = future
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100}
handleMsgMhfGetBoostTimeLimit(session, pkt)
// One response: the stray second ACK (doAckSimpleSucceed) was removed
// because it fired with the same ack handle as the real buf ACK, which
// is a protocol bug.
count := 0
for {
select {
case <-session.sendPackets:
count++
default:
goto done
}
}
done:
if count != 1 {
t.Errorf("Expected 1 response packet, got %d", count)
}
}
func TestHandleMsgMhfGetBoostTimeLimit_NoBoost(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.readErr = errNotFound
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100}
handleMsgMhfGetBoostTimeLimit(session, pkt)
// Should still send responses even on error
count := 0
for {
select {
case <-session.sendPackets:
count++
default:
goto done2
}
}
done2:
if count < 1 {
t.Error("Should queue at least one response packet")
}
}
func TestHandleMsgMhfGetBoostRight_Active(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.times["boost_time"] = time.Now().Add(1 * time.Hour) // Future = active
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100}
handleMsgMhfGetBoostRight(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfGetBoostRight_Expired(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.times["boost_time"] = time.Now().Add(-1 * time.Hour) // Past = expired
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100}
handleMsgMhfGetBoostRight(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfGetBoostRight_NoRecord(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
charMock.readErr = errNotFound
server.charRepo = charMock
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100}
handleMsgMhfGetBoostRight(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) < 4 {
t.Fatal("Response too short")
}
default:
t.Error("No response packet queued")
}
}
// --- Cafe Duration Bonus Info tests ---
func TestHandleMsgMhfGetCafeDurationBonusInfo_Error(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{bonusesErr: errors.New("db error")}
srv.charRepo = newMockCharacterRepo()
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDurationBonusInfo{AckHandle: 1}
handleMsgMhfGetCafeDurationBonusInfo(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
func TestHandleMsgMhfGetCafeDurationBonusInfo_WithBonuses(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{
bonuses: []CafeBonus{
{ID: 1, TimeReq: 100, ItemType: 5, ItemID: 10, Quantity: 2, Claimed: false},
{ID: 2, TimeReq: 200, ItemType: 6, ItemID: 20, Quantity: 1, Claimed: true},
},
}
srv.charRepo = newMockCharacterRepo()
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDurationBonusInfo{AckHandle: 1}
handleMsgMhfGetCafeDurationBonusInfo(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
// --- Receive Cafe Duration Bonus tests ---
func TestHandleMsgMhfReceiveCafeDurationBonus_Error(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{claimableErr: errors.New("db error")}
srv.charRepo = newMockCharacterRepo()
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfReceiveCafeDurationBonus{AckHandle: 1}
handleMsgMhfReceiveCafeDurationBonus(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
func TestHandleMsgMhfReceiveCafeDurationBonus_WithClaimable(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{
claimable: []CafeBonus{
{ID: 1, ItemType: 5, ItemID: 10, Quantity: 2},
},
}
srv.charRepo = newMockCharacterRepo()
s := createMockSession(100, srv)
// Course 30 is required for claimable items
s.courses = []mhfcourse.Course{{ID: 30}}
pkt := &mhfpacket.MsgMhfReceiveCafeDurationBonus{AckHandle: 1}
handleMsgMhfReceiveCafeDurationBonus(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
// --- Post Cafe Duration Bonus Received tests ---
func TestHandleMsgMhfPostCafeDurationBonusReceived_Empty(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{}
srv.charRepo = newMockCharacterRepo()
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfPostCafeDurationBonusReceived{AckHandle: 1, CafeBonusID: []uint32{}}
handleMsgMhfPostCafeDurationBonusReceived(s, pkt)
select {
case <-s.sendPackets:
default:
t.Fatal("No response packet queued")
}
}
func TestHandleMsgMhfPostCafeDurationBonusReceived_WithBonusIDs(t *testing.T) {
srv := createMockServer()
srv.cafeRepo = &mockCafeRepo{
bonusItemType: 17, // netcafe point type
bonusItemQty: 100,
}
charRepo := newMockCharacterRepo()
charRepo.ints["netcafe_points"] = 50
srv.charRepo = charRepo
s := createMockSession(100, srv)
srv.erupeConfig.GameplayOptions.MaximumNP = 99999
pkt := &mhfpacket.MsgMhfPostCafeDurationBonusReceived{AckHandle: 1, CafeBonusID: []uint32{1, 2}}
handleMsgMhfPostCafeDurationBonusReceived(s, pkt)
select {
case <-s.sendPackets:
default:
t.Fatal("No response packet queued")
}
}
// --- Daily Cafe Point tests ---
func TestHandleMsgMhfCheckDailyCafepoint_Eligible(t *testing.T) {
srv := createMockServer()
charRepo := newMockCharacterRepo()
// Set daily_time far in the past so midday.After(dailyTime) is true
charRepo.times["daily_time"] = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
charRepo.ints["netcafe_points"] = 10
srv.charRepo = charRepo
srv.erupeConfig.GameplayOptions.MaximumNP = 99999
srv.erupeConfig.GameplayOptions.BonusQuestAllowance = 10
srv.erupeConfig.GameplayOptions.DailyQuestAllowance = 5
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfCheckDailyCafepoint{AckHandle: 1}
handleMsgMhfCheckDailyCafepoint(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
func TestHandleMsgMhfCheckDailyCafepoint_NotEligible(t *testing.T) {
srv := createMockServer()
charRepo := newMockCharacterRepo()
// Set daily_time far in the future so midday.After(dailyTime) is false
charRepo.times["daily_time"] = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
srv.charRepo = charRepo
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfCheckDailyCafepoint{AckHandle: 1}
handleMsgMhfCheckDailyCafepoint(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
// --- Cafe Duration tests ---
func TestHandleMsgMhfGetCafeDuration(t *testing.T) {
srv := createMockServer()
charRepo := newMockCharacterRepo()
// cafe_reset in the future so we don't trigger reset logic
charRepo.times["cafe_reset"] = time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC)
charRepo.ints["cafe_time"] = 3600
srv.charRepo = charRepo
srv.cafeRepo = &mockCafeRepo{}
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDuration{AckHandle: 1}
handleMsgMhfGetCafeDuration(s, pkt)
select {
case p := <-s.sendPackets:
if len(p.data) == 0 {
t.Fatal("Expected non-empty response")
}
default:
t.Fatal("No response packet queued")
}
}
func TestHandleMsgMhfGetCafeDuration_ResetPath(t *testing.T) {
srv := createMockServer()
charRepo := newMockCharacterRepo()
// cafe_reset in the past to trigger reset logic
charRepo.times["cafe_reset"] = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
charRepo.ints["cafe_time"] = 1800
srv.charRepo = charRepo
srv.cafeRepo = &mockCafeRepo{}
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDuration{AckHandle: 1}
handleMsgMhfGetCafeDuration(s, pkt)
<-s.sendPackets
}
func TestHandleMsgMhfGetCafeDuration_NoResetTime(t *testing.T) {
srv := createMockServer()
charRepo := newMockCharacterRepo()
// No cafe_reset set -> ReadTime returns error -> sets new reset time
charRepo.ints["cafe_time"] = 100
srv.charRepo = charRepo
srv.cafeRepo = &mockCafeRepo{}
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDuration{AckHandle: 1}
handleMsgMhfGetCafeDuration(s, pkt)
<-s.sendPackets
}
func TestHandleMsgMhfGetCafeDuration_ZZClient(t *testing.T) {
srv := createMockServer()
srv.erupeConfig.RealClientMode = cfg.ZZ
charRepo := newMockCharacterRepo()
charRepo.times["cafe_reset"] = time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC)
charRepo.ints["cafe_time"] = 3600
srv.charRepo = charRepo
srv.cafeRepo = &mockCafeRepo{}
s := createMockSession(100, srv)
pkt := &mhfpacket.MsgMhfGetCafeDuration{AckHandle: 1}
handleMsgMhfGetCafeDuration(s, pkt)
<-s.sendPackets
}
// ackBufPayload extracts the payload bytes from a queued buffered-ack packet.
// Layout: opcode(u16) + ackHandle(u32) + isBuf(u8) + err(u8) + len(u16) + data.
func ackBufPayload(t *testing.T, data []byte) []byte {
t.Helper()
const headerLen = 10
if len(data) < headerLen {
t.Fatalf("ack packet too short: %d bytes", len(data))
}
return data[headerLen:]
}
// Regression for #187: GetBoostTimeLimit must return 0 when DisableBoostTime
// is set, overriding any stored boost_time.
func TestHandleMsgMhfGetBoostTimeLimit_DisableBoostTime(t *testing.T) {
server := createMockServer()
server.erupeConfig.GameplayOptions.DisableBoostTime = true
charMock := newMockCharacterRepo()
charMock.times["boost_time"] = time.Now().Add(1 * time.Hour)
server.charRepo = charMock
session := createMockSession(1, server)
handleMsgMhfGetBoostTimeLimit(session, &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100})
p := <-session.sendPackets
payload := ackBufPayload(t, p.data)
if len(payload) != 4 || payload[0] != 0 || payload[1] != 0 || payload[2] != 0 || payload[3] != 0 {
t.Errorf("expected zero uint32 payload, got %x", payload)
}
}
// Regression: GetBoostTimeLimit must return 0 for a stale past boost_time
// (e.g. a pre-1970 sentinel left over from the pre-#187 bug). Without the
// guard, uint32(negative int64) wraps to a huge future-looking timestamp
// the client interprets as a permanently active boost — while
// GetBoostRight correctly reports it as expired, producing an observable
// inconsistency between the two handlers.
func TestHandleMsgMhfGetBoostTimeLimit_PastBoostTime(t *testing.T) {
server := createMockServer()
charMock := newMockCharacterRepo()
// Year 1906 — the actual value found on the live test server.
charMock.times["boost_time"] = time.Date(1906, 1, 1, 0, 0, 0, 0, time.UTC)
server.charRepo = charMock
session := createMockSession(1, server)
handleMsgMhfGetBoostTimeLimit(session, &mhfpacket.MsgMhfGetBoostTimeLimit{AckHandle: 100})
p := <-session.sendPackets
payload := ackBufPayload(t, p.data)
if len(payload) != 4 || payload[0] != 0 || payload[1] != 0 || payload[2] != 0 || payload[3] != 0 {
t.Errorf("expected zero uint32 payload for past boost_time, got %x", payload)
}
}
// Regression for #187: GetBoostRight must report "no right" when disabled.
func TestHandleMsgMhfGetBoostRight_DisableBoostTime(t *testing.T) {
server := createMockServer()
server.erupeConfig.GameplayOptions.DisableBoostTime = true
charMock := newMockCharacterRepo()
charMock.times["boost_time"] = time.Now().Add(1 * time.Hour)
server.charRepo = charMock
session := createMockSession(1, server)
handleMsgMhfGetBoostRight(session, &mhfpacket.MsgMhfGetBoostRight{AckHandle: 100})
p := <-session.sendPackets
payload := ackBufPayload(t, p.data)
want := []byte{0x00, 0x00, 0x00, 0x00}
if len(payload) != 4 || payload[0] != want[0] || payload[1] != want[1] || payload[2] != want[2] || payload[3] != want[3] {
t.Errorf("expected %x, got %x", want, payload)
}
}