editing and maintenance and user suggestions

* every user has a right of a username and a display name and has the right to change it
* System information page now shows information about database user count, char count, and database schema version
* more maintenance lists
* show the right values in columns and fields
* move similar from inside of frontend component functions to utility js when used multiple times
* display help on mouse over
* add more than one believe to character
* make char name editable with better char info in headline
* GiT Gifttoleranz value not calculated correctly
* Bump backend to 0.2.3, frontend to 0.2.2
This commit is contained in:
Bardioc26
2026-02-03 17:21:43 +01:00
committed by GitHub
parent 49e6a684dd
commit 95f0fc0b7a
51 changed files with 3513 additions and 118 deletions
@@ -1,4 +1,4 @@
package config
package appsystem
import (
"github.com/gin-gonic/gin"
@@ -8,3 +8,7 @@ import (
func Versionsinfo(c *gin.Context) {
c.JSON(200, GetInfo())
}
func SystemInfo(c *gin.Context) {
c.JSON(200, GetInfo2())
}
@@ -1,10 +1,11 @@
package config
package appsystem
import "github.com/gin-gonic/gin"
// RegisterRoutes registers config-related routes (protected)
func RegisterRoutes(r *gin.RouterGroup) {
r.GET("/version", Versionsinfo)
r.GET("/systeminfo", SystemInfo)
}
// RegisterPublicRoutes registers public config routes (no auth required)
@@ -12,4 +13,5 @@ func RegisterPublicRoutes(r *gin.Engine) {
// Public version endpoint - no authentication required
public := r.Group("/api/public")
public.GET("/version", Versionsinfo)
public.GET("/systeminfo", SystemInfo)
}
@@ -1,4 +1,8 @@
package config
package appsystem
import (
"bamort/database"
)
// Version is the application version
const Version = "0.2.2"
@@ -50,6 +54,22 @@ type Info struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
}
type Info2 struct {
Version string `json:"version"`
GitCommit string `json:"gitCommit"`
UserCount int64 `json:"userCount"`
CharCount int64 `json:"charCount"`
DbVersion string `json:"dbVersion"`
}
func getUserCount() int {
// Placeholder implementation
return 42
}
func getCharCount() int {
// Placeholder implementation
return 123456
}
// GetInfo returns version information as a struct
func GetInfo() Info {
@@ -58,3 +78,20 @@ func GetInfo() Info {
GitCommit: GitCommit,
}
}
func GetInfo2() Info2 {
info := Info2{
Version: Version,
GitCommit: GitCommit,
}
db := database.DB
if db == nil {
return info
}
db.Table("char_chars").Count(&info.CharCount)
db.Table("users").Count(&info.UserCount)
db.Raw("SELECT version FROM schema_version ORDER BY id DESC LIMIT 1").Scan(&info.DbVersion)
return info
}
@@ -1,7 +1,10 @@
package config
package appsystem
import (
"testing"
"bamort/database"
"bamort/testutils"
)
func TestGetVersion(t *testing.T) {
@@ -41,3 +44,24 @@ func TestGetInfo(t *testing.T) {
t.Errorf("Expected info.Version %s, got %s", Version, info.Version)
}
}
func TestGetInfo2UsesDatabaseValues(t *testing.T) {
testutils.SetupTestEnvironment(t)
database.ResetTestDB()
t.Cleanup(database.ResetTestDB)
database.SetupTestDB()
info := GetInfo2()
if info.CharCount <= 0 {
t.Fatalf("expected character count from database, got %d", info.CharCount)
}
if info.UserCount <= 0 {
t.Fatalf("expected user count from database, got %d", info.UserCount)
}
if info.DbVersion == "" {
t.Fatalf("expected database schema version, got empty string")
}
}
+1
View File
@@ -195,6 +195,7 @@ func ToFeChar(object *models.Char) *models.FeChar {
feC.Fertigkeiten = skills
feC.InnateSkills = innateSkills
feC.CategorizedSkills = categories
feC.Git = object.GetGiftToleranz()
return feC
}
+3 -1
View File
@@ -1073,9 +1073,11 @@ func TestGetDatasheetOptions(t *testing.T) {
assert.Contains(t, socialClasses, "Mittelschicht")
faiths := response["faiths"].([]interface{})
assert.Equal(t, 5, len(faiths))
assert.Equal(t, 15, len(faiths))
assert.Contains(t, faiths, "Druide")
assert.Contains(t, faiths, "Keine")
assert.Contains(t, faiths, "Torkin")
assert.NotContains(t, faiths, "")
handedness := response["handedness"].([]interface{})
assert.Equal(t, 3, len(handedness))
+15 -11
View File
@@ -33,8 +33,9 @@ func GetCharacterShares(c *gin.Context) {
// Get user details for each share
type ShareWithUser struct {
models.CharShare
Username string `json:"username"`
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
var sharesWithUsers []ShareWithUser
@@ -42,9 +43,10 @@ func GetCharacterShares(c *gin.Context) {
var u user.User
if err := u.FirstId(share.UserID); err == nil {
sharesWithUsers = append(sharesWithUsers, ShareWithUser{
CharShare: share,
Username: u.Username,
Email: u.Email,
CharShare: share,
Username: u.Username,
DisplayName: u.DisplayNameOrUsername(),
Email: u.Email,
})
}
}
@@ -128,17 +130,19 @@ func GetAvailableUsersForSharing(c *gin.Context) {
// Remove sensitive data
type UserInfo struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
UserID uint `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
var userInfos []UserInfo
for _, u := range users {
userInfos = append(userInfos, UserInfo{
UserID: u.UserID,
Username: u.Username,
Email: u.Email,
UserID: u.UserID,
Username: u.Username,
DisplayName: u.DisplayNameOrUsername(),
Email: u.Email,
})
}
+93
View File
@@ -0,0 +1,93 @@
package character
import (
"bamort/database"
"bamort/models"
"bamort/user"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupShareHandlerTestEnvironment(t *testing.T) {
original := os.Getenv("ENVIRONMENT")
os.Setenv("ENVIRONMENT", "test")
t.Cleanup(func() {
if original != "" {
os.Setenv("ENVIRONMENT", original)
} else {
os.Unsetenv("ENVIRONMENT")
}
})
database.SetupTestDB(true, true)
t.Cleanup(database.ResetTestDB)
err := user.MigrateStructure()
require.NoError(t, err, "Should migrate user structure")
err = models.MigrateStructure()
require.NoError(t, err, "Should migrate models structure")
gin.SetMode(gin.TestMode)
}
func createHashedUser(t *testing.T, username, password, email, displayName string) *user.User {
u := &user.User{
Username: username,
PasswordHash: password,
Email: email,
DisplayName: displayName,
}
hashed := md5.Sum([]byte(password))
u.PasswordHash = hex.EncodeToString(hashed[:])
err := u.Create()
require.NoError(t, err, "Should create user")
return u
}
func TestGetAvailableUsersForSharingReturnsDisplayNames(t *testing.T) {
setupShareHandlerTestEnvironment(t)
owner := createHashedUser(t, "owneruser", "ownerpass", "owner@example.com", "")
sharedUser := createHashedUser(t, "shareduser", "sharedpass", "shared@example.com", "Shared Display")
char := models.Char{
BamortBase: models.BamortBase{Name: "Shared Character"},
UserID: owner.UserID,
}
err := database.DB.Create(&char).Error
require.NoError(t, err, "Should create character")
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/characters/%d/available-users", char.ID), nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{gin.Param{Key: "id", Value: fmt.Sprintf("%d", char.ID)}}
c.Set("userID", owner.UserID)
GetAvailableUsersForSharing(c)
assert.Equal(t, http.StatusOK, w.Code)
var response []map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
require.Len(t, response, 1, "Only the non-owner user should be returned")
entry := response[0]
assert.Equal(t, float64(sharedUser.UserID), entry["user_id"])
assert.Equal(t, sharedUser.DisplayName, entry["display_name"])
}
+3 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"bamort/appsystem"
"bamort/character"
"bamort/config"
"bamort/database"
@@ -92,11 +93,11 @@ func main() {
importer.RegisterRoutes(protected)
pdfrender.RegisterRoutes(protected)
transfer.RegisterRoutes(protected)
config.RegisterRoutes(protected)
appsystem.RegisterRoutes(protected)
// Register public routes (no authentication)
pdfrender.RegisterPublicRoutes(r)
config.RegisterPublicRoutes(r)
appsystem.RegisterPublicRoutes(r)
logger.Info("API-Routen erfolgreich registriert")
+97
View File
@@ -4,6 +4,7 @@ import (
"bamort/database"
"bamort/models"
"fmt"
"sort"
"strings"
)
@@ -19,23 +20,32 @@ func GetMiscLookupByKey(key string, order ...string) ([]models.MiscLookup, error
func GetMiscLookupByKeyForSystem(key string, gameSystemId uint, order ...string) ([]models.MiscLookup, error) {
var items []models.MiscLookup
orderKey := "value"
orderBy := "value ASC"
if len(order) > 0 && order[0] != "" {
switch order[0] {
case "id":
orderKey = "id"
orderBy = "id ASC"
case "value":
orderKey = "value"
orderBy = "value ASC"
case "source":
orderKey = "source"
orderBy = "source_id ASC, value ASC"
case "source_value":
orderKey = "source"
orderBy = "source_id ASC, value ASC"
default:
orderKey = "value"
orderBy = "value ASC"
}
}
gs := models.GetGameSystem(gameSystemId, "")
if gs == nil {
gs = models.GetGameSystem(0, "")
}
query := database.DB.Where("`key` = ?", key)
if gs.ID != 0 {
@@ -57,9 +67,96 @@ func GetMiscLookupByKeyForSystem(key string, gameSystemId uint, order ...string)
}
}
if key == "faiths" {
existingValues := make(map[string]struct{}, len(items))
for i := range items {
trimmed := strings.TrimSpace(items[i].Value)
if trimmed == "" {
continue
}
existingValues[trimmed] = struct{}{}
}
believeItems, err := collectBeliefsForSystem(gs)
if err != nil {
return items, err
}
additions := make([]models.MiscLookup, 0, len(believeItems))
for _, believe := range believeItems {
value := strings.TrimSpace(believe.Name)
if value == "" {
continue
}
if _, exists := existingValues[value]; exists {
continue
}
existingValues[value] = struct{}{}
addition := models.MiscLookup{
ID: believe.ID,
Key: key,
Value: value,
SourceID: believe.SourceID,
PageNumber: believe.PageNumber,
GameSystem: believe.GameSystem,
GameSystemId: believe.GameSystemId,
}
if addition.GameSystemId == 0 && gs.ID != 0 {
addition.GameSystemId = gs.ID
}
additions = append(additions, addition)
}
if len(additions) > 0 {
items = append(items, additions...)
sortMiscLookup(items, orderKey)
}
}
return items, nil
}
func collectBeliefsForSystem(gs *models.GameSystem) ([]models.Believe, error) {
var believes []models.Believe
if gs == nil {
return believes, nil
}
query := database.DB.Model(&models.Believe{})
if gs.ID != 0 {
query = query.Where("game_system_id = ?", gs.ID)
}
if gs.Name != "" {
query = query.Or("game_system = ?", gs.Name)
}
if err := query.Find(&believes).Error; err != nil {
return believes, err
}
return believes, nil
}
func sortMiscLookup(items []models.MiscLookup, orderKey string) {
sort.SliceStable(items, func(i, j int) bool {
switch orderKey {
case "id":
return items[i].ID < items[j].ID
case "source":
if items[i].SourceID == items[j].SourceID {
return items[i].Value < items[j].Value
}
return items[i].SourceID < items[j].SourceID
default:
return items[i].Value < items[j].Value
}
})
}
/*
// PopulateMiscLookupData populates initial misc lookup data if table is empty
func PopulateMiscLookupData() error {
+16 -1
View File
@@ -111,7 +111,7 @@ func TestPopulateMiscLookupData(t *testing.T) {
"races": 5,
"origins": 15,
"social_classes": 4,
"faiths": 5,
"faiths": 15,
"handedness": 3,
}
@@ -133,6 +133,21 @@ func TestPopulateMiscLookupData(t *testing.T) {
assert.Contains(t, raceValues, "Mensch")
assert.Contains(t, raceValues, "Elf")
faiths, _ := GetMiscLookupByKey("faiths")
faithValues := make([]string, len(faiths))
for i, f := range faiths {
faithValues[i] = f.Value
}
assert.Contains(t, faithValues, "Torkin")
assert.NotContains(t, faithValues, "")
mahalCount := 0
for _, v := range faithValues {
if v == "Mahal" {
mahalCount++
}
}
assert.Equal(t, 1, mahalCount)
/*
// Second population should not duplicate data
err = PopulateMiscLookupData()
+155
View File
@@ -0,0 +1,155 @@
package maintenance
import (
"bamort/database"
"bamort/models"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type believeResponse struct {
models.Believe
SourceCode string `json:"source_code,omitempty"`
}
type believeUpdateRequest struct {
Name string `json:"name"`
Beschreibung string `json:"beschreibung"`
SourceID *uint `json:"source_id"`
PageNumber *int `json:"page_number"`
}
func resolveGameSystemOrDefault(c *gin.Context) *models.GameSystem {
gsIDStr := c.Query("game_system_id")
gsName := c.Query("game_system")
var gsID uint
if gsIDStr != "" {
id, err := strconv.ParseUint(gsIDStr, 10, 32)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid game_system_id")
return nil
}
gsID = uint(id)
}
gs := models.GetGameSystem(gsID, gsName)
if gs == nil {
respondWithError(c, http.StatusBadRequest, "Invalid game system")
return nil
}
return gs
}
func GetBelieves(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var believes []models.Believe
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("name ASC").
Find(&believes).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve believes")
return
}
var sources []models.Source
if err := database.DB.Find(&sources).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve sources")
return
}
sourceMap := make(map[uint]string, len(sources))
for _, source := range sources {
sourceMap[source.ID] = source.Code
}
enhanced := make([]believeResponse, len(believes))
for i, believe := range believes {
enhanced[i] = believeResponse{
Believe: believe,
SourceCode: sourceMap[believe.SourceID],
}
}
c.JSON(http.StatusOK, gin.H{
"believes": enhanced,
"sources": sources,
})
}
func UpdateBelieve(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var believe models.Believe
if err := database.DB.First(&believe, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Believe not found")
return
}
var payload believeUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
respondWithError(c, http.StatusBadRequest, "name is required")
return
}
believe.Name = name
believe.Beschreibung = payload.Beschreibung
if payload.SourceID != nil {
believe.SourceID = *payload.SourceID
} else {
believe.SourceID = 0
}
if payload.PageNumber != nil {
believe.PageNumber = *payload.PageNumber
} else {
believe.PageNumber = 0
}
believe.GameSystem = gs.Name
believe.GameSystemId = gs.ID
if err := database.DB.Save(&believe).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update believe")
return
}
c.JSON(http.StatusOK, believeResponse{
Believe: believe,
SourceCode: lookupSourceCode(believe.SourceID),
})
}
func lookupSourceCode(sourceID uint) string {
if sourceID == 0 {
return ""
}
var source models.Source
if err := database.DB.Select("code").First(&source, sourceID).Error; err != nil {
return ""
}
return source.Code
}
@@ -0,0 +1,160 @@
package maintenance
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bamort/database"
"bamort/models"
"bamort/router"
"bamort/testutils"
"bamort/user"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupMaintenanceTest(t *testing.T) (string, *gin.Engine, *models.GameSystem) {
t.Helper()
testutils.SetupTestEnvironment(t)
database.ResetTestDB()
t.Cleanup(database.ResetTestDB)
database.SetupTestDB(true)
var maintainer user.User
require.NoError(t, database.DB.First(&maintainer, 1).Error)
maintainer.Role = user.RoleMaintainer
require.NoError(t, maintainer.Save())
token := user.GenerateToken(&maintainer)
gin.SetMode(gin.TestMode)
r := gin.Default()
router.SetupGin(r)
protected := router.BaseRouterGrp(r)
RegisterRoutes(protected)
gs := models.GetGameSystem(0, "")
require.NotNil(t, gs)
return token, r, gs
}
func createSource(t *testing.T, gs *models.GameSystem, code string) models.Source {
t.Helper()
source := models.Source{
Code: code,
Name: fmt.Sprintf("Source %s", code),
FullName: fmt.Sprintf("Source %s", code),
GameSystem: gs.Name,
GameSystemId: gs.ID,
IsActive: true,
}
require.NoError(t, database.DB.Create(&source).Error)
return source
}
func createBelieve(t *testing.T, gs *models.GameSystem, source models.Source, name string) models.Believe {
t.Helper()
believe := models.Believe{
Name: name,
Beschreibung: "Initial description",
SourceID: source.ID,
PageNumber: 7,
GameSystem: gs.Name,
GameSystemId: gs.ID,
}
require.NoError(t, database.DB.Create(&believe).Error)
return believe
}
func TestListBelievesReturnsData(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
source := createSource(t, gs, "TSTBEL")
created := createBelieve(t, gs, source, "Test Believe One")
req, err := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-believes", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Believes []struct {
ID uint `json:"id"`
Name string `json:"name"`
SourceID uint `json:"source_id"`
SourceCode string `json:"source_code"`
} `json:"believes"`
Sources []models.Source `json:"sources"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
assert.NotEmpty(t, payload.Sources)
var found bool
for _, b := range payload.Believes {
if b.ID == created.ID {
found = true
assert.Equal(t, created.Name, b.Name)
assert.Equal(t, source.ID, b.SourceID)
assert.Equal(t, source.Code, b.SourceCode)
}
}
assert.True(t, found, "expected created believe in response")
}
func TestUpdateBelieve(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
sourceOld := createSource(t, gs, "OLD01")
sourceNew := createSource(t, gs, "NEW01")
created := createBelieve(t, gs, sourceOld, "Old Believe")
updateBody := map[string]interface{}{
"name": "Updated Believe",
"beschreibung": "Updated description",
"source_id": sourceNew.ID,
"page_number": 123,
}
bodyBytes, err := json.Marshal(updateBody)
require.NoError(t, err)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-believes/%d", created.ID), bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.Believe
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, created.ID, updated.ID)
assert.Equal(t, "Updated Believe", updated.Name)
assert.Equal(t, "Updated description", updated.Beschreibung)
assert.Equal(t, sourceNew.ID, updated.SourceID)
assert.Equal(t, 123, updated.PageNumber)
assert.Equal(t, gs.Name, updated.GameSystem)
assert.Equal(t, gs.ID, updated.GameSystemId)
var stored models.Believe
require.NoError(t, database.DB.First(&stored, created.ID).Error)
assert.Equal(t, "Updated Believe", stored.Name)
assert.Equal(t, sourceNew.ID, stored.SourceID)
assert.Equal(t, 123, stored.PageNumber)
}
+356
View File
@@ -0,0 +1,356 @@
package maintenance
import (
"bamort/database"
"bamort/models"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func GetGameSystems(c *gin.Context) {
var systems []models.GameSystem
if err := database.DB.Order("code ASC").Find(&systems).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve game systems")
return
}
c.JSON(http.StatusOK, gin.H{"game_systems": systems})
}
type gameSystemUpdateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
IsActive *bool `json:"is_active"`
}
func UpdateGameSystem(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var gs models.GameSystem
if err := database.DB.First(&gs, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Game system not found")
return
}
var payload gameSystemUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
name := strings.TrimSpace(payload.Name)
if name != "" {
gs.Name = name
}
gs.Description = payload.Description
if payload.IsActive != nil {
gs.IsActive = *payload.IsActive
}
if err := database.DB.Save(&gs).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update game system")
return
}
c.JSON(http.StatusOK, gs)
}
// --- Sources ---
type sourceUpdateRequest struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Edition string `json:"edition"`
Publisher string `json:"publisher"`
PublishYear int `json:"publish_year"`
Description string `json:"description"`
IsCore *bool `json:"is_core"`
IsActive *bool `json:"is_active"`
}
func GetLitSources(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
var sources []models.Source
if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID).
Order("code ASC").
Find(&sources).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve sources")
return
}
c.JSON(http.StatusOK, gin.H{"sources": sources})
}
func UpdateLitSource(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var src models.Source
if err := database.DB.First(&src, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Source not found")
return
}
var payload sourceUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if name := strings.TrimSpace(payload.Name); name != "" {
src.Name = name
}
src.FullName = payload.FullName
src.Edition = payload.Edition
src.Publisher = payload.Publisher
src.PublishYear = payload.PublishYear
src.Description = payload.Description
if payload.IsCore != nil {
src.IsCore = *payload.IsCore
}
if payload.IsActive != nil {
src.IsActive = *payload.IsActive
}
src.GameSystem = gs.Name
src.GameSystemId = gs.ID
if err := database.DB.Save(&src).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update source")
return
}
c.JSON(http.StatusOK, src)
}
// --- Misc lookup ---
type miscUpdateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
SourceID *uint `json:"source_id"`
PageNumber *int `json:"page_number"`
}
func GetMisc(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
keyFilter := strings.TrimSpace(c.Query("key"))
var items []models.MiscLookup
q := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID)
if keyFilter != "" {
q = q.Where("`key` = ?", keyFilter)
}
if err := q.Order("`key` ASC, value ASC").Find(&items).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve misc")
return
}
c.JSON(http.StatusOK, gin.H{"misc": items})
}
func UpdateMisc(c *gin.Context) {
gs := resolveGameSystemOrDefault(c)
if gs == nil {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var item models.MiscLookup
if err := database.DB.First(&item, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Misc entry not found")
return
}
var payload miscUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if key := strings.TrimSpace(payload.Key); key != "" {
item.Key = key
}
if payload.Value != "" {
item.Value = payload.Value
}
if payload.SourceID != nil {
item.SourceID = *payload.SourceID
}
if payload.PageNumber != nil {
item.PageNumber = *payload.PageNumber
}
item.GameSystem = gs.Name
item.GameSystemId = gs.ID
if err := database.DB.Save(&item).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update misc entry")
return
}
c.JSON(http.StatusOK, item)
}
// --- Skill improvement cost2 ---
type skillImprovementUpdateRequest struct {
CurrentLevel *int `json:"current_level"`
TERequired *int `json:"te_required"`
CategoryID *uint `json:"category_id"`
DifficultyID *uint `json:"difficulty_id"`
}
func GetSkillImprovementCost2(c *gin.Context) {
var costs []models.SkillImprovementCost
if err := database.DB.Order("current_level ASC").Find(&costs).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill improvement costs")
return
}
categoryNames, difficultyNames, err := loadSkillMetadata(costs)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill improvement costs")
return
}
responses := make([]skillImprovementCostResponse, len(costs))
for i, cost := range costs {
responses[i] = skillImprovementCostResponse{
SkillImprovementCost: cost,
CategoryName: categoryNames[cost.CategoryID],
DifficultyName: difficultyNames[cost.DifficultyID],
}
}
c.JSON(http.StatusOK, gin.H{"costs": responses})
}
func UpdateSkillImprovementCost2(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
respondWithError(c, http.StatusBadRequest, "Invalid ID")
return
}
var cost models.SkillImprovementCost
if err := database.DB.First(&cost, uint(id)).Error; err != nil {
respondWithError(c, http.StatusNotFound, "Skill improvement cost not found")
return
}
var payload skillImprovementUpdateRequest
if err := c.ShouldBindJSON(&payload); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
if payload.CurrentLevel != nil {
cost.CurrentLevel = *payload.CurrentLevel
}
if payload.TERequired != nil {
cost.TERequired = *payload.TERequired
}
if payload.CategoryID != nil {
cost.CategoryID = *payload.CategoryID
}
if payload.DifficultyID != nil {
cost.DifficultyID = *payload.DifficultyID
}
if err := database.DB.Save(&cost).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update skill improvement cost")
return
}
categoryNames, difficultyNames, err := loadSkillMetadata([]models.SkillImprovementCost{cost})
if err != nil {
respondWithError(c, http.StatusInternalServerError, "Failed to update skill improvement cost")
return
}
c.JSON(http.StatusOK, skillImprovementCostResponse{
SkillImprovementCost: cost,
CategoryName: categoryNames[cost.CategoryID],
DifficultyName: difficultyNames[cost.DifficultyID],
})
}
type skillImprovementCostResponse struct {
models.SkillImprovementCost
CategoryName string `json:"category_name,omitempty"`
DifficultyName string `json:"difficulty_name,omitempty"`
}
func loadSkillMetadata(costs []models.SkillImprovementCost) (map[uint]string, map[uint]string, error) {
categoryNames := make(map[uint]string)
difficultyNames := make(map[uint]string)
if len(costs) == 0 {
return categoryNames, difficultyNames, nil
}
categoryIDs := make([]uint, 0)
difficultyIDs := make([]uint, 0)
seenCategories := make(map[uint]struct{})
seenDifficulties := make(map[uint]struct{})
for _, cost := range costs {
if _, ok := seenCategories[cost.CategoryID]; !ok {
seenCategories[cost.CategoryID] = struct{}{}
categoryIDs = append(categoryIDs, cost.CategoryID)
}
if _, ok := seenDifficulties[cost.DifficultyID]; !ok {
seenDifficulties[cost.DifficultyID] = struct{}{}
difficultyIDs = append(difficultyIDs, cost.DifficultyID)
}
}
if len(categoryIDs) > 0 {
var categories []models.SkillCategory
if err := database.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil {
return nil, nil, err
}
for _, category := range categories {
categoryNames[category.ID] = category.Name
}
}
if len(difficultyIDs) > 0 {
var difficulties []models.SkillDifficulty
if err := database.DB.Where("id IN ?", difficultyIDs).Find(&difficulties).Error; err != nil {
return nil, nil, err
}
for _, difficulty := range difficulties {
difficultyNames[difficulty.ID] = difficulty.Name
}
}
return categoryNames, difficultyNames, nil
}
@@ -0,0 +1,292 @@
package maintenance
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bamort/database"
"bamort/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createGameSystem(t *testing.T, code string) models.GameSystem {
t.Helper()
gs := models.GameSystem{Code: code, Name: "Game System " + code, Description: "desc", IsActive: true}
require.NoError(t, database.DB.Create(&gs).Error)
return gs
}
func TestListGameSystems(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
created := createGameSystem(t, "TSTGS")
req, err := http.NewRequest(http.MethodGet, "/api/maintenance/game-systems", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
GameSystems []models.GameSystem `json:"game_systems"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
var found bool
for _, gs := range payload.GameSystems {
if gs.ID == created.ID {
found = true
assert.Equal(t, created.Code, gs.Code)
assert.Equal(t, created.Name, gs.Name)
}
}
assert.True(t, found, "expected created game system in response")
}
func TestUpdateGameSystem(t *testing.T) {
token, router, _ := setupMaintenanceTest(t)
gs := createGameSystem(t, "UPDGS")
body := map[string]interface{}{
"name": "Updated GS",
"description": "Updated desc",
"is_active": false,
}
bodyBytes, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/game-systems/%d", gs.ID), bytes.NewBuffer(bodyBytes))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.GameSystem
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, gs.ID, updated.ID)
assert.Equal(t, "Updated GS", updated.Name)
assert.Equal(t, "Updated desc", updated.Description)
assert.False(t, updated.IsActive)
}
func createLitSource(t *testing.T, gs models.GameSystem, code string) models.Source {
t.Helper()
src := models.Source{
Code: code,
Name: "Source " + code,
FullName: "Full " + code,
Edition: "1",
Publisher: "Pub",
PublishYear: 2025,
Description: "Desc",
IsCore: false,
IsActive: true,
GameSystem: gs.Name,
GameSystemId: gs.ID,
}
require.NoError(t, database.DB.Create(&src).Error)
return src
}
func TestListLitSources(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
src := createLitSource(t, *gs, "SRC01")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-lit-sources", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Sources []models.Source `json:"sources"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
var found bool
for _, s := range payload.Sources {
if s.ID == src.ID {
found = true
assert.Equal(t, src.Code, s.Code)
assert.Equal(t, src.Name, s.Name)
}
}
assert.True(t, found)
}
func TestUpdateLitSource(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
src := createLitSource(t, *gs, "SRCUPD")
body := map[string]interface{}{
"name": "Updated Source",
"full_name": "Updated Full",
"edition": "2",
"publisher": "NewPub",
"publish_year": 2026,
"description": "New Desc",
"is_active": false,
"is_core": true,
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-lit-sources/%d", src.ID), bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.Source
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, "Updated Source", updated.Name)
assert.Equal(t, "Updated Full", updated.FullName)
assert.Equal(t, "2", updated.Edition)
assert.Equal(t, "NewPub", updated.Publisher)
assert.Equal(t, 2026, updated.PublishYear)
assert.False(t, updated.IsActive)
assert.True(t, updated.IsCore)
}
func createMisc(t *testing.T, gs models.GameSystem, key, value string) models.MiscLookup {
t.Helper()
m := models.MiscLookup{
Key: key,
Value: value,
GameSystem: gs.Name,
GameSystemId: gs.ID,
}
require.NoError(t, database.DB.Create(&m).Error)
return m
}
func TestListMisc(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
item := createMisc(t, *gs, "origin", "North")
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-misc", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Items []models.MiscLookup `json:"misc"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
var found bool
for _, it := range payload.Items {
if it.ID == item.ID {
found = true
assert.Equal(t, "origin", it.Key)
assert.Equal(t, "North", it.Value)
}
}
assert.True(t, found)
}
func TestUpdateMisc(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
item := createMisc(t, *gs, "race", "Human")
body := map[string]interface{}{
"value": "Elf",
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-misc/%d", item.ID), bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.MiscLookup
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, "Elf", updated.Value)
}
func createSkillImprovementCost(t *testing.T, gs models.GameSystem) models.SkillImprovementCost {
t.Helper()
cost := models.SkillImprovementCost{
CurrentLevel: 5,
TERequired: 2,
CategoryID: 1,
DifficultyID: 1,
}
require.NoError(t, database.DB.Create(&cost).Error)
return cost
}
func TestListSkillImprovementCost(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
created := createSkillImprovementCost(t, *gs)
req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-improvement-cost2", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload struct {
Costs []models.SkillImprovementCost `json:"costs"`
}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
var found bool
for _, c := range payload.Costs {
if c.ID == created.ID {
found = true
assert.Equal(t, created.CurrentLevel, c.CurrentLevel)
assert.Equal(t, created.TERequired, c.TERequired)
}
}
assert.True(t, found)
}
func TestUpdateSkillImprovementCost(t *testing.T) {
token, router, gs := setupMaintenanceTest(t)
created := createSkillImprovementCost(t, *gs)
body := map[string]interface{}{
"te_required": 5,
"current_level": 6,
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/skill-improvement-cost2/%d", created.ID), bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var updated models.SkillImprovementCost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated))
assert.Equal(t, 5, updated.TERequired)
assert.Equal(t, 6, updated.CurrentLevel)
}
+10
View File
@@ -10,6 +10,16 @@ func RegisterRoutes(r *gin.RouterGroup) {
charGrp := r.Group("/maintenance")
charGrp.Use(user.RequireMaintainer())
{
charGrp.GET("/gsm-believes", GetBelieves)
charGrp.PUT("/gsm-believes/:id", UpdateBelieve)
charGrp.GET("/game-systems", GetGameSystems)
charGrp.PUT("/game-systems/:id", UpdateGameSystem)
charGrp.GET("/gsm-lit-sources", GetLitSources)
charGrp.PUT("/gsm-lit-sources/:id", UpdateLitSource)
charGrp.GET("/gsm-misc", GetMisc)
charGrp.PUT("/gsm-misc/:id", UpdateMisc)
charGrp.GET("/skill-improvement-cost2", GetSkillImprovementCost2)
charGrp.PUT("/skill-improvement-cost2/:id", UpdateSkillImprovementCost2)
charGrp.GET("/setupcheck", SetupCheck)
charGrp.GET("/setupcheck-dev", SetupCheckDev)
charGrp.GET("/mktestdata", MakeTestdataFromLive)
+9 -3
View File
@@ -131,6 +131,7 @@ type CharList struct {
type FeChar struct {
Char
Git int `json:"git"` // GiftToleranz
CategorizedSkills map[string][]SkFertigkeit `json:"categorizedskills"`
InnateSkills []SkFertigkeit `json:"innateskills"`
}
@@ -140,6 +141,11 @@ func (object *Char) TableName() string {
return dbPrefix + "_" + "chars"
}
func (object *Char) GetGiftToleranz() int {
//30+(Konstitution/2)
konstitution := object.GetAttributeValue("Ko")
return 30 + (konstitution / 2)
}
func (object *Char) ensureGameSystem() {
gs := GetGameSystem(object.GameSystemId, object.GameSystem)
if gs == nil {
@@ -259,7 +265,7 @@ func FindSharedCharList(userID uint) ([]CharList, error) {
var chars []CharList
gs := GetGameSystem(0, "midgard")
err := database.DB.Table("char_chars").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner").
Joins("LEFT JOIN users ON char_chars.user_id = users.user_id").
Joins("INNER JOIN char_shares ON char_shares.character_id = char_chars.id").
Where("char_shares.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID).
@@ -274,7 +280,7 @@ func FindPublicCharList() ([]CharList, error) {
var chars []CharList
gs := GetGameSystem(0, "midgard")
err := database.DB.Table("char_chars").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner").
Joins("LEFT JOIN users ON char_chars.user_id = users.user_id").
Where("char_chars.public = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", true, gs.Name, gs.ID).
Find(&chars).Error
@@ -289,7 +295,7 @@ func FindCharListByUserID(userID uint) ([]CharList, error) {
var chars []CharList
gs := GetGameSystem(0, "midgard")
err := database.DB.Table("char_chars").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner").
Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner").
Joins("LEFT JOIN users ON char_chars.user_id = users.user_id").
Where("char_chars.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID).
Find(&chars).Error
+38 -1
View File
@@ -13,8 +13,11 @@ import (
func setupCharacterTestDB(t *testing.T) {
database.SetupTestDB()
err := user.MigrateStructure()
require.NoError(t, err, "Failed to migrate user structure")
// Migrate structures
err := MigrateStructure()
err = MigrateStructure()
require.NoError(t, err, "Failed to migrate database structure")
// Clean up any existing test data
@@ -355,6 +358,40 @@ func TestFindCharListByUserID_Success(t *testing.T) {
}
}
func TestFindPublicCharList_UsesOwnerDisplayName(t *testing.T) {
setupCharacterTestDB(t)
displayOwner := &user.User{
Username: "display-owner",
DisplayName: "Display Owner",
PasswordHash: "hash",
Email: "display-owner@example.com",
}
err := displayOwner.Create()
require.NoError(t, err, "User creation should succeed")
char := createTestChar("Public Char With Display Name")
char.UserID = displayOwner.UserID
char.Public = true
err = char.Create()
require.NoError(t, err, "Character creation should succeed")
publicChars, err := FindPublicCharList()
require.NoError(t, err, "FindPublicCharList should succeed")
var found *CharList
for i := range publicChars {
if publicChars[i].ID == char.ID {
found = &publicChars[i]
break
}
}
require.NotNil(t, found, "Created public character should be present in list")
assert.Equal(t, displayOwner.DisplayName, found.Owner, "Owner should use display name")
}
// =============================================================================
// Tests for Eigenschaft struct
// =============================================================================
+2 -2
View File
@@ -1,7 +1,7 @@
package transfer
import (
"bamort/config"
"bamort/appsystem"
"bamort/database"
"bamort/models"
"bamort/user"
@@ -90,7 +90,7 @@ func ExportDatabase(exportDir string) (*ExportResult, error) {
}
export := DatabaseExport{
Version: config.GetVersion(),
Version: appsystem.GetVersion(),
Timestamp: time.Now(),
}
+6 -3
View File
@@ -27,6 +27,7 @@ func ListUsers(c *gin.Context) {
for i := range users {
users[i].PasswordHash = ""
users[i].ResetPwHash = nil
users[i].DisplayName = users[i].DisplayNameOrUsername()
}
logger.Info("Successfully fetched %d users", len(users))
@@ -77,6 +78,7 @@ func GetUser(c *gin.Context) {
// Remove sensitive data
user.PasswordHash = ""
user.ResetPwHash = nil
user.DisplayName = user.DisplayNameOrUsername()
logger.Info("Successfully fetched user: %s (ID: %d)", user.Username, user.UserID)
c.JSON(http.StatusOK, user)
@@ -135,9 +137,10 @@ func UpdateUserRole(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "User role updated successfully",
"user": gin.H{
"id": user.UserID,
"username": user.Username,
"role": user.Role,
"id": user.UserID,
"username": user.Username,
"display_name": user.DisplayNameOrUsername(),
"role": user.Role,
},
})
}
+54 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"strconv"
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
@@ -414,9 +415,10 @@ func ValidateResetToken(c *gin.Context) {
logger.Debug("Reset-Token gültig für Benutzer: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"username": user.Username,
"expires": user.ResetPwHashExpires,
"valid": true,
"username": user.Username,
"display_name": user.DisplayNameOrUsername(),
"expires": user.ResetPwHashExpires,
})
}
@@ -443,6 +445,7 @@ func GetUserProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"id": user.UserID,
"username": user.Username,
"display_name": user.DisplayNameOrUsername(),
"email": user.Email,
"role": user.Role,
"preferred_language": user.PreferredLanguage,
@@ -605,3 +608,51 @@ func UpdateLanguage(c *gin.Context) {
"language": user.PreferredLanguage,
})
}
// UpdateDisplayName Handler to update user's display name
func UpdateDisplayName(c *gin.Context) {
logger.Debug("Starte Anzeigenamen-Aktualisierung...")
userID, exists := c.Get("userID")
if !exists {
logger.Error("Benutzer-ID nicht im Context gefunden")
respondWithError(c, http.StatusUnauthorized, "Unauthorized")
return
}
var input struct {
DisplayName string `json:"display_name"`
}
if err := c.ShouldBindJSON(&input); err != nil {
logger.Error("Fehler beim Parsen des Anzeigenamens: %s", err.Error())
respondWithError(c, http.StatusBadRequest, "Display name is required")
return
}
if utf8.RuneCountInString(input.DisplayName) > 30 {
logger.Warn("Anzeigename zu lang: %d Zeichen", utf8.RuneCountInString(input.DisplayName))
respondWithError(c, http.StatusBadRequest, "Display name must be at most 30 characters")
return
}
var user User
if err := user.FirstId(userID.(uint)); err != nil {
logger.Error("Benutzer mit ID %v nicht gefunden: %s", userID, err.Error())
respondWithError(c, http.StatusNotFound, "User not found")
return
}
user.DisplayName = input.DisplayName
if err := user.Save(); err != nil {
logger.Error("Fehler beim Speichern des Anzeigenamens für Benutzer %s: %s", user.Username, err.Error())
respondWithError(c, http.StatusInternalServerError, "Failed to update display name")
return
}
logger.Info("Anzeigename erfolgreich aktualisiert für Benutzer: %s (ID: %d)", user.Username, user.UserID)
c.JSON(http.StatusOK, gin.H{
"message": "Display name updated successfully",
"display_name": user.DisplayNameOrUsername(),
})
}
+117
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"bamort/database"
@@ -31,6 +32,83 @@ func setupTestEnvironment(t *testing.T) {
})
}
func TestUpdateDisplayName(t *testing.T) {
setupHandlerTestEnvironment(t)
t.Run("Success - Update display name", func(t *testing.T) {
user := createTestUser(t, "displayuser", "password123", "display@test.com")
requestData := map[string]interface{}{
"display_name": "Neuer Anzeigename",
}
requestBody, _ := json.Marshal(requestData)
req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set("userID", user.UserID)
UpdateDisplayName(c)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "Display name updated successfully", response["message"])
assert.Equal(t, "Neuer Anzeigename", response["display_name"])
var updated User
err = updated.FirstId(user.UserID)
assert.NoError(t, err)
assert.Equal(t, "Neuer Anzeigename", updated.DisplayName)
})
t.Run("Failure - Display name too long", func(t *testing.T) {
user := createTestUser(t, "displayuser2", "password123", "display2@test.com")
requestData := map[string]interface{}{
"display_name": strings.Repeat("a", 31),
}
requestBody, _ := json.Marshal(requestData)
req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set("userID", user.UserID)
UpdateDisplayName(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "Display name must be at most 30 characters", response["error"])
})
t.Run("Failure - No user ID in context", func(t *testing.T) {
requestData := map[string]interface{}{
"display_name": "Any",
}
requestBody, _ := json.Marshal(requestData)
req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
UpdateDisplayName(c)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
// setupHandlerTestEnvironment sets up the test environment for handler tests
func setupHandlerTestEnvironment(t *testing.T) {
setupTestEnvironment(t)
@@ -89,6 +167,45 @@ func TestGetUserProfile(t *testing.T) {
assert.Equal(t, float64(testUser.UserID), response["id"])
})
t.Run("Success - Profile uses display name with fallback", func(t *testing.T) {
userWithDisplay := createTestUser(t, "profiledisplay", "password123", "profiledisplay@test.com")
req, _ := http.NewRequest("GET", "/api/user/profile", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Set("userID", userWithDisplay.UserID)
GetUserProfile(c)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, userWithDisplay.Username, response["display_name"])
userWithDisplay.DisplayName = "Profile Display"
require.NoError(t, userWithDisplay.Save())
req2, _ := http.NewRequest("GET", "/api/user/profile", nil)
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = req2
c2.Set("userID", userWithDisplay.UserID)
GetUserProfile(c2)
assert.Equal(t, http.StatusOK, w2.Code)
var response2 map[string]interface{}
err = json.Unmarshal(w2.Body.Bytes(), &response2)
assert.NoError(t, err)
assert.Equal(t, userWithDisplay.DisplayName, response2["display_name"])
})
t.Run("Failure - No user ID in context", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/user/profile", nil)
w := httptest.NewRecorder()
+17
View File
@@ -3,6 +3,7 @@ package user
import (
"bamort/database"
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -18,6 +19,7 @@ const (
type User struct {
UserID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
DisplayName string `gorm:"not null;default:''" json:"display_name"`
PasswordHash string `json:"password"`
Email string `gorm:"unique" json:"email"`
Role string `gorm:"default:standard" json:"role"`
@@ -33,6 +35,10 @@ func (object *User) Create() error {
return fmt.Errorf("database connection is nil")
}
if strings.TrimSpace(object.DisplayName) == "" {
object.DisplayName = object.Username
}
err := database.DB.Transaction(func(tx *gorm.DB) error {
// Save the User record
if err := tx.Create(&object).Error; err != nil {
@@ -75,6 +81,10 @@ func (object *User) Save() error {
return fmt.Errorf("database connection is nil")
}
if strings.TrimSpace(object.DisplayName) == "" {
object.DisplayName = object.Username
}
err := database.DB.Save(&object).Error
if err != nil {
// User found
@@ -154,3 +164,10 @@ func (u *User) IsStandardUser() bool {
func ValidateRole(role string) bool {
return role == RoleStandardUser || role == RoleMaintainer || role == RoleAdmin
}
func (u *User) DisplayNameOrUsername() string {
if strings.TrimSpace(u.DisplayName) != "" {
return u.DisplayName
}
return u.Username
}
+37 -1
View File
@@ -39,6 +39,21 @@ func TestUserRoleDefaults(t *testing.T) {
assert.Equal(t, RoleStandardUser, user.Role, "New users should have standard role")
}
// TestUserDisplayNameDefaultsToUsername ensures a user's display name falls back to the username
func TestUserDisplayNameDefaultsToUsername(t *testing.T) {
setupRoleTestEnvironment(t)
user := &User{
Username: "display_user",
PasswordHash: "hashedpw",
Email: "display@example.com",
}
err := user.Create()
require.NoError(t, err, "Should create user")
assert.Equal(t, user.Username, user.DisplayName, "DisplayName should default to username")
}
// TestRoleValidation tests role validation
func TestRoleValidation(t *testing.T) {
assert.True(t, ValidateRole(RoleStandardUser), "standard should be valid")
@@ -75,7 +90,7 @@ func TestListUsers(t *testing.T) {
setupRoleTestEnvironment(t)
// Create test users
admin := &User{Username: "listadmin", PasswordHash: "hash", Email: "listadmin@test.com", Role: RoleAdmin}
admin := &User{Username: "listadmin", PasswordHash: "hash", Email: "listadmin@test.com", Role: RoleAdmin, DisplayName: "List Admin"}
require.NoError(t, admin.Create())
standardUser := &User{Username: "listuser", PasswordHash: "hash", Email: "listuser@test.com", Role: RoleStandardUser}
@@ -93,6 +108,27 @@ func TestListUsers(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code, "Admin should access list")
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
var adminEntry map[string]interface{}
var standardEntry map[string]interface{}
for _, entry := range response {
switch entry["username"] {
case admin.Username:
adminEntry = entry
case standardUser.Username:
standardEntry = entry
}
}
require.NotNil(t, adminEntry, "admin should be present in response")
require.NotNil(t, standardEntry, "standard user should be present in response")
assert.Equal(t, admin.DisplayName, adminEntry["display_name"], "admin display name should be returned")
assert.Equal(t, standardUser.Username, standardEntry["display_name"], "standard user should fall back to username when display name is empty")
// Test standard user access (should fail)
router2 := gin.Default()
router2.GET("/users", func(c *gin.Context) {
+1
View File
@@ -10,6 +10,7 @@ func RegisterRoutes(r *gin.RouterGroup) {
{
// Protected routes - require authentication
userGroup.GET("/profile", GetUserProfile)
userGroup.PUT("/display-name", UpdateDisplayName)
userGroup.PUT("/email", UpdateEmail)
userGroup.PUT("/password", UpdatePassword)
userGroup.PUT("/language", UpdateLanguage)