Adds GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1, PC-201) on top of the typed settings processor. The response shows every setting with its default when unset, the organization's point value, and the effective EnakPoint cashback (earn_value × point_value / earn_per_amount), so an owner cannot misread the scale. PUT applies the body on top of the current settings: fields left out keep their value, null clears an optional limit, and unknown fields are refused so a typo cannot be ignored silently. The read-only fields of the GET response are accepted and ignored, so a client can send back what it received. It returns the keys that changed. Values outside the F1 bounds answer 400, and an outlet of another organization 404. RequireAdminOrManager also lets the purchasing role through, so loyalty settings and the manual wallet adjustment from PC-107 now use a stricter RequireLoyaltyManager (superadmin, admin, manager, owner). Adds a test that registers every route, since gin panics at startup when two routes name the same path parameter differently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
7.7 KiB
Go
170 lines
7.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"apskel-pos-be/internal/appcontext"
|
|
applogger "apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/middleware"
|
|
"apskel-pos-be/internal/processor"
|
|
"apskel-pos-be/internal/repository"
|
|
"apskel-pos-be/internal/service"
|
|
)
|
|
|
|
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
|
// TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
applogger.Setup("fatal", "json")
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
|
|
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
require.NoError(t, db.Exec(q, args...).Error)
|
|
}
|
|
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
|
|
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
|
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
|
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
|
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
|
})
|
|
|
|
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
|
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings))
|
|
auth := middleware.NewAuthMiddleware(nil)
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
|
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
|
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
|
c.Request = c.Request.WithContext(ctx)
|
|
}
|
|
}
|
|
for prefix, who := range map[string]struct {
|
|
org uuid.UUID
|
|
role string
|
|
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
|
g := router.Group(prefix, as(who.org, who.role))
|
|
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
|
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
|
}
|
|
call := func(method, path string, body string) (int, map[string]any) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
|
return rec.Code, out
|
|
}
|
|
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
|
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
|
|
|
// An outlet without settings shows every default and 1% cashback.
|
|
status, body := call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
got := data(body)
|
|
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
|
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
|
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
|
assert.EqualValues(t, 1, got["point_value"])
|
|
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
|
|
|
// A partial update keeps everything it does not mention.
|
|
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
got = data(body)
|
|
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
|
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
|
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
|
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
|
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
|
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
|
assert.Len(t, got["changes"], 3)
|
|
|
|
// The cashback follows the organization's point value.
|
|
orgSettings, err := settings.Organization(context.Background(), org)
|
|
require.NoError(t, err)
|
|
orgSettings.PointValue = 5
|
|
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
|
require.NoError(t, err)
|
|
status, body = call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
|
|
|
// Sending back what GET returned changes nothing.
|
|
echo, err := json.Marshal(data(body))
|
|
require.NoError(t, err)
|
|
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Empty(t, data(body)["changes"])
|
|
|
|
// null clears a limit.
|
|
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
|
|
|
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
|
for name, bad := range map[string]string{
|
|
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
|
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
|
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
|
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
|
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
|
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
|
"wrong type": `{"point": {"enabled": "yes"}}`,
|
|
"not json": `enabled=true`,
|
|
} {
|
|
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
|
assert.Equal(t, http.StatusBadRequest, status, name)
|
|
}
|
|
status, body = call(http.MethodGet, "/manager"+path, "")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
|
|
|
// Purchasing staff can read but not change; another organization sees nothing.
|
|
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
|
assert.Equal(t, http.StatusForbidden, status)
|
|
status, _ = call(http.MethodGet, "/other"+path, "")
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
|
|
// Every change is in the history with who made it.
|
|
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
|
for _, c := range history.Data {
|
|
assert.Equal(t, admin, c.ChangedBy)
|
|
}
|
|
}
|