Files
Erupe/server/api/endpoints_test.go
Houmgaor 82b967b715 refactor: replace raw SQL with repository interfaces in entranceserver and API server
Extract all direct database calls from entranceserver (2 calls) and
API server (17 calls) into typed repository interfaces with PostgreSQL
implementations, matching the pattern established in signserver and
channelserver.

Entranceserver: EntranceServerRepo, EntranceSessionRepo
API server: APIUserRepo, APICharacterRepo, APISessionRepo

Also fix the 3 remaining fmt.Sprintf calls inside logger invocations
in handlers_commands.go and handlers_stage.go, replacing them with
structured zap fields.

Unskip 5 TestNewAuthData* tests that previously required a real
database — they now run with mock repos.
2026-02-22 17:04:58 +01:00

626 lines
16 KiB
Go

package api
import (
"bytes"
"encoding/json"
"encoding/xml"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
cfg "erupe-ce/config"
"erupe-ce/common/gametime"
"go.uber.org/zap"
)
// TestLauncherEndpoint tests the /launcher endpoint
func TestLauncherEndpoint(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.API.Banners = []cfg.APISignBanner{
{Src: "http://example.com/banner1.jpg", Link: "http://example.com"},
}
c.API.Messages = []cfg.APISignMessage{
{Message: "Welcome to Erupe", Date: 0, Kind: 0, Link: "http://example.com"},
}
c.API.Links = []cfg.APISignLink{
{Name: "Forum", Icon: "forum", Link: "http://forum.example.com"},
}
server := &APIServer{
logger: logger,
erupeConfig: c,
}
// Create test request
req, err := http.NewRequest("GET", "/launcher", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Create response recorder
recorder := httptest.NewRecorder()
// Call handler
server.Launcher(recorder, req)
// Check response status
if recorder.Code != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
// Check Content-Type header
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" {
t.Errorf("Content-Type header = %v, want application/json", contentType)
}
// Parse response
var respData LauncherResponse
if err := json.NewDecoder(recorder.Body).Decode(&respData); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
// Verify response content
if len(respData.Banners) != 1 {
t.Errorf("Number of banners = %d, want 1", len(respData.Banners))
}
if len(respData.Messages) != 1 {
t.Errorf("Number of messages = %d, want 1", len(respData.Messages))
}
if len(respData.Links) != 1 {
t.Errorf("Number of links = %d, want 1", len(respData.Links))
}
}
// TestLauncherEndpointEmptyConfig tests launcher with empty config
func TestLauncherEndpointEmptyConfig(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.API.Banners = []cfg.APISignBanner{}
c.API.Messages = []cfg.APISignMessage{}
c.API.Links = []cfg.APISignLink{}
server := &APIServer{
logger: logger,
erupeConfig: c,
}
req := httptest.NewRequest("GET", "/launcher", nil)
recorder := httptest.NewRecorder()
server.Launcher(recorder, req)
var respData LauncherResponse
_ = json.NewDecoder(recorder.Body).Decode(&respData)
if respData.Banners == nil {
t.Error("Banners should not be nil, should be empty slice")
}
if respData.Messages == nil {
t.Error("Messages should not be nil, should be empty slice")
}
if respData.Links == nil {
t.Error("Links should not be nil, should be empty slice")
}
}
// TestLoginEndpointInvalidJSON tests login with invalid JSON
func TestLoginEndpointInvalidJSON(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
// Invalid JSON
invalidJSON := `{"username": "test", "password": `
req := httptest.NewRequest("POST", "/login", strings.NewReader(invalidJSON))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.Login(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
// TestLoginEndpointEmptyCredentials tests login with empty credentials
func TestLoginEndpointEmptyCredentials(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
tests := []struct {
name string
username string
password string
wantPanic bool // Note: will panic without real DB
}{
{"EmptyUsername", "", "password", true},
{"EmptyPassword", "username", "", true},
{"BothEmpty", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.wantPanic {
t.Skip("Skipping - requires real database connection")
}
body := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: tt.username,
Password: tt.password,
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/login", bytes.NewReader(bodyBytes))
recorder := httptest.NewRecorder()
// Note: Without a database, this will fail
server.Login(recorder, req)
// Should fail (400 or 500 depending on DB availability)
if recorder.Code < http.StatusBadRequest {
t.Errorf("Should return error status for test: %s", tt.name)
}
})
}
}
// TestRegisterEndpointInvalidJSON tests register with invalid JSON
func TestRegisterEndpointInvalidJSON(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
invalidJSON := `{"username": "test"`
req := httptest.NewRequest("POST", "/register", strings.NewReader(invalidJSON))
recorder := httptest.NewRecorder()
server.Register(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
// TestRegisterEndpointEmptyCredentials tests register with empty fields
func TestRegisterEndpointEmptyCredentials(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
tests := []struct {
name string
username string
password string
wantCode int
}{
{"EmptyUsername", "", "password", http.StatusBadRequest},
{"EmptyPassword", "username", "", http.StatusBadRequest},
{"BothEmpty", "", "", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: tt.username,
Password: tt.password,
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/register", bytes.NewReader(bodyBytes))
recorder := httptest.NewRecorder()
// Validating empty credentials check only (no database call)
server.Register(recorder, req)
// Empty credentials should return 400
if recorder.Code != tt.wantCode {
t.Logf("Got status %d, want %d - %s", recorder.Code, tt.wantCode, tt.name)
}
})
}
}
// TestCreateCharacterEndpointInvalidJSON tests create character with invalid JSON
func TestCreateCharacterEndpointInvalidJSON(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
invalidJSON := `{"token": `
req := httptest.NewRequest("POST", "/character/create", strings.NewReader(invalidJSON))
recorder := httptest.NewRecorder()
server.CreateCharacter(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
// TestDeleteCharacterEndpointInvalidJSON tests delete character with invalid JSON
func TestDeleteCharacterEndpointInvalidJSON(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
invalidJSON := `{"token": "test"`
req := httptest.NewRequest("POST", "/character/delete", strings.NewReader(invalidJSON))
recorder := httptest.NewRecorder()
server.DeleteCharacter(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
// TestExportSaveEndpointInvalidJSON tests export save with invalid JSON
func TestExportSaveEndpointInvalidJSON(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
invalidJSON := `{"token": `
req := httptest.NewRequest("POST", "/character/export", strings.NewReader(invalidJSON))
recorder := httptest.NewRecorder()
server.ExportSave(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
// TestScreenShotEndpointDisabled tests screenshot endpoint when disabled
func TestScreenShotEndpointDisabled(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.Screenshots.Enabled = false
server := &APIServer{
logger: logger,
erupeConfig: c,
}
req := httptest.NewRequest("POST", "/api/ss/bbs/upload.php", nil)
recorder := httptest.NewRecorder()
server.ScreenShot(recorder, req)
// Parse XML response
var result struct {
XMLName xml.Name `xml:"result"`
Code string `xml:"code"`
}
_ = xml.NewDecoder(recorder.Body).Decode(&result)
if result.Code != "400" {
t.Errorf("Expected code 400, got %s", result.Code)
}
}
// TestScreenShotEndpointInvalidMethod tests screenshot endpoint with invalid method
func TestScreenShotEndpointInvalidMethod(t *testing.T) {
t.Skip("Screenshot endpoint doesn't have proper control flow for early returns")
// The ScreenShot function doesn't exit early on method check, so it continues
// to try to decode image from nil body which causes panic
// This would need refactoring of the endpoint to fix
}
// TestScreenShotGetInvalidToken tests screenshot get with invalid token
func TestScreenShotGetInvalidToken(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
tests := []struct {
name string
token string
}{
{"EmptyToken", ""},
{"InvalidCharactersToken", "../../etc/passwd"},
{"SpecialCharactersToken", "token@!#$"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/ss/bbs/"+tt.token, nil)
recorder := httptest.NewRecorder()
// Set up the URL variable manually since we're not using gorilla/mux
if tt.token == "" {
server.ScreenShotGet(recorder, req)
// Empty token should fail
if recorder.Code != http.StatusBadRequest {
t.Logf("Empty token returned status %d", recorder.Code)
}
}
})
}
}
// newTestUserRepo returns a mock user repo suitable for newAuthData tests.
func newTestUserRepo() *mockAPIUserRepo {
return &mockAPIUserRepo{
lastLogin: time.Now(),
returnExpiry: time.Now().Add(time.Hour * 24 * 30),
}
}
// TestNewAuthDataStructure tests the newAuthData helper function
func TestNewAuthDataStructure(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.DebugOptions.MaxLauncherHR = false
c.HideLoginNotice = false
c.LoginNotices = []string{"Notice 1", "Notice 2"}
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: newTestUserRepo(),
}
characters := []Character{
{
ID: 1,
Name: "Char1",
IsFemale: false,
Weapon: 0,
HR: 5,
GR: 0,
},
}
authData := server.newAuthData(1, 0, 1, "test-token", characters)
if authData.User.TokenID != 1 {
t.Errorf("Token ID = %d, want 1", authData.User.TokenID)
}
if authData.User.Token != "test-token" {
t.Errorf("Token = %s, want test-token", authData.User.Token)
}
if len(authData.Characters) != 1 {
t.Errorf("Number of characters = %d, want 1", len(authData.Characters))
}
if authData.MezFes == nil {
t.Error("MezFes should not be nil")
}
if authData.PatchServer != c.API.PatchServer {
t.Errorf("PatchServer = %s, want %s", authData.PatchServer, c.API.PatchServer)
}
if len(authData.Notices) == 0 {
t.Error("Notices should not be empty when HideLoginNotice is false")
}
}
// TestNewAuthDataDebugMode tests newAuthData with debug mode enabled
func TestNewAuthDataDebugMode(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.DebugOptions.MaxLauncherHR = true
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: newTestUserRepo(),
}
characters := []Character{
{
ID: 1,
Name: "Char1",
IsFemale: false,
Weapon: 0,
HR: 100, // High HR
GR: 0,
},
}
authData := server.newAuthData(1, 0, 1, "token", characters)
if authData.Characters[0].HR != 7 {
t.Errorf("Debug mode should set HR to 7, got %d", authData.Characters[0].HR)
}
}
// TestNewAuthDataMezFesConfiguration tests MezFes configuration in newAuthData
func TestNewAuthDataMezFesConfiguration(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.GameplayOptions.MezFesSoloTickets = 150
c.GameplayOptions.MezFesGroupTickets = 75
c.GameplayOptions.MezFesSwitchMinigame = true
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: newTestUserRepo(),
}
authData := server.newAuthData(1, 0, 1, "token", []Character{})
if authData.MezFes.SoloTickets != 150 {
t.Errorf("SoloTickets = %d, want 150", authData.MezFes.SoloTickets)
}
if authData.MezFes.GroupTickets != 75 {
t.Errorf("GroupTickets = %d, want 75", authData.MezFes.GroupTickets)
}
// Check that minigame stall is switched
if authData.MezFes.Stalls[4] != 2 {
t.Errorf("Minigame stall should be 2 when MezFesSwitchMinigame is true, got %d", authData.MezFes.Stalls[4])
}
}
// TestNewAuthDataHideNotices tests notice hiding in newAuthData
func TestNewAuthDataHideNotices(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
c.HideLoginNotice = true
c.LoginNotices = []string{"Notice 1", "Notice 2"}
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: newTestUserRepo(),
}
authData := server.newAuthData(1, 0, 1, "token", []Character{})
if len(authData.Notices) != 0 {
t.Errorf("Notices should be empty when HideLoginNotice is true, got %d", len(authData.Notices))
}
}
// TestNewAuthDataTimestamps tests timestamp generation in newAuthData
func TestNewAuthDataTimestamps(t *testing.T) {
logger := NewTestLogger(t)
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: newTestUserRepo(),
}
authData := server.newAuthData(1, 0, 1, "token", []Character{})
// Timestamps should be reasonable (within last minute and next 30 days)
now := uint32(gametime.Adjusted().Unix())
if authData.CurrentTS < now-60 || authData.CurrentTS > now+60 {
t.Errorf("CurrentTS not within reasonable range: %d vs %d", authData.CurrentTS, now)
}
if authData.ExpiryTS < now {
t.Errorf("ExpiryTS should be in future")
}
}
// BenchmarkLauncherEndpoint benchmarks the launcher endpoint
func BenchmarkLauncherEndpoint(b *testing.B) {
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest("GET", "/launcher", nil)
recorder := httptest.NewRecorder()
server.Launcher(recorder, req)
}
}
// BenchmarkNewAuthData benchmarks the newAuthData function
func BenchmarkNewAuthData(b *testing.B) {
logger, _ := zap.NewDevelopment()
defer func() { _ = logger.Sync() }()
c := NewTestConfig()
server := &APIServer{
logger: logger,
erupeConfig: c,
userRepo: &mockAPIUserRepo{
lastLogin: time.Now(),
returnExpiry: time.Now().Add(time.Hour * 24 * 30),
},
}
characters := make([]Character, 16)
for i := 0; i < 16; i++ {
characters[i] = Character{
ID: uint32(i + 1),
Name: "Character",
IsFemale: i%2 == 0,
Weapon: uint32(i % 14),
HR: uint32(100 + i),
GR: 0,
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = server.newAuthData(1, 0, 1, "token", characters)
}
}