added a template package

This commit is contained in:
2026-04-22 09:13:47 +02:00
parent c684123ffc
commit 5f059c27ba
8 changed files with 467 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
# ptpl — Package Template
`ptpl` is a **copy-paste template** for new Bamort backend modules. It contains the full set of required files and wiring so a new module can be added by copying this package, renaming it, and filling in domain logic.
---
## Purpose
Every Bamort backend module must follow the same structure so the registry-based auto-registration works correctly. `ptpl` demonstrates this structure with minimal but complete sample code:
- one **public** route (no authentication required)
- one **protected** route group (requires a valid JWT)
- a GORM model with auto-migration
- handler tests covering happy paths, error paths, and user-isolation
---
## File Structure
| File | Required | Responsibility |
|---|:---:|---|
| `model.go` | yes | GORM entity definitions owned by this module |
| `register.go` | yes | `init()` — registers routes and migration with the central registry |
| `routes.go` | yes | `RegisterRoutes` / `RegisterPublicRoutes` — maps URL paths to handlers |
| `handlers.go` | yes | Gin handler functions (HTTP controllers) |
| `database.go` | when needed | `MigrateStructure` and other DB helpers specific to this module |
| `handlers_test.go` | yes | Tests for all handlers |
---
## Registration Flow
Modules register themselves via `init()` so `main.go` never needs to call module code directly — a single blank import is all that is needed.
```
main.go
└─ _ "bamort/bmrt/ptpl" ← blank import triggers init()
└─ init()
├─ registry.RegisterRoutes(RegisterRoutes) → /api/ptpl/*
├─ registry.RegisterPublicRoutes(RegisterPublicRoutes) → /public/ptpl/*
└─ registry.RegisterMigration(MigrateStructure) → AutoMigrate on startup
```
At startup `main.go` calls the registry run-functions which iterate over all registered callbacks:
```
registry.RunAllMigrations(db) // calls MigrateStructure
registry.RunAllRoutes(protected) // calls RegisterRoutes (under /api, auth required)
registry.RunAllPublicRoutes(r) // calls RegisterPublicRoutes (no auth)
```
---
## Routes
### Public (no authentication)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | `/public/ptpl/info` | `GetPublicInfo` | Returns module status information |
### Protected (JWT required)
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | `/api/ptpl` | `ListItems` | Lists all items owned by the authenticated user |
| POST | `/api/ptpl` | `CreateItem` | Creates a new item for the authenticated user |
| GET | `/api/ptpl/:id` | `GetItem` | Returns a single item (only if owned by the caller) |
Role-restricted sub-groups are applied in `routes.go` using middleware — never inside a handler:
```go
admin := grp.Group("")
admin.Use(user.RequireAdmin())
admin.DELETE("/:id", DeleteItem) // admin only
```
---
## Adding Role Restrictions
Import `bamort/user` and apply middleware to a sub-group in `routes.go`:
```go
import "bamort/user"
func RegisterRoutes(r *gin.RouterGroup) {
grp := r.Group("/ptpl")
grp.GET("", ListItems) // all authenticated users
maintainer := grp.Group("")
maintainer.Use(user.RequireMaintainer())
maintainer.POST("", CreateItem) // maintainer + admin only
admin := grp.Group("")
admin.Use(user.RequireAdmin())
admin.DELETE("/:id", DeleteItem) // admin only
}
```
Role hierarchy (ascending): `standard` < `maintainer` < `admin`.
---
## Creating a New Module from This Template
1. Copy the `ptpl/` directory and rename it (e.g. `myfeature/`).
2. Rename the Go package declaration in every file from `package ptpl` to `package myfeature`.
3. Replace `PtplItem` with your own GORM model in `model.go`.
4. Update the route prefix in `routes.go` (e.g. `/ptpl``/myfeature`).
5. Implement your handler logic in `handlers.go`.
6. Add the blank import to `cmd/main.go`:
```go
_ "bamort/bmrt/myfeature"
```
7. Run `go test ./bmrt/myfeature/` — all tests should pass before adding new ones.
---
## Testing Conventions
Every test file must:
- define a local `setupTestEnvironment(t *testing.T)` that sets `ENVIRONMENT=test`, calls `database.SetupTestDB(true, true)`, registers a cleanup with `database.ResetTestDB`, and sets Gin to test mode.
- call `setupTestEnvironment(t)` as the **first statement** of every top-level test function.
- cover the **happy path**, the **error / not-found path**, and **user-isolation** (data from another user must not be returned) for each handler.
- use `t.Run("scenario", ...)` subtests for multiple input variants of the same handler.
+19
View File
@@ -0,0 +1,19 @@
package ptpl
import (
"bamort/database"
"gorm.io/gorm"
)
// MigrateStructure runs the auto-migration for models owned by this module.
func MigrateStructure(db ...*gorm.DB) error {
var targetDB *gorm.DB
if len(db) > 0 && db[0] != nil {
targetDB = db[0]
} else {
targetDB = database.DB
}
return targetDB.AutoMigrate(&PtplItem{})
}
+64
View File
@@ -0,0 +1,64 @@
package ptpl
import (
"bamort/database"
"bamort/logger"
"net/http"
"github.com/gin-gonic/gin"
)
func respondWithError(c *gin.Context, status int, message string) {
logger.Warn("HTTP Fehler %d: %s", status, message)
c.JSON(status, gin.H{"error": message})
}
// GetPublicInfo returns public module information (no auth required).
func GetPublicInfo(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"module": "ptpl",
"status": "active",
})
}
// ListItems returns all items for the authenticated user.
func ListItems(c *gin.Context) {
userID := c.GetUint("userID")
var items []PtplItem
if err := database.DB.Where("user_id = ?", userID).Find(&items).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, items)
}
// CreateItem creates a new item for the authenticated user.
func CreateItem(c *gin.Context) {
var item PtplItem
if err := c.ShouldBindJSON(&item); err != nil {
respondWithError(c, http.StatusBadRequest, err.Error())
return
}
item.UserID = c.GetUint("userID")
if err := database.DB.Create(&item).Error; err != nil {
respondWithError(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, item)
}
// GetItem returns a single item by ID for the authenticated user.
func GetItem(c *gin.Context) {
id := c.Param("id")
userID := c.GetUint("userID")
var item PtplItem
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&item).Error; err != nil {
respondWithError(c, http.StatusNotFound, "item not found")
return
}
c.JSON(http.StatusOK, item)
}
+209
View File
@@ -0,0 +1,209 @@
package ptpl
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"bamort/database"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestEnvironment(t *testing.T) {
t.Helper()
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 := MigrateStructure()
require.NoError(t, err)
gin.SetMode(gin.TestMode)
}
func TestGetPublicInfo(t *testing.T) {
setupTestEnvironment(t)
t.Run("returns module info", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/public/ptpl/info", nil)
GetPublicInfo(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ptpl", resp["module"])
assert.Equal(t, "active", resp["status"])
})
}
func TestListItems(t *testing.T) {
setupTestEnvironment(t)
t.Run("returns empty list when no items exist", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Request = httptest.NewRequest(http.MethodGet, "/api/ptpl", nil)
ListItems(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp []PtplItem
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Empty(t, resp)
})
t.Run("returns items for authenticated user", func(t *testing.T) {
item := PtplItem{Name: "Test Item", Description: "A test", UserID: 1}
require.NoError(t, database.DB.Create(&item).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Request = httptest.NewRequest(http.MethodGet, "/api/ptpl", nil)
ListItems(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp []PtplItem
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Len(t, resp, 1)
assert.Equal(t, "Test Item", resp[0].Name)
})
t.Run("does not return items from other users", func(t *testing.T) {
// item from user 99 should not appear for user 2
other := PtplItem{Name: "Other User Item", UserID: 99}
require.NoError(t, database.DB.Create(&other).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(2))
c.Request = httptest.NewRequest(http.MethodGet, "/api/ptpl", nil)
ListItems(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp []PtplItem
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Empty(t, resp)
})
}
func TestCreateItem(t *testing.T) {
setupTestEnvironment(t)
t.Run("creates item successfully", func(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{
"name": "New Item",
"description": "Created via test",
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Request = httptest.NewRequest(http.MethodPost, "/api/ptpl", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
CreateItem(c)
assert.Equal(t, http.StatusCreated, w.Code)
var resp PtplItem
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "New Item", resp.Name)
assert.Equal(t, uint(1), resp.UserID)
})
t.Run("rejects invalid JSON", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Request = httptest.NewRequest(http.MethodPost, "/api/ptpl", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
CreateItem(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
func TestGetItem(t *testing.T) {
setupTestEnvironment(t)
t.Run("returns existing item", func(t *testing.T) {
item := PtplItem{Name: "Findable", Description: "Yes", UserID: 1}
require.NoError(t, database.DB.Create(&item).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", item.ID)}}
c.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/ptpl/%d", item.ID), nil)
GetItem(c)
assert.Equal(t, http.StatusOK, w.Code)
var resp PtplItem
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "Findable", resp.Name)
})
t.Run("returns 404 for non-existent item", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Params = gin.Params{{Key: "id", Value: "99999"}}
c.Request = httptest.NewRequest(http.MethodGet, "/api/ptpl/99999", nil)
GetItem(c)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("returns 404 for item owned by another user", func(t *testing.T) {
item := PtplItem{Name: "Private", UserID: 99}
require.NoError(t, database.DB.Create(&item).Error)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("userID", uint(1))
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", item.ID)}}
c.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/ptpl/%d", item.ID), nil)
GetItem(c)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
func TestMigrateStructure(t *testing.T) {
setupTestEnvironment(t)
// Migration already ran in setup; verify the table exists by inserting a row
item := PtplItem{Name: "Migration Test", UserID: 1}
err := database.DB.Create(&item).Error
assert.NoError(t, err)
assert.NotZero(t, item.ID)
}
+11
View File
@@ -0,0 +1,11 @@
package ptpl
import "gorm.io/gorm"
// PtplItem is a sample GORM entity owned by this module.
type PtplItem struct {
gorm.Model
Name string `gorm:"type:varchar(255);not null" json:"name"`
Description string `json:"description"`
UserID uint `gorm:"index" json:"user_id"`
}
+16
View File
@@ -0,0 +1,16 @@
package ptpl
import "bamort/registry"
// init self-registers the ptpl module with the central registry.
// main.go blank-imports this package to trigger this function.
func init() {
// Protected API routes (/api/ptpl/*).
registry.RegisterRoutes(RegisterRoutes)
// Public routes.
registry.RegisterPublicRoutes(RegisterPublicRoutes)
// Database migration for PtplItem model.
registry.RegisterMigration(MigrateStructure)
}
+20
View File
@@ -0,0 +1,20 @@
package ptpl
import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes registers protected API routes (/api/ptpl/*).
// All routes require a valid JWT (auth middleware is applied by the router).
func RegisterRoutes(r *gin.RouterGroup) {
grp := r.Group("/ptpl")
grp.GET("", ListItems)
grp.POST("", CreateItem)
grp.GET("/:id", GetItem)
}
// RegisterPublicRoutes registers public routes (no authentication required).
func RegisterPublicRoutes(r *gin.Engine) {
pub := r.Group("/public/ptpl")
pub.GET("/info", GetPublicInfo)
}
+1
View File
@@ -18,6 +18,7 @@ import (
_ "bamort/bmrt/maintenance"
_ "bamort/bmrt/models"
_ "bamort/bmrt/pdfrender"
_ "bamort/bmrt/ptpl"
_ "bamort/bmrt/transfer"
_ "bamort/user"